feat: add member URL pool and login tracking

This commit is contained in:
richjohnrambo
2026-10-15 10:41:29 +08:00
parent ca0d115e36
commit f8b5e4270b
46 changed files with 3733 additions and 66 deletions
+7
View File
@@ -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);
+13
View File
@@ -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);
+18 -19
View File
@@ -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 });
+7 -1
View File
@@ -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']);
}
+194
View File
@@ -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']);
}