feat: add member URL pool and login tracking
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { http, jumpExport } from '@/utils/http/axios';
|
||||
|
||||
export const List = (params) =>
|
||||
http.request({ url: '/appMemberLoginLog/list', method: 'get', params });
|
||||
export const View = (params) =>
|
||||
http.request({ url: '/appMemberLoginLog/view', method: 'get', params });
|
||||
export const Export = (params) => jumpExport('/appMemberLoginLog/export', params);
|
||||
@@ -0,0 +1,13 @@
|
||||
import { http } from '@/utils/http/axios';
|
||||
import type { UploadFileParams } from '@/utils/http/axios/types';
|
||||
|
||||
const request = (url: string, method: 'get' | 'POST', params?: any) =>
|
||||
http.request({ url, method, params });
|
||||
|
||||
export const List = (params) => request('/urlPool/list', 'get', params);
|
||||
export const View = (params) => request('/urlPool/view', 'get', params);
|
||||
export const Edit = (params) => request('/urlPool/edit', 'POST', params);
|
||||
export const Status = (params) => request('/urlPool/status', 'POST', params);
|
||||
export const ImportExcel = (params: UploadFileParams) => http.uploadFile({ url: '/urlPool/import', method: 'post' }, params);
|
||||
export const MemberList = (params) => request('/urlPool/memberList', 'get', params);
|
||||
export const Summary = (params = {}) => request('/urlPool/summary', 'get', params);
|
||||
@@ -22,13 +22,7 @@
|
||||
:action-column="actionColumn"
|
||||
:scroll-x="scrollX"
|
||||
:resize-height-offset="-10000"
|
||||
>
|
||||
<template #tableTitle>
|
||||
<n-button v-if="hasPermission(['/appMember/edit'])" type="primary" @click="openEdit()"
|
||||
>新增会员</n-button
|
||||
>
|
||||
</template>
|
||||
</BasicTable>
|
||||
/>
|
||||
<div
|
||||
class="action-column-scrollbar"
|
||||
aria-label="操作栏横向滚动条"
|
||||
@@ -42,7 +36,7 @@
|
||||
<n-modal
|
||||
v-model:show="showEdit"
|
||||
preset="card"
|
||||
:title="editForm.id ? '编辑会员' : '新增会员'"
|
||||
title="编辑会员"
|
||||
class="edit-modal"
|
||||
>
|
||||
<n-spin :show="editLoading">
|
||||
@@ -63,12 +57,10 @@
|
||||
<n-form-item label="用户名称" path="username" required
|
||||
><n-input v-model:value="editForm.username" placeholder="请输入用户名称"
|
||||
/></n-form-item>
|
||||
<n-form-item label="手机号码" path="mobile" :required="!editForm.id"
|
||||
<n-form-item label="手机号码" path="mobile"
|
||||
><n-input
|
||||
v-model:value="editForm.mobile"
|
||||
:placeholder="
|
||||
editForm.id ? editForm.currentMobile || '留空不修改' : '请输入手机号码'
|
||||
"
|
||||
:placeholder="editForm.currentMobile || '留空不修改'"
|
||||
/></n-form-item>
|
||||
<n-form-item label="账户余额"
|
||||
><n-input :value="money(editForm.availableAmount)" disabled
|
||||
@@ -92,12 +84,12 @@
|
||||
v-model:value="editForm.riskStatus"
|
||||
:options="dict.getOptionUnRef('appMemberRiskStatus')"
|
||||
/></n-form-item>
|
||||
<n-form-item label="登录密码" path="password" :required="!editForm.id"
|
||||
<n-form-item label="登录密码" path="password"
|
||||
><n-input
|
||||
v-model:value="editForm.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="editForm.id ? '留空不修改' : '至少8位,含字母和数字'"
|
||||
placeholder="留空不修改"
|
||||
/></n-form-item>
|
||||
<n-form-item label="交易密码" path="tradePassword"
|
||||
><n-input
|
||||
@@ -151,6 +143,9 @@
|
||||
<n-descriptions-item label="收货地址">{{ detail.addressCount }} 个</n-descriptions-item>
|
||||
<n-descriptions-item label="下级团队">{{ detail.teamCount }} 人</n-descriptions-item>
|
||||
<n-descriptions-item label="注册时间">{{ detail.createdAt }}</n-descriptions-item>
|
||||
<n-descriptions-item label="注册域名">{{ detail.registerDomain || '待绑定' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="域名状态">{{ dict.getLabel('appMemberDomainStatus', detail.domainStatus) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="绑定时间">{{ detail.domainBoundAt || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-divider />
|
||||
<n-space>
|
||||
@@ -469,14 +464,13 @@
|
||||
{ min: 3, max: 64, message: '用户名长度为3到64个字符', trigger: ['input', 'blur'] },
|
||||
],
|
||||
mobile: {
|
||||
validator: (_rule, value) =>
|
||||
editForm.id || String(value || '').trim() ? true : new Error('请输入手机号码'),
|
||||
validator: () => true,
|
||||
trigger: ['input', 'blur'],
|
||||
},
|
||||
password: {
|
||||
validator: (_rule, value) => {
|
||||
const text = String(value || '');
|
||||
if (editForm.id && !text) return true;
|
||||
if (!text) return true;
|
||||
if (text.length < 8 || !/[A-Za-z]/.test(text) || !/\d/.test(text))
|
||||
return new Error('密码至少8位且必须同时包含字母和数字');
|
||||
return true;
|
||||
@@ -511,6 +505,12 @@
|
||||
h(TableAction as any, {
|
||||
style: 'button',
|
||||
actions: [
|
||||
{
|
||||
label: '详情',
|
||||
type: 'default',
|
||||
onClick: () => openDetail(record.id),
|
||||
auth: ['/appMember/view'],
|
||||
},
|
||||
{
|
||||
label: '编辑',
|
||||
type: 'primary',
|
||||
@@ -602,7 +602,7 @@
|
||||
actionScrollLeft = externalMax > 0 ? (el.scrollLeft * actionMax) / externalMax : 0;
|
||||
syncActionViewports(actionScrollLeft);
|
||||
}
|
||||
async function openEdit(row?: MemberRecord) {
|
||||
async function openEdit(row: MemberRecord) {
|
||||
reset(editForm, {
|
||||
id: row?.id || 0,
|
||||
username: row?.username || '',
|
||||
@@ -621,7 +621,6 @@
|
||||
frozenAmount: row?.frozenAmount || 0,
|
||||
});
|
||||
showEdit.value = true;
|
||||
if (!row?.id) return;
|
||||
editLoading.value = true;
|
||||
try {
|
||||
const data: any = await View({ id: row.id });
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface MemberRecord {
|
||||
availableAmount: number; frozenAmount: number; parentId: number; parentUsername: string;
|
||||
inviteCode: string; memberStatus: number; riskStatus: number; autoDispatch: number; memberCategory: number;
|
||||
isTestAccount: number; lastLoginAt?: string; lastLoginIpMask: string; lastLoginRegion: string;
|
||||
urlPoolId: number; registerDomain: string; domainStatus: number; domainBoundAt?: string;
|
||||
todayTradeCount: number; createdAt: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +21,8 @@ export const schemas = ref<FormSchema[]>([
|
||||
{ field: 'memberStatus', component: 'NSelect', label: '状态', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberStatus')), clearable: true } },
|
||||
{ field: 'autoDispatch', component: 'NSelect', label: '派单模式', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberDispatchMode')), clearable: true } },
|
||||
{ field: 'memberCategory', component: 'NSelect', label: '类别', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberCategory')), clearable: true } },
|
||||
{ field: 'registerDomain', component: 'NInput', label: '注册域名', componentProps: { placeholder: '请输入域名关键词' } },
|
||||
{ field: 'domainStatus', component: 'NSelect', label: '域名状态', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberDomainStatus')), clearable: true } },
|
||||
{ field: 'createdAt', component: 'NDatePicker', label: '注册时间', componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() } },
|
||||
]);
|
||||
|
||||
@@ -42,9 +45,12 @@ export const columns = [
|
||||
{ title: '今日交易', key: 'todayTradeCount', width: 90 },
|
||||
{ title: '最后登录IP', key: 'lastLoginIpMask', width: 130, render: (row: MemberRecord) => row.lastLoginIpMask || '-' },
|
||||
{ title: '最后登录地区', key: 'lastLoginRegion', width: 140, render: (row: MemberRecord) => row.lastLoginRegion || '-' },
|
||||
{ title: '注册域名', key: 'registerDomain', width: 220, render: (row: MemberRecord) => row.registerDomain || '-' },
|
||||
{ title: '域名状态', key: 'domainStatus', width: 110, render: (row: MemberRecord) => tag('appMemberDomainStatus', row.domainStatus) },
|
||||
{ title: '绑定时间', key: 'domainBoundAt', width: 180, render: (row: MemberRecord) => row.domainBoundAt || '-' },
|
||||
{ title: '注册时间', key: 'createdAt', width: 180 },
|
||||
];
|
||||
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['appMemberStatus', 'appMemberRiskStatus', 'appMemberDispatchMode', 'appMemberCategory', 'paymentAccountType', 'walletAdjustType', 'walletDirection', 'sys_switch', 'member_disable_reason', 'wallet_adjust_reason']);
|
||||
dict.loadOptions(['appMemberStatus', 'appMemberRiskStatus', 'appMemberDispatchMode', 'appMemberCategory', 'appMemberDomainStatus', 'paymentAccountType', 'walletAdjustType', 'walletDirection', 'sys_switch', 'member_disable_reason', 'wallet_adjust_reason']);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header"><n-card :bordered="false" title="会员登录日志">记录C端会员每次登录成功或失败,敏感信息均脱敏展示。</n-card></div>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="row => row.id" :action-column="actionColumn" :scroll-x="scrollX" :resize-height-offset="-10000">
|
||||
<template #tableTitle><n-button v-if="hasPermission(['/appMemberLoginLog/export'])" type="primary" @click="exportRows">导出</n-button></template>
|
||||
</BasicTable>
|
||||
</n-card>
|
||||
<n-modal v-model:show="showDetail" preset="card" title="登录日志详情" class="detail-modal">
|
||||
<n-spin :show="detailLoading">
|
||||
<n-descriptions v-if="detail.id" bordered :column="2" label-placement="left">
|
||||
<n-descriptions-item label="日志ID">{{ detail.id }}</n-descriptions-item>
|
||||
<n-descriptions-item label="请求ID">{{ detail.reqId }}</n-descriptions-item>
|
||||
<n-descriptions-item label="会员编号">{{ detail.memberNo || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="用户名">{{ detail.username || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="手机号">{{ detail.mobileMask || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录方式">{{ dict.getLabel('appMemberLoginTypeOptions', detail.loginType) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录状态">{{ dict.getLabel('sys_login_status', detail.status) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录时间">{{ detail.loginAt }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录IP">{{ detail.loginIpMask || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录地区">{{ detail.loginRegion || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="登录域名">{{ detail.loginDomain || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="操作系统">{{ detail.os }}</n-descriptions-item>
|
||||
<n-descriptions-item label="浏览器">{{ detail.browser }}</n-descriptions-item>
|
||||
<n-descriptions-item label="失败原因">{{ detail.errMsg || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="User-Agent" :span="2">{{ detail.userAgent || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import { List, View, Export } from '@/api/appMemberLoginLog';
|
||||
import { columns, schemas, loadOptions } from './model';
|
||||
|
||||
const dict = useDictStore();
|
||||
const { hasPermission } = usePermission();
|
||||
const actionRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const showDetail = ref(false);
|
||||
const detailLoading = ref(false);
|
||||
const detail = reactive<any>({});
|
||||
const [register] = useForm({ gridProps: { cols: '1 s:1 m:2 l:3' }, labelWidth: 80, schemas });
|
||||
const loadDataTable = (res) => List({ ...searchFormRef.value?.formModel, ...res });
|
||||
const reloadTable = () => actionRef.value?.reload();
|
||||
const actionColumn = reactive({ width: 100, title: '操作', key: 'action', fixed: 'right', render: (row) => h(TableAction as any, { style: 'button', actions: [{ label: '详情', onClick: () => openDetail(row), auth: ['/appMemberLoginLog/view'] }] }) });
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
async function openDetail(row) { showDetail.value = true; detailLoading.value = true; try { Object.assign(detail, await View({ id: row.id })); } finally { detailLoading.value = false; } }
|
||||
function exportRows() { Export({ ...searchFormRef.value?.formModel }); }
|
||||
onMounted(loadOptions);
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
:deep(.detail-modal) { width: min(900px, 94vw); }
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { computed, h, ref } from 'vue';
|
||||
import { NTag } from 'naive-ui';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{ field: 'keyword', component: 'NInput', label: '会员', componentProps: { placeholder: '编号、用户名或手机号' } },
|
||||
{ field: 'loginType', component: 'NSelect', label: '登录方式', componentProps: { options: computed(() => dict.getOptionUnRef('appMemberLoginTypeOptions')), clearable: true } },
|
||||
{ field: 'status', component: 'NSelect', label: '登录状态', componentProps: { options: computed(() => dict.getOptionUnRef('sys_login_status')), clearable: true } },
|
||||
{ field: 'loginDomain', component: 'NInput', label: '登录域名', componentProps: { placeholder: '请输入登录域名' } },
|
||||
{ field: 'loginIp', component: 'NInput', label: '登录IP', componentProps: { placeholder: '请输入IP脱敏值' } },
|
||||
{ field: 'loginAt', component: 'NDatePicker', label: '登录时间', componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() } },
|
||||
]);
|
||||
|
||||
const tag = (type: string, value: number) =>
|
||||
h(NTag, { type: dict.getType(type, value), bordered: false }, { default: () => dict.getLabel(type, value) || '-' });
|
||||
|
||||
export const columns = [
|
||||
{ title: '日志ID', key: 'id', width: 90 },
|
||||
{ title: '会员编号', key: 'memberNo', width: 170, render: (row) => row.memberNo || '-' },
|
||||
{ title: '用户名', key: 'username', width: 130, render: (row) => row.username || '-' },
|
||||
{ title: '手机号', key: 'mobileMask', width: 130, render: (row) => row.mobileMask || '-' },
|
||||
{ title: '登录方式', key: 'loginType', width: 110, render: (row) => tag('appMemberLoginTypeOptions', row.loginType) },
|
||||
{ title: '状态', key: 'status', width: 90, render: (row) => tag('sys_login_status', row.status) },
|
||||
{ title: '登录时间', key: 'loginAt', width: 180 },
|
||||
{ title: '登录IP', key: 'loginIpMask', width: 130, render: (row) => row.loginIpMask || '-' },
|
||||
{ title: '登录地区', key: 'loginRegion', width: 160, render: (row) => row.loginRegion || '-' },
|
||||
{ title: '登录域名', key: 'loginDomain', width: 220, render: (row) => row.loginDomain || '-' },
|
||||
{ title: '操作系统', key: 'os', width: 140 },
|
||||
{ title: '浏览器', key: 'browser', width: 150 },
|
||||
{ title: '失败原因', key: 'errMsg', width: 180, ellipsis: { tooltip: true }, render: (row) => row.errMsg || '-' },
|
||||
];
|
||||
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['appMemberLoginTypeOptions', 'sys_login_status']);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="n-layout-page-header">
|
||||
<n-card :bordered="false" title="URL地址池">维护会员注册入口;会员仅在C端注册时自动绑定域名。</n-card>
|
||||
</div>
|
||||
<n-grid :cols="6" :x-gap="12" class="summary-grid">
|
||||
<n-gi v-for="item in summaryItems" :key="item.label">
|
||||
<n-card size="small"><n-statistic :label="item.label" :value="item.value" /></n-card>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-card :bordered="false" class="proCard">
|
||||
<BasicForm ref="searchFormRef" @register="register" @submit="reloadTable" @reset="reloadTable" />
|
||||
<BasicTable ref="actionRef" :columns="columns" :request="loadDataTable" :row-key="row => row.id" :action-column="actionColumn" :scroll-x="scrollX" :resize-height-offset="-10000">
|
||||
<template #tableTitle>
|
||||
<n-space>
|
||||
<n-button v-if="hasPermission(['/urlPool/edit'])" type="primary" @click="openEdit()">新增地址</n-button>
|
||||
<n-button v-if="hasPermission(['/urlPool/import'])" @click="openImport">Excel批量导入</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
</BasicTable>
|
||||
</n-card>
|
||||
|
||||
<n-modal v-model:show="showEdit" preset="card" :title="editForm.id ? '编辑地址' : '新增地址'" class="edit-modal">
|
||||
<n-form ref="editFormRef" :model="editForm" :rules="editRules" label-placement="left" label-width="110">
|
||||
<n-form-item label="地址名称" path="name"><n-input v-model:value="editForm.name" placeholder="例如:推广域名A" /></n-form-item>
|
||||
<n-form-item label="注册入口URL" path="baseUrl"><n-input v-model:value="editForm.baseUrl" placeholder="https://register.example.com/register" /></n-form-item>
|
||||
<n-form-item label="状态" path="status"><n-select v-model:value="editForm.status" :options="dict.getOptionUnRef('urlPoolStatus')" /></n-form-item>
|
||||
<n-form-item label="备注"><n-input v-model:value="editForm.remark" type="textarea" maxlength="500" show-count /></n-form-item>
|
||||
</n-form>
|
||||
<template #action><n-space justify="end"><n-button @click="showEdit=false">取消</n-button><n-button type="primary" :loading="saving" @click="saveAddress">保存</n-button></n-space></template>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showImport" preset="card" title="Excel批量导入地址" class="import-modal">
|
||||
<n-alert type="info" title="Excel格式说明" class="import-tip">
|
||||
第一行为表头,必须包含“地址名称”和“注册入口URL”,可选“备注”。所有成功导入的地址统一为启用状态;单次最多5000条,仅支持 .xlsx 或 .xlsm 文件。
|
||||
</n-alert>
|
||||
<n-upload
|
||||
directory-dnd
|
||||
name="file"
|
||||
:max="1"
|
||||
:disabled="importing"
|
||||
:custom-request="handleImport"
|
||||
accept=".xlsx,.xlsm,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel.sheet.macroEnabled.12"
|
||||
>
|
||||
<n-upload-dragger>
|
||||
<n-icon size="46" :depth="3"><CloudUploadOutlined /></n-icon>
|
||||
<n-text class="import-title">点击或拖动Excel文件到此处</n-text>
|
||||
<n-p depth="3">重复域名将自动跳过,格式错误的行会在导入结果中列出。</n-p>
|
||||
</n-upload-dragger>
|
||||
</n-upload>
|
||||
<n-spin :show="importing">
|
||||
<n-result v-if="importResult" :status="importResult.failedCount ? 'warning' : 'success'" title="导入完成">
|
||||
<template #description>
|
||||
共读取 {{ importResult.totalRows }} 条,成功 {{ importResult.successCount }} 条,重复跳过 {{ importResult.duplicateCount }} 条,失败 {{ importResult.failedCount }} 条。
|
||||
</template>
|
||||
<template v-if="importResult.issues?.length" #footer>
|
||||
<n-list bordered class="import-issues">
|
||||
<n-list-item v-for="issue in importResult.issues" :key="`${issue.row}-${issue.message}`">
|
||||
第 {{ issue.row }} 行:{{ issue.message }}
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</template>
|
||||
</n-result>
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
|
||||
<n-modal v-model:show="showDetail" preset="card" title="地址详情" class="detail-modal">
|
||||
<n-spin :show="detailLoading">
|
||||
<n-descriptions v-if="detail.id" bordered :column="2" label-placement="left">
|
||||
<n-descriptions-item label="地址名称">{{ detail.name }}</n-descriptions-item>
|
||||
<n-descriptions-item label="注册域名">{{ detail.domain }}</n-descriptions-item>
|
||||
<n-descriptions-item label="注册入口URL" :span="2">{{ detail.baseUrl }}</n-descriptions-item>
|
||||
<n-descriptions-item label="状态">{{ dict.getLabel('urlPoolStatus', detail.status) }}</n-descriptions-item>
|
||||
<n-descriptions-item label="累计绑定">{{ detail.bindCount }}</n-descriptions-item>
|
||||
<n-descriptions-item label="当前绑定">{{ detail.currentBoundCount }}</n-descriptions-item>
|
||||
<n-descriptions-item label="最后绑定">{{ detail.lastBoundAt || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">{{ detail.createdAt }}</n-descriptions-item>
|
||||
<n-descriptions-item label="备注">{{ detail.remark || '-' }}</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
<n-divider title-placement="left">绑定会员</n-divider>
|
||||
<BasicTable v-if="detail.id" ref="memberTableRef" :columns="memberColumns" :request="loadMemberData" :row-key="row => row.memberId" :scroll-x="800" :resize-height-offset="-10000" />
|
||||
</n-spin>
|
||||
</n-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { UploadCustomRequestOptions, useMessage } from 'naive-ui';
|
||||
import { CloudUploadOutlined } from '@vicons/antd';
|
||||
import { BasicTable, TableAction } from '@/components/Table';
|
||||
import { BasicForm, useForm } from '@/components/Form';
|
||||
import { usePermission } from '@/hooks/web/usePermission';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { adaTableScrollX } from '@/utils/hotgo';
|
||||
import { List, View, Edit, Status, ImportExcel, MemberList, Summary } from '@/api/urlPool';
|
||||
import type { UploadFileParams } from '@/utils/http/axios/types';
|
||||
import { columns, memberColumns, schemas, loadOptions } from './model';
|
||||
|
||||
const message = useMessage();
|
||||
const dict = useDictStore();
|
||||
const { hasPermission } = usePermission();
|
||||
const actionRef = ref();
|
||||
const memberTableRef = ref();
|
||||
const searchFormRef = ref<any>({});
|
||||
const editFormRef = ref<any>();
|
||||
const showEdit = ref(false);
|
||||
const showDetail = ref(false);
|
||||
const showImport = ref(false);
|
||||
const saving = ref(false);
|
||||
const importing = ref(false);
|
||||
const importResult = ref<any>(null);
|
||||
const detailLoading = ref(false);
|
||||
const summary = reactive<any>({ totalCount: 0, enabledCount: 0, invalidCount: 0, boundMemberCount: 0, unboundMemberCount: 0, invalidMemberCount: 0 });
|
||||
const detail = reactive<any>({});
|
||||
const editForm = reactive<any>({ id: 0, name: '', baseUrl: '', status: 1, remark: '' });
|
||||
const editRules = {
|
||||
name: { required: true, message: '请输入地址名称', trigger: ['input', 'blur'] },
|
||||
baseUrl: { required: true, message: '请输入注册入口URL', trigger: ['input', 'blur'] },
|
||||
status: { required: true, type: 'number', message: '请选择状态', trigger: ['change'] },
|
||||
};
|
||||
const summaryItems = computed(() => [
|
||||
{ label: 'URL总数', value: summary.totalCount },
|
||||
{ label: '启用URL', value: summary.enabledCount },
|
||||
{ label: '失效URL', value: summary.invalidCount },
|
||||
{ label: '已绑定会员', value: summary.boundMemberCount },
|
||||
{ label: '待绑定会员', value: summary.unboundMemberCount },
|
||||
{ label: '失效域名会员', value: summary.invalidMemberCount },
|
||||
]);
|
||||
const [register] = useForm({ gridProps: { cols: '1 s:1 m:2 l:4' }, labelWidth: 80, schemas });
|
||||
const loadDataTable = (res) => List({ ...searchFormRef.value?.formModel, ...res });
|
||||
const loadMemberData = (res) => MemberList({ urlPoolId: detail.id, ...res });
|
||||
const reloadTable = () => { actionRef.value?.reload(); loadSummary(); };
|
||||
const reset = (target, value) => { Object.keys(target).forEach(key => delete target[key]); Object.assign(target, value); };
|
||||
|
||||
const actionColumn = reactive({
|
||||
width: 290,
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render: (row) => h(TableAction as any, { style: 'button', actions: [
|
||||
{ label: '详情', onClick: () => openDetail(row), auth: ['/urlPool/view'] },
|
||||
{ label: '编辑', onClick: () => openEdit(row), auth: ['/urlPool/edit'] },
|
||||
{ label: row.status === 1 ? '设为失效' : '启用', type: row.status === 1 ? 'warning' : 'success', onClick: () => toggleStatus(row), auth: ['/urlPool/status'] },
|
||||
{ label: '复制链接', onClick: () => copyLink(row) },
|
||||
] }),
|
||||
});
|
||||
const scrollX = computed(() => adaTableScrollX(columns, actionColumn.width));
|
||||
|
||||
async function loadSummary() { Object.assign(summary, await Summary()); }
|
||||
async function openEdit(row?) {
|
||||
reset(editForm, { id: 0, name: '', baseUrl: '', status: 1, remark: '' });
|
||||
if (row) Object.assign(editForm, await View({ id: row.id }));
|
||||
showEdit.value = true;
|
||||
}
|
||||
async function saveAddress() {
|
||||
try { await editFormRef.value?.validate(); } catch { return; }
|
||||
saving.value = true;
|
||||
try { await Edit(editForm); message.success('保存成功'); showEdit.value = false; reloadTable(); } finally { saving.value = false; }
|
||||
}
|
||||
async function openDetail(row) {
|
||||
showDetail.value = true; detailLoading.value = true;
|
||||
try { reset(detail, await View({ id: row.id })); setTimeout(() => memberTableRef.value?.reload(), 0); } finally { detailLoading.value = false; }
|
||||
}
|
||||
async function toggleStatus(row) { await Status({ id: row.id, status: row.status === 1 ? 2 : 1 }); message.success('状态已更新'); reloadTable(); }
|
||||
async function copyLink(row) { await navigator.clipboard.writeText(row.baseUrl); message.success('注册链接已复制'); }
|
||||
function openImport() { importResult.value = null; showImport.value = true; }
|
||||
async function handleImport(options: UploadCustomRequestOptions) {
|
||||
if (!options.file.file) return;
|
||||
importing.value = true;
|
||||
try {
|
||||
const params: UploadFileParams = { file: options.file.file as File };
|
||||
importResult.value = await ImportExcel(params);
|
||||
options.onFinish();
|
||||
message.success(`成功导入 ${importResult.value.successCount} 条地址`);
|
||||
reloadTable();
|
||||
} catch {
|
||||
options.onError();
|
||||
} finally {
|
||||
importing.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(() => { loadOptions(); loadSummary(); });
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.summary-grid { margin-bottom: 12px; }
|
||||
:deep(.edit-modal) { width: min(680px, 94vw); }
|
||||
:deep(.detail-modal) { width: min(1100px, 96vw); }
|
||||
:deep(.import-modal) { width: min(720px, 94vw); }
|
||||
.import-tip { margin-bottom: 16px; }
|
||||
.import-title { display: block; margin-top: 8px; font-size: 16px; }
|
||||
.import-issues { max-height: 240px; overflow-y: auto; text-align: left; }
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
import { computed, h, ref } from 'vue';
|
||||
import { NTag } from 'naive-ui';
|
||||
import { FormSchema } from '@/components/Form';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { defRangeShortcuts } from '@/utils/dateUtil';
|
||||
|
||||
const dict = useDictStore();
|
||||
|
||||
export const schemas = ref<FormSchema[]>([
|
||||
{ field: 'name', component: 'NInput', label: '地址名称', componentProps: { placeholder: '请输入地址名称' } },
|
||||
{ field: 'domain', component: 'NInput', label: '注册域名', componentProps: { placeholder: '请输入域名关键词' } },
|
||||
{ field: 'status', component: 'NSelect', label: '状态', componentProps: { options: computed(() => dict.getOptionUnRef('urlPoolStatus')), clearable: true } },
|
||||
{ field: 'createdAt', component: 'NDatePicker', label: '创建时间', componentProps: { type: 'datetimerange', clearable: true, shortcuts: defRangeShortcuts() } },
|
||||
]);
|
||||
|
||||
export const columns = [
|
||||
{ title: 'ID', key: 'id', width: 80 },
|
||||
{ title: '地址名称', key: 'name', width: 150 },
|
||||
{ title: '注册域名', key: 'domain', width: 220 },
|
||||
{ title: '注册入口URL', key: 'baseUrl', width: 300, ellipsis: { tooltip: true } },
|
||||
{ title: '状态', key: 'status', width: 90, render: (row) => h(NTag, { type: dict.getType('urlPoolStatus', row.status), bordered: false }, { default: () => dict.getLabel('urlPoolStatus', row.status) }) },
|
||||
{ title: '当前绑定会员', key: 'currentBoundCount', width: 120 },
|
||||
{ title: '累计绑定次数', key: 'bindCount', width: 120 },
|
||||
{ title: '最后绑定时间', key: 'lastBoundAt', width: 180, render: (row) => row.lastBoundAt || '-' },
|
||||
{ title: '备注', key: 'remark', width: 180, ellipsis: { tooltip: true } },
|
||||
{ title: '创建时间', key: 'createdAt', width: 180 },
|
||||
];
|
||||
|
||||
export const memberColumns = [
|
||||
{ title: '会员编号', key: 'memberNo', width: 170 },
|
||||
{ title: '用户名', key: 'username', width: 140 },
|
||||
{ title: '手机号', key: 'mobile', width: 130 },
|
||||
{ title: '会员状态', key: 'memberStatus', width: 100, render: (row) => h(NTag, { type: dict.getType('appMemberStatus', row.memberStatus), bordered: false }, { default: () => dict.getLabel('appMemberStatus', row.memberStatus) }) },
|
||||
{ title: '绑定时间', key: 'boundAt', width: 180 },
|
||||
];
|
||||
|
||||
export function loadOptions() {
|
||||
dict.loadOptions(['urlPoolStatus', 'appMemberStatus']);
|
||||
}
|
||||
Reference in New Issue
Block a user