diff --git a/admin/src/pages/login/locale/index.ts b/admin/src/pages/login/locale/index.ts
index 3539c63..418e034 100644
--- a/admin/src/pages/login/locale/index.ts
+++ b/admin/src/pages/login/locale/index.ts
@@ -1,6 +1,6 @@
const i18n = {
'en-US': {
- 'login.form.title': 'Login to Arco Design Pro',
+ 'login.form.title': 'Sign in to OmniCard',
'login.form.userName.errMsg': 'Username cannot be empty',
'login.form.password.errMsg': 'Password cannot be empty',
'login.form.login.errMsg': 'Login error, please refresh and try again',
@@ -8,19 +8,19 @@ const i18n = {
'login.form.password.placeholder': 'Password: admin',
'login.form.rememberPassword': 'Remember password',
'login.form.forgetPassword': 'Forgot password',
- 'login.form.login': 'login',
- 'login.form.register': 'register account',
- 'login.banner.slogan1': 'Out-of-the-box high-quality template',
+ 'login.form.login': 'Sign in',
+ 'login.form.register': 'Create account',
+ 'login.banner.slogan1': 'Digital cards for global business',
'login.banner.subSlogan1':
- 'Rich page templates, covering most typical business scenarios',
- 'login.banner.slogan2': 'Built-in solutions to common problems',
+ 'NFC tap or scan to open. No app required.',
+ 'login.banner.slogan2': 'Visitor radar and lightweight CRM',
'login.banner.subSlogan2':
- 'Internationalization, routing configuration, state management everything',
- 'login.banner.slogan3': 'Access visualization enhancement tool AUX',
- 'login.banner.subSlogan3': 'Realize flexible block development',
+ 'See who visits, how long they stay, and who is ready to talk',
+ 'login.banner.slogan3': 'Bento Grid mini-sites',
+ 'login.banner.subSlogan3': 'One canvas for profile, cases, and articles',
},
'zh-CN': {
- 'login.form.title': '登录 Arco Design Pro',
+ 'login.form.title': '登录 OmniCard',
'login.form.userName.errMsg': '用户名不能为空',
'login.form.password.errMsg': '密码不能为空',
'login.form.login.errMsg': '登录出错,请刷新重试',
@@ -30,12 +30,12 @@ const i18n = {
'login.form.forgetPassword': '忘记密码',
'login.form.login': '登录',
'login.form.register': '注册账号',
- 'login.banner.slogan1': '开箱即用的高质量模板',
- 'login.banner.subSlogan1': '丰富的的页面模板,覆盖大多数典型业务场景',
- 'login.banner.slogan2': '内置了常见问题的解决方案',
- 'login.banner.subSlogan2': '国际化,路由配置,状态管理应有尽有',
- 'login.banner.slogan3': '接入可视化增强工具AUX',
- 'login.banner.subSlogan3': '实现灵活的区块式开发',
+ 'login.banner.slogan1': '全球商务智能数字名片',
+ 'login.banner.subSlogan1': 'NFC 触碰或扫码即开,无需下载',
+ 'login.banner.slogan2': '访客雷达与轻量 CRM',
+ 'login.banner.subSlogan2': '看清谁来过、停留多久、谁值得跟进',
+ 'login.banner.slogan3': 'Bento Grid 微官网',
+ 'login.banner.subSlogan3': '一张画布承载身份、案例与洞见',
},
};
diff --git a/admin/src/pages/nfc/batch/index.tsx b/admin/src/pages/nfc/batch/index.tsx
new file mode 100644
index 0000000..d4595f6
--- /dev/null
+++ b/admin/src/pages/nfc/batch/index.tsx
@@ -0,0 +1,280 @@
+import React, { useMemo, useState } from 'react';
+import {
+ Table,
+ Form,
+ Input,
+ InputNumber,
+ Select,
+ Card,
+ Tag,
+ Message,
+ Button,
+} from '@arco-design/web-react';
+import type { TableColumnProps } from '@arco-design/web-react';
+import axios from 'axios';
+import copy from 'copy-to-clipboard';
+import useLocale from '@/utils/useLocale';
+import { lightColors } from '@/style/tokens';
+import type { NFCMaterial } from '@/types/omni';
+import locale from './locale';
+
+const ADD_API = '/api/admin/nfc/device/add';
+const MIN_COUNT = 1;
+const MAX_COUNT = 200;
+const DEFAULT_COUNT = 1;
+const UID_MAX_LENGTH = 64;
+
+interface BatchInboundForm {
+ batchNo?: string;
+ material?: NFCMaterial;
+ count?: number;
+}
+
+interface BatchCardRow {
+ uid: string;
+ shortLink: string;
+ material: NFCMaterial;
+}
+
+interface CoolAddResponse {
+ code: number;
+ message?: string;
+}
+
+interface DeviceAddPayload {
+ uid: string;
+ batchNo: string;
+ material: NFCMaterial;
+ status: 'unbound';
+}
+
+const MATERIAL_VALUES: NFCMaterial[] = ['wood', 'metal', 'pvc'];
+
+function isNFCMaterial(value: unknown): value is NFCMaterial {
+ return value === 'wood' || value === 'metal' || value === 'pvc';
+}
+
+function readPayloadMessage(payload: unknown): string | undefined {
+ if (!payload || typeof payload !== 'object' || !('message' in payload)) {
+ return undefined;
+ }
+ const message = Reflect.get(payload, 'message');
+ return typeof message === 'string' && message ? message : undefined;
+}
+
+function resolveErrorMessage(error: unknown, fallback: string): string {
+ if (axios.isAxiosError(error)) {
+ const fromPayload = readPayloadMessage(error.response?.data);
+ if (fromPayload) {
+ return fromPayload;
+ }
+ if (error.message) {
+ return error.message;
+ }
+ }
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return fallback;
+}
+
+function getMaterialColor(material: NFCMaterial): string {
+ if (material === 'wood') {
+ return lightColors.warning;
+ }
+ if (material === 'metal') {
+ return lightColors.text2;
+ }
+ return lightColors.primary;
+}
+
+function createBatchUid(batchNo: string, index: number): string {
+ const stamp = Date.now().toString(36);
+ const rand = Math.random().toString(36).slice(2, 8);
+ return `${batchNo}-${index + 1}-${stamp}${rand}`.slice(0, UID_MAX_LENGTH);
+}
+
+function buildShortLink(uid: string): string {
+ return `${window.location.origin}/#/portal?uid=${encodeURIComponent(uid)}`;
+}
+
+function NfcBatchPage() {
+ const t = useLocale(locale) as Record
;
+ const requestFailText = t['nfcBatch.request.fail'];
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(false);
+ const [rows, setRows] = useState([]);
+
+ const handleInbound = async (values: BatchInboundForm) => {
+ const batchNo = values.batchNo?.trim() ?? '';
+ const material = isNFCMaterial(values.material) ? values.material : undefined;
+ const count = values.count;
+
+ if (!batchNo) {
+ Message.error(t['nfcBatch.validation.batchNo']);
+ return;
+ }
+ if (!material) {
+ Message.error(t['nfcBatch.validation.material']);
+ return;
+ }
+ if (
+ typeof count !== 'number' ||
+ !Number.isInteger(count) ||
+ count < MIN_COUNT ||
+ count > MAX_COUNT
+ ) {
+ Message.error(t['nfcBatch.validation.count']);
+ return;
+ }
+
+ const created: BatchCardRow[] = [];
+ setLoading(true);
+ try {
+ for (let index = 0; index < count; index += 1) {
+ const uid = createBatchUid(batchNo, index);
+ const body: DeviceAddPayload = {
+ uid,
+ batchNo,
+ material,
+ status: 'unbound',
+ };
+ const { data: payload } = await axios.post(
+ ADD_API,
+ body
+ );
+ if (payload.code === 1000) {
+ created.push({
+ uid,
+ shortLink: buildShortLink(uid),
+ material,
+ });
+ continue;
+ }
+ setRows(created);
+ Message.error(payload.message || requestFailText);
+ return;
+ }
+ setRows(created);
+ Message.success(t['nfcBatch.request.success']);
+ } catch (error: unknown) {
+ setRows(created);
+ Message.error(resolveErrorMessage(error, requestFailText));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCopyAll = () => {
+ if (!rows.length) {
+ Message.error(t['nfcBatch.copy.empty']);
+ return;
+ }
+ copy(rows.map((row) => row.shortLink).join('\n'));
+ Message.success(t['nfcBatch.copy.success']);
+ };
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ title: t['nfcBatch.columns.uid'],
+ dataIndex: 'uid',
+ },
+ {
+ title: t['nfcBatch.columns.shortLink'],
+ dataIndex: 'shortLink',
+ },
+ {
+ title: t['nfcBatch.columns.material'],
+ dataIndex: 'material',
+ render: (value: NFCMaterial) => {
+ const material = isNFCMaterial(value) ? value : 'pvc';
+ return (
+
+ {t[`nfcBatch.material.${material}`]}
+
+ );
+ },
+ },
+ ],
+ [t]
+ );
+
+ const materialOptions = MATERIAL_VALUES.map((value) => ({
+ label: t[`nfcBatch.material.${value}`],
+ value,
+ }));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default NfcBatchPage;
diff --git a/admin/src/pages/nfc/batch/locale/index.ts b/admin/src/pages/nfc/batch/locale/index.ts
new file mode 100644
index 0000000..0186642
--- /dev/null
+++ b/admin/src/pages/nfc/batch/locale/index.ts
@@ -0,0 +1,76 @@
+const i18n = {
+ 'en-US': {
+ 'menu.nfc.batch': 'Batch Cards',
+ 'nfcBatch.form.batchNo': 'Batch number',
+ 'nfcBatch.form.batchNo.placeholder': 'Enter a batch number',
+ 'nfcBatch.form.material': 'Material',
+ 'nfcBatch.form.material.placeholder': 'Select a material',
+ 'nfcBatch.form.count': 'Quantity',
+ 'nfcBatch.form.submit': 'Stock in',
+ 'nfcBatch.form.copyAll': 'Copy all short links',
+ 'nfcBatch.columns.uid': 'UID',
+ 'nfcBatch.columns.shortLink': 'Short link',
+ 'nfcBatch.columns.material': 'Material',
+ 'nfcBatch.material.wood': 'Wood',
+ 'nfcBatch.material.metal': 'Metal',
+ 'nfcBatch.material.pvc': 'PVC',
+ 'nfcBatch.validation.batchNo': 'Enter a batch number',
+ 'nfcBatch.validation.material': 'Select a material',
+ 'nfcBatch.validation.count': 'Enter a quantity between 1 and 200',
+ 'nfcBatch.request.fail': 'Unable to stock this batch',
+ 'nfcBatch.request.success': 'Cards stocked',
+ 'nfcBatch.copy.success': 'Short links copied',
+ 'nfcBatch.copy.empty': 'Stock a batch first',
+ 'nfcBatch.empty': 'No cards in this batch yet',
+ },
+ 'zh-CN': {
+ 'menu.nfc.batch': '批量制卡',
+ 'nfcBatch.form.batchNo': '批次号',
+ 'nfcBatch.form.batchNo.placeholder': '请输入批次号',
+ 'nfcBatch.form.material': '材质',
+ 'nfcBatch.form.material.placeholder': '请选择材质',
+ 'nfcBatch.form.count': '数量',
+ 'nfcBatch.form.submit': '入库',
+ 'nfcBatch.form.copyAll': '复制全部短链',
+ 'nfcBatch.columns.uid': 'UID',
+ 'nfcBatch.columns.shortLink': '短链',
+ 'nfcBatch.columns.material': '材质',
+ 'nfcBatch.material.wood': '木质',
+ 'nfcBatch.material.metal': '金属',
+ 'nfcBatch.material.pvc': 'PVC',
+ 'nfcBatch.validation.batchNo': '请输入批次号',
+ 'nfcBatch.validation.material': '请选择材质',
+ 'nfcBatch.validation.count': '请输入 1 到 200 的数量',
+ 'nfcBatch.request.fail': '本批入库失败',
+ 'nfcBatch.request.success': '已入库',
+ 'nfcBatch.copy.success': '短链已复制',
+ 'nfcBatch.copy.empty': '请先完成本批入库',
+ 'nfcBatch.empty': '本批暂无卡片',
+ },
+ 'ar-SA': {
+ 'menu.nfc.batch': 'بطاقات الدفعة',
+ 'nfcBatch.form.batchNo': 'رقم الدفعة',
+ 'nfcBatch.form.batchNo.placeholder': 'أدخل رقم الدفعة',
+ 'nfcBatch.form.material': 'الخامة',
+ 'nfcBatch.form.material.placeholder': 'اختر الخامة',
+ 'nfcBatch.form.count': 'الكمية',
+ 'nfcBatch.form.submit': 'إدخال المخزون',
+ 'nfcBatch.form.copyAll': 'نسخ كل الروابط القصيرة',
+ 'nfcBatch.columns.uid': 'UID',
+ 'nfcBatch.columns.shortLink': 'الرابط القصير',
+ 'nfcBatch.columns.material': 'الخامة',
+ 'nfcBatch.material.wood': 'خشب',
+ 'nfcBatch.material.metal': 'معدن',
+ 'nfcBatch.material.pvc': 'PVC',
+ 'nfcBatch.validation.batchNo': 'أدخل رقم الدفعة',
+ 'nfcBatch.validation.material': 'اختر الخامة',
+ 'nfcBatch.validation.count': 'أدخل كمية بين 1 و 200',
+ 'nfcBatch.request.fail': 'تعذر إدخال هذه الدفعة',
+ 'nfcBatch.request.success': 'تم إدخال البطاقات',
+ 'nfcBatch.copy.success': 'تم نسخ الروابط القصيرة',
+ 'nfcBatch.copy.empty': 'أدخل دفعة أولاً',
+ 'nfcBatch.empty': 'لا توجد بطاقات في هذه الدفعة بعد',
+ },
+};
+
+export default i18n;
diff --git a/admin/src/pages/nfc/devices/index.tsx b/admin/src/pages/nfc/devices/index.tsx
new file mode 100644
index 0000000..816e35e
--- /dev/null
+++ b/admin/src/pages/nfc/devices/index.tsx
@@ -0,0 +1,440 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ Table,
+ Form,
+ Input,
+ Select,
+ Card,
+ Tag,
+ Badge,
+ Message,
+ Button,
+ Popconfirm,
+} from '@arco-design/web-react';
+import type { PaginationProps, TableColumnProps } from '@arco-design/web-react';
+import axios from 'axios';
+import useLocale from '@/utils/useLocale';
+import { lightColors } from '@/style/tokens';
+import type {
+ NFCMaterial,
+ NFCRecord,
+ NFCSceneMode,
+ NFCStatus,
+} from '@/types/omni';
+import locale from './locale';
+
+const DEFAULT_PAGE = 1;
+const DEFAULT_SIZE = 10;
+const PAGE_API = '/api/admin/nfc/device/page';
+const FREEZE_API = '/api/admin/nfc/security/freeze';
+const RESET_API = '/api/admin/nfc/security/reset';
+
+interface DeviceListFilterForm {
+ keyWord?: string;
+ status?: NFCStatus;
+ material?: NFCMaterial;
+}
+
+interface CoolPagination {
+ page?: number;
+ size?: number;
+ total?: number;
+}
+
+interface CoolPageData {
+ list?: NFCRecord[];
+ pagination?: CoolPagination;
+}
+
+interface CoolPageResponse {
+ code: number;
+ message?: string;
+ data?: CoolPageData;
+}
+
+interface CoolActionResponse {
+ code: number;
+ message?: string;
+}
+
+interface DevicePageQuery {
+ page: number;
+ size: number;
+ keyWord?: string;
+ status?: NFCStatus;
+ material?: NFCMaterial;
+}
+
+const STATUS_VALUES: NFCStatus[] = ['active', 'suspended', 'unbound'];
+const MATERIAL_VALUES: NFCMaterial[] = ['wood', 'metal', 'pvc'];
+
+function isNFCStatus(value: unknown): value is NFCStatus {
+ return (
+ value === 'active' || value === 'suspended' || value === 'unbound'
+ );
+}
+
+function isNFCMaterial(value: unknown): value is NFCMaterial {
+ return value === 'wood' || value === 'metal' || value === 'pvc';
+}
+
+function isNFCSceneMode(value: unknown): value is NFCSceneMode {
+ return value === 'business' || value === 'social';
+}
+
+function readPayloadMessage(payload: unknown): string | undefined {
+ if (!payload || typeof payload !== 'object' || !('message' in payload)) {
+ return undefined;
+ }
+ const message = Reflect.get(payload, 'message');
+ return typeof message === 'string' && message ? message : undefined;
+}
+
+function resolveErrorMessage(error: unknown, fallback: string): string {
+ if (axios.isAxiosError(error)) {
+ const fromPayload = readPayloadMessage(error.response?.data);
+ if (fromPayload) {
+ return fromPayload;
+ }
+ if (error.message) {
+ return error.message;
+ }
+ }
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return fallback;
+}
+
+function getStatusColor(status: NFCStatus): string {
+ if (status === 'active') {
+ return lightColors.success;
+ }
+ if (status === 'suspended') {
+ return lightColors.error;
+ }
+ return lightColors.text2;
+}
+
+function getMaterialColor(material: NFCMaterial): string {
+ if (material === 'wood') {
+ return lightColors.warning;
+ }
+ if (material === 'metal') {
+ return lightColors.text2;
+ }
+ return lightColors.primary;
+}
+
+function getSceneColor(sceneMode: NFCSceneMode): string {
+ if (sceneMode === 'social') {
+ return lightColors.ai;
+ }
+ return lightColors.primary;
+}
+
+function formatCardId(value: NFCRecord['cardId'], emptyText: string): string {
+ if (value == null || value === '') {
+ return emptyText;
+ }
+ return String(value);
+}
+
+function formatLastScannedAt(
+ value: NFCRecord['lastScannedAt'],
+ emptyText: string
+): string {
+ if (!value) {
+ return emptyText;
+ }
+ if (value instanceof Date) {
+ return value.toISOString();
+ }
+ return String(value);
+}
+
+function NfcDevicesPage() {
+ const t = useLocale(locale) as Record;
+ const requestFailText = t['nfcDevices.request.fail'];
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(false);
+ const [data, setData] = useState([]);
+ const [page, setPage] = useState(DEFAULT_PAGE);
+ const [size, setSize] = useState(DEFAULT_SIZE);
+ const [total, setTotal] = useState(0);
+ const [filters, setFilters] = useState({});
+ const [reloadTick, setReloadTick] = useState(0);
+
+ const refreshList = useCallback(() => {
+ setReloadTick((tick) => tick + 1);
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const run = async () => {
+ const body: DevicePageQuery = {
+ page,
+ size,
+ };
+ const keyWord = filters.keyWord?.trim();
+ if (keyWord) {
+ body.keyWord = keyWord;
+ }
+ if (filters.status) {
+ body.status = filters.status;
+ }
+ if (filters.material) {
+ body.material = filters.material;
+ }
+
+ setLoading(true);
+ try {
+ const { data: payload } = await axios.post(
+ PAGE_API,
+ body
+ );
+ if (cancelled) {
+ return;
+ }
+ if (payload.code === 1000) {
+ setData(payload.data?.list ?? []);
+ setTotal(payload.data?.pagination?.total ?? 0);
+ return;
+ }
+ setData([]);
+ setTotal(0);
+ Message.error(payload.message || requestFailText);
+ } catch (error: unknown) {
+ if (cancelled) {
+ return;
+ }
+ setData([]);
+ setTotal(0);
+ Message.error(resolveErrorMessage(error, requestFailText));
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void run();
+ return () => {
+ cancelled = true;
+ };
+ }, [page, size, filters, requestFailText, reloadTick]);
+
+ const handleSearch = (values: DeviceListFilterForm) => {
+ setPage(DEFAULT_PAGE);
+ setFilters({
+ keyWord: values.keyWord,
+ status: values.status,
+ material: values.material,
+ });
+ };
+
+ const handleReset = () => {
+ form.resetFields();
+ setPage(DEFAULT_PAGE);
+ setFilters({});
+ };
+
+ const handleTableChange = (pagination: PaginationProps) => {
+ setPage(pagination.current ?? DEFAULT_PAGE);
+ setSize(pagination.pageSize ?? DEFAULT_SIZE);
+ };
+
+ const handleFreeze = useCallback(
+ async (record: NFCRecord) => {
+ try {
+ const { data: payload } = await axios.post(
+ FREEZE_API,
+ { uid: record.uid }
+ );
+ if (payload.code === 1000) {
+ Message.success(t['nfcDevices.freeze.success']);
+ refreshList();
+ return;
+ }
+ Message.error(payload.message || t['nfcDevices.freeze.fail']);
+ } catch (error: unknown) {
+ Message.error(resolveErrorMessage(error, t['nfcDevices.freeze.fail']));
+ }
+ },
+ [refreshList, t]
+ );
+
+ const handleResetDevice = useCallback(
+ async (record: NFCRecord) => {
+ try {
+ const { data: payload } = await axios.post(
+ RESET_API,
+ { uid: record.uid }
+ );
+ if (payload.code === 1000) {
+ Message.success(t['nfcDevices.reset.success']);
+ refreshList();
+ return;
+ }
+ Message.error(payload.message || t['nfcDevices.reset.fail']);
+ } catch (error: unknown) {
+ Message.error(resolveErrorMessage(error, t['nfcDevices.reset.fail']));
+ }
+ },
+ [refreshList, t]
+ );
+
+ const columns = useMemo[]>(
+ () => [
+ {
+ title: t['nfcDevices.columns.uid'],
+ dataIndex: 'uid',
+ },
+ {
+ title: t['nfcDevices.columns.batchNo'],
+ dataIndex: 'batchNo',
+ },
+ {
+ title: t['nfcDevices.columns.material'],
+ dataIndex: 'material',
+ render: (value: NFCMaterial) => {
+ const material = isNFCMaterial(value) ? value : 'pvc';
+ return (
+
+ {t[`nfcDevices.material.${material}`]}
+
+ );
+ },
+ },
+ {
+ title: t['nfcDevices.columns.cardId'],
+ dataIndex: 'cardId',
+ render: (value: NFCRecord['cardId']) =>
+ formatCardId(value, t['nfcDevices.cardId.empty']),
+ },
+ {
+ title: t['nfcDevices.columns.status'],
+ dataIndex: 'status',
+ render: (value: NFCStatus) => {
+ const status = isNFCStatus(value) ? value : 'unbound';
+ return (
+
+ );
+ },
+ },
+ {
+ title: t['nfcDevices.columns.sceneMode'],
+ dataIndex: 'sceneMode',
+ render: (value: NFCSceneMode) => {
+ const sceneMode = isNFCSceneMode(value) ? value : 'business';
+ return (
+
+ {t[`nfcDevices.scene.${sceneMode}`]}
+
+ );
+ },
+ },
+ {
+ title: t['nfcDevices.columns.lastScannedAt'],
+ dataIndex: 'lastScannedAt',
+ render: (value: NFCRecord['lastScannedAt']) =>
+ formatLastScannedAt(value, t['nfcDevices.lastScannedAt.empty']),
+ },
+ {
+ title: t['nfcDevices.columns.operations'],
+ dataIndex: 'operations',
+ render: (_value: unknown, record: NFCRecord) => (
+ <>
+ handleFreeze(record)}
+ >
+
+
+ handleResetDevice(record)}
+ >
+
+
+ >
+ ),
+ },
+ ],
+ [handleFreeze, handleResetDevice, t]
+ );
+
+ const pagination: PaginationProps = {
+ current: page,
+ pageSize: size,
+ total,
+ showTotal: true,
+ sizeCanChange: true,
+ pageSizeChangeResetCurrent: true,
+ };
+
+ const statusOptions = STATUS_VALUES.map((value) => ({
+ label: t[`nfcDevices.status.${value}`],
+ value,
+ }));
+
+ const materialOptions = MATERIAL_VALUES.map((value) => ({
+ label: t[`nfcDevices.material.${value}`],
+ value,
+ }));
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default NfcDevicesPage;
diff --git a/admin/src/pages/nfc/devices/locale/index.ts b/admin/src/pages/nfc/devices/locale/index.ts
new file mode 100644
index 0000000..34f66aa
--- /dev/null
+++ b/admin/src/pages/nfc/devices/locale/index.ts
@@ -0,0 +1,124 @@
+const i18n = {
+ 'en-US': {
+ 'menu.nfc.devices': 'Device Pool',
+ 'nfcDevices.filter.keyWord': 'Keyword',
+ 'nfcDevices.filter.keyWord.placeholder': 'Search UID or batch',
+ 'nfcDevices.filter.status': 'Status',
+ 'nfcDevices.filter.status.placeholder': 'All statuses',
+ 'nfcDevices.filter.material': 'Material',
+ 'nfcDevices.filter.material.placeholder': 'All materials',
+ 'nfcDevices.form.search': 'Search',
+ 'nfcDevices.form.reset': 'Clear',
+ 'nfcDevices.columns.uid': 'UID',
+ 'nfcDevices.columns.batchNo': 'Batch',
+ 'nfcDevices.columns.material': 'Material',
+ 'nfcDevices.columns.cardId': 'Card',
+ 'nfcDevices.columns.status': 'Status',
+ 'nfcDevices.columns.sceneMode': 'Scene',
+ 'nfcDevices.columns.lastScannedAt': 'Last scan',
+ 'nfcDevices.columns.operations': 'Actions',
+ 'nfcDevices.action.freeze': 'Report lost',
+ 'nfcDevices.action.reset': 'Reset',
+ 'nfcDevices.status.active': 'Active',
+ 'nfcDevices.status.suspended': 'Frozen',
+ 'nfcDevices.status.unbound': 'Unbound',
+ 'nfcDevices.material.wood': 'Wood',
+ 'nfcDevices.material.metal': 'Metal',
+ 'nfcDevices.material.pvc': 'PVC',
+ 'nfcDevices.scene.business': 'Business',
+ 'nfcDevices.scene.social': 'Social',
+ 'nfcDevices.request.fail': 'Unable to load devices',
+ 'nfcDevices.freeze.confirm': 'Freeze this card and mark it lost?',
+ 'nfcDevices.freeze.success': 'Card frozen',
+ 'nfcDevices.freeze.fail': 'Unable to freeze this card',
+ 'nfcDevices.reset.confirm': 'Reset this card and clear its binding?',
+ 'nfcDevices.reset.success': 'Card reset',
+ 'nfcDevices.reset.fail': 'Unable to reset this card',
+ 'nfcDevices.confirm.ok': 'Confirm',
+ 'nfcDevices.confirm.cancel': 'Cancel',
+ 'nfcDevices.cardId.empty': 'Unbound',
+ 'nfcDevices.lastScannedAt.empty': 'Never scanned',
+ },
+ 'zh-CN': {
+ 'menu.nfc.devices': '设备池',
+ 'nfcDevices.filter.keyWord': '关键词',
+ 'nfcDevices.filter.keyWord.placeholder': '搜索 UID 或批次号',
+ 'nfcDevices.filter.status': '状态',
+ 'nfcDevices.filter.status.placeholder': '全部状态',
+ 'nfcDevices.filter.material': '材质',
+ 'nfcDevices.filter.material.placeholder': '全部材质',
+ 'nfcDevices.form.search': '查询',
+ 'nfcDevices.form.reset': '清空',
+ 'nfcDevices.columns.uid': 'UID',
+ 'nfcDevices.columns.batchNo': '批次',
+ 'nfcDevices.columns.material': '材质',
+ 'nfcDevices.columns.cardId': '名片',
+ 'nfcDevices.columns.status': '状态',
+ 'nfcDevices.columns.sceneMode': '场景',
+ 'nfcDevices.columns.lastScannedAt': '最近扫描',
+ 'nfcDevices.columns.operations': '操作',
+ 'nfcDevices.action.freeze': '挂失',
+ 'nfcDevices.action.reset': '注销',
+ 'nfcDevices.status.active': '使用中',
+ 'nfcDevices.status.suspended': '已挂失',
+ 'nfcDevices.status.unbound': '未绑定',
+ 'nfcDevices.material.wood': '木质',
+ 'nfcDevices.material.metal': '金属',
+ 'nfcDevices.material.pvc': 'PVC',
+ 'nfcDevices.scene.business': '商务',
+ 'nfcDevices.scene.social': '社交',
+ 'nfcDevices.request.fail': '设备列表加载失败',
+ 'nfcDevices.freeze.confirm': '确认挂失并冻结该设备?',
+ 'nfcDevices.freeze.success': '设备已挂失',
+ 'nfcDevices.freeze.fail': '挂失失败',
+ 'nfcDevices.reset.confirm': '确认注销并解绑该设备?',
+ 'nfcDevices.reset.success': '设备已注销',
+ 'nfcDevices.reset.fail': '注销失败',
+ 'nfcDevices.confirm.ok': '确认',
+ 'nfcDevices.confirm.cancel': '取消',
+ 'nfcDevices.cardId.empty': '未绑定',
+ 'nfcDevices.lastScannedAt.empty': '暂无扫描',
+ },
+ 'ar-SA': {
+ 'menu.nfc.devices': 'مجموعة الأجهزة',
+ 'nfcDevices.filter.keyWord': 'كلمة البحث',
+ 'nfcDevices.filter.keyWord.placeholder': 'ابحث بمعرف الشريحة أو رقم الدفعة',
+ 'nfcDevices.filter.status': 'الحالة',
+ 'nfcDevices.filter.status.placeholder': 'كل الحالات',
+ 'nfcDevices.filter.material': 'المادة',
+ 'nfcDevices.filter.material.placeholder': 'كل المواد',
+ 'nfcDevices.form.search': 'بحث',
+ 'nfcDevices.form.reset': 'مسح',
+ 'nfcDevices.columns.uid': 'معرّف الشريحة',
+ 'nfcDevices.columns.batchNo': 'الدفعة',
+ 'nfcDevices.columns.material': 'المادة',
+ 'nfcDevices.columns.cardId': 'البطاقة',
+ 'nfcDevices.columns.status': 'الحالة',
+ 'nfcDevices.columns.sceneMode': 'المشهد',
+ 'nfcDevices.columns.lastScannedAt': 'آخر مسح',
+ 'nfcDevices.columns.operations': 'إجراءات',
+ 'nfcDevices.action.freeze': 'الإبلاغ عن الفقدان',
+ 'nfcDevices.action.reset': 'إعادة التعيين',
+ 'nfcDevices.status.active': 'نشط',
+ 'nfcDevices.status.suspended': 'مجمّد',
+ 'nfcDevices.status.unbound': 'غير مرتبط',
+ 'nfcDevices.material.wood': 'خشب',
+ 'nfcDevices.material.metal': 'معدن',
+ 'nfcDevices.material.pvc': 'PVC',
+ 'nfcDevices.scene.business': 'أعمال',
+ 'nfcDevices.scene.social': 'اجتماعي',
+ 'nfcDevices.request.fail': 'تعذر تحميل الأجهزة',
+ 'nfcDevices.freeze.confirm': 'هل تريد تجميد هذه البطاقة والإبلاغ عن فقدانها؟',
+ 'nfcDevices.freeze.success': 'تم تجميد البطاقة',
+ 'nfcDevices.freeze.fail': 'تعذر تجميد هذه البطاقة',
+ 'nfcDevices.reset.confirm': 'هل تريد إعادة تعيين هذه البطاقة وإلغاء ارتباطها؟',
+ 'nfcDevices.reset.success': 'تمت إعادة تعيين البطاقة',
+ 'nfcDevices.reset.fail': 'تعذر إعادة تعيين هذه البطاقة',
+ 'nfcDevices.confirm.ok': 'تأكيد',
+ 'nfcDevices.confirm.cancel': 'إلغاء',
+ 'nfcDevices.cardId.empty': 'غير مرتبط',
+ 'nfcDevices.lastScannedAt.empty': 'لم يُمسح بعد',
+ },
+};
+
+export default i18n;
diff --git a/admin/src/pages/nfc/rules/index.tsx b/admin/src/pages/nfc/rules/index.tsx
new file mode 100644
index 0000000..9a40148
--- /dev/null
+++ b/admin/src/pages/nfc/rules/index.tsx
@@ -0,0 +1,886 @@
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
+import {
+ Table,
+ Form,
+ Input,
+ Select,
+ Card,
+ Tag,
+ Message,
+ Button,
+ Popconfirm,
+ Modal,
+ TimePicker,
+ Switch,
+} from '@arco-design/web-react';
+import type { TableColumnProps, PaginationProps } from '@arco-design/web-react';
+import axios from 'axios';
+import useLocale from '@/utils/useLocale';
+import { lightColors, spacing, radius } from '@/style/tokens';
+import type { NFCSceneMode, NFCSceneRule } from '@/types/omni';
+import locale from './locale';
+
+const DEFAULT_PAGE = 1;
+const DEFAULT_SIZE = 10;
+const PAGE_API = '/api/admin/nfc/rule/page';
+const ADD_API = '/api/admin/nfc/rule/add';
+const UPDATE_API = '/api/admin/nfc/rule/update';
+const DELETE_API = '/api/admin/nfc/rule/delete';
+const RESOLVE_API = '/api/admin/nfc/rule/resolveScene';
+
+interface CoolPagination {
+ page?: number;
+ size?: number;
+ total?: number;
+}
+
+interface CoolPageData {
+ list?: NFCSceneRule[];
+ pagination?: CoolPagination;
+}
+
+interface CoolPageResponse {
+ code: number;
+ message?: string;
+ data?: CoolPageData;
+}
+
+interface CoolActionResponse {
+ code: number;
+ message?: string;
+ data?: unknown;
+}
+
+interface ResolveSceneResult {
+ targetCardId: string | number;
+ sceneMode: NFCSceneMode;
+}
+
+interface CoolResolveResponse {
+ code: number;
+ message?: string;
+ data?: ResolveSceneResult | null;
+}
+
+interface RuleFilterForm {
+ keyWord?: string;
+ sceneMode?: NFCSceneMode | 'all';
+ enabled?: 'all' | 'true' | 'false';
+ countryCode?: string;
+}
+
+interface RuleFormValues {
+ name: string;
+ deviceId?: string;
+ sceneMode: NFCSceneMode;
+ targetCardId: string;
+ timeRange?: string[];
+ weekdays?: number[];
+ countryCode?: string;
+ enabled?: boolean;
+}
+
+interface ResolveFormValues {
+ deviceId?: string;
+ time?: string;
+ countryCode?: string;
+}
+
+const WEEKDAY_NAMES: Record = {
+ 0: { en: 'Sun', zh: '周日' },
+ 1: { en: 'Mon', zh: '周一' },
+ 2: { en: 'Tue', zh: '周二' },
+ 3: { en: 'Wed', zh: '周三' },
+ 4: { en: 'Thu', zh: '周四' },
+ 5: { en: 'Fri', zh: '周五' },
+ 6: { en: 'Sat', zh: '周六' },
+};
+
+function readPayloadMessage(payload: unknown): string | undefined {
+ if (!payload || typeof payload !== 'object' || !('message' in payload)) {
+ return undefined;
+ }
+ const message = Reflect.get(payload, 'message');
+ return typeof message === 'string' && message ? message : undefined;
+}
+
+function resolveErrorMessage(error: unknown, fallback: string): string {
+ if (axios.isAxiosError(error)) {
+ const fromPayload = readPayloadMessage(error.response?.data);
+ if (fromPayload) {
+ return fromPayload;
+ }
+ if (error.message) {
+ return error.message;
+ }
+ }
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return fallback;
+}
+
+const NfcRulesPage: React.FC = () => {
+ const t = useLocale(locale);
+ const isZh = t['menu.nfc.rules'] === '场景路由规则';
+
+ const [loading, setLoading] = useState(false);
+ const [dataList, setDataList] = useState([]);
+ const [page, setPage] = useState(DEFAULT_PAGE);
+ const [size, setSize] = useState(DEFAULT_SIZE);
+ const [total, setTotal] = useState(0);
+
+ const [filterForm] = Form.useForm();
+ const [ruleModalVisible, setRuleModalVisible] = useState(false);
+ const [ruleModalMode, setRuleModalMode] = useState<'create' | 'edit'>('create');
+ const [editingRuleId, setEditingRuleId] = useState(null);
+ const [ruleForm] = Form.useForm();
+ const [submitting, setSubmitting] = useState(false);
+
+ // 模拟解析弹窗状态
+ const [resolveModalVisible, setResolveModalVisible] = useState(false);
+ const [resolveForm] = Form.useForm();
+ const [resolving, setResolving] = useState(false);
+ const [resolveResult, setResolveResult] = useState(
+ undefined
+ );
+
+ const fetchRules = useCallback(
+ async (targetPage: number = page, targetSize: number = size) => {
+ setLoading(true);
+ try {
+ const values = filterForm.getFieldsValue();
+ const payload: Record = {
+ page: targetPage,
+ size: targetSize,
+ };
+
+ if (values.keyWord?.trim()) {
+ payload.keyWord = values.keyWord.trim();
+ }
+ if (values.sceneMode && values.sceneMode !== 'all') {
+ payload.sceneMode = values.sceneMode;
+ }
+ if (values.enabled && values.enabled !== 'all') {
+ payload.enabled = values.enabled === 'true';
+ }
+ if (values.countryCode?.trim()) {
+ payload.countryCode = values.countryCode.trim().toUpperCase();
+ }
+
+ const res = await axios.post(PAGE_API, payload);
+ if (res.data?.code === 1000) {
+ const pageData = res.data.data;
+ setDataList(pageData?.list || []);
+ setTotal(pageData?.pagination?.total || 0);
+ setPage(pageData?.pagination?.page || targetPage);
+ setSize(pageData?.pagination?.size || targetSize);
+ } else {
+ Message.error(res.data?.message || t['nfcRules.request.fail']);
+ }
+ } catch (err) {
+ Message.error(resolveErrorMessage(err, t['nfcRules.request.fail']));
+ } finally {
+ setLoading(false);
+ }
+ },
+ [filterForm, page, size, t]
+ );
+
+ useEffect(() => {
+ fetchRules(DEFAULT_PAGE, DEFAULT_SIZE);
+ }, [fetchRules]);
+
+ const handleSearch = () => {
+ fetchRules(DEFAULT_PAGE, size);
+ };
+
+ const handleReset = () => {
+ filterForm.resetFields();
+ fetchRules(DEFAULT_PAGE, size);
+ };
+
+ const handleOpenCreate = () => {
+ setRuleModalMode('create');
+ setEditingRuleId(null);
+ ruleForm.resetFields();
+ ruleForm.setFieldsValue({
+ sceneMode: 'business',
+ enabled: true,
+ });
+ setRuleModalVisible(true);
+ };
+
+ const handleOpenEdit = (rule: NFCSceneRule) => {
+ setRuleModalMode('edit');
+ setEditingRuleId(rule.id);
+ ruleForm.resetFields();
+ ruleForm.setFieldsValue({
+ name: rule.name,
+ deviceId: rule.deviceId ? String(rule.deviceId) : '',
+ sceneMode: rule.sceneMode,
+ targetCardId: String(rule.targetCardId),
+ timeRange:
+ rule.startTime && rule.endTime ? [rule.startTime, rule.endTime] : undefined,
+ weekdays: rule.weekdays || undefined,
+ countryCode: rule.countryCode || '',
+ enabled: rule.enabled,
+ });
+ setRuleModalVisible(true);
+ };
+
+ const handleDelete = async (id: string | number) => {
+ try {
+ const res = await axios.post(DELETE_API, {
+ ids: [id],
+ });
+ if (res.data?.code === 1000) {
+ Message.success(t['nfcRules.delete.success']);
+ fetchRules(page, size);
+ } else {
+ Message.error(res.data?.message || t['nfcRules.request.fail']);
+ }
+ } catch (err) {
+ Message.error(resolveErrorMessage(err, t['nfcRules.request.fail']));
+ }
+ };
+
+ const handleToggleEnabled = async (rule: NFCSceneRule, newStatus: boolean) => {
+ try {
+ const res = await axios.post(UPDATE_API, {
+ id: rule.id,
+ enabled: newStatus,
+ });
+ if (res.data?.code === 1000) {
+ Message.success(t['nfcRules.status.success']);
+ setDataList(prev =>
+ prev.map(item =>
+ item.id === rule.id ? { ...item, enabled: newStatus } : item
+ )
+ );
+ } else {
+ Message.error(res.data?.message || t['nfcRules.request.fail']);
+ }
+ } catch (err) {
+ Message.error(resolveErrorMessage(err, t['nfcRules.request.fail']));
+ }
+ };
+
+ const handleSaveRule = async () => {
+ try {
+ const values = await ruleForm.validate();
+ setSubmitting(true);
+
+ const payload: Record = {
+ name: values.name.trim(),
+ deviceId: values.deviceId?.trim() ? Number(values.deviceId.trim()) : null,
+ sceneMode: values.sceneMode,
+ targetCardId: Number(values.targetCardId.trim()),
+ countryCode: values.countryCode?.trim()
+ ? values.countryCode.trim().toUpperCase()
+ : null,
+ weekdays: values.weekdays && values.weekdays.length > 0 ? values.weekdays : null,
+ enabled: values.enabled !== false,
+ };
+
+ if (values.timeRange && values.timeRange.length === 2) {
+ payload.startTime = values.timeRange[0];
+ payload.endTime = values.timeRange[1];
+ } else {
+ payload.startTime = null;
+ payload.endTime = null;
+ }
+
+ const endpoint = ruleModalMode === 'create' ? ADD_API : UPDATE_API;
+ if (ruleModalMode === 'edit') {
+ payload.id = editingRuleId;
+ }
+
+ const res = await axios.post(endpoint, payload);
+ if (res.data?.code === 1000) {
+ Message.success(t['nfcRules.save.success']);
+ setRuleModalVisible(false);
+ fetchRules(page, size);
+ } else {
+ Message.error(res.data?.message || t['nfcRules.request.fail']);
+ }
+ } catch (err) {
+ Message.error(resolveErrorMessage(err, t['nfcRules.request.fail']));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ const handleResolveSimulate = async () => {
+ try {
+ const values = await resolveForm.validate();
+ setResolving(true);
+ const payload: Record = {
+ tenantId: 1,
+ };
+
+ if (values.deviceId?.trim()) {
+ payload.deviceId = Number(values.deviceId.trim());
+ }
+ if (values.countryCode?.trim()) {
+ payload.countryCode = values.countryCode.trim().toUpperCase();
+ }
+ if (values.time) {
+ const today = new Date();
+ const [hh, mm] = values.time.split(':');
+ today.setHours(Number(hh) || 0, Number(mm) || 0, 0, 0);
+ payload.now = today.toISOString();
+ }
+
+ const res = await axios.post(RESOLVE_API, payload);
+ if (res.data?.code === 1000) {
+ setResolveResult(res.data.data || null);
+ } else {
+ Message.error(res.data?.message || t['nfcRules.request.fail']);
+ }
+ } catch (err) {
+ Message.error(resolveErrorMessage(err, t['nfcRules.request.fail']));
+ } finally {
+ setResolving(false);
+ }
+ };
+
+ const columns: TableColumnProps[] = useMemo(
+ () => [
+ {
+ title: t['nfcRules.columns.name'],
+ dataIndex: 'name',
+ width: 160,
+ render: (col: string) => (
+ {col}
+ ),
+ },
+ {
+ title: t['nfcRules.columns.deviceId'],
+ dataIndex: 'deviceId',
+ width: 140,
+ render: (deviceId: number | null | undefined) =>
+ deviceId ? (
+
+ #{deviceId}
+
+ ) : (
+
+ {t['nfcRules.columns.allDevices']}
+
+ ),
+ },
+ {
+ title: t['nfcRules.columns.sceneMode'],
+ dataIndex: 'sceneMode',
+ width: 120,
+ render: (mode: NFCSceneMode) => (
+
+ {mode === 'business'
+ ? t['nfcRules.mode.business']
+ : t['nfcRules.mode.social']}
+
+ ),
+ },
+ {
+ title: t['nfcRules.columns.targetCardId'],
+ dataIndex: 'targetCardId',
+ width: 140,
+ render: (cardId: number) => (
+
+ Card #{cardId}
+
+ ),
+ },
+ {
+ title: t['nfcRules.columns.timeRange'],
+ dataIndex: 'startTime',
+ width: 140,
+ render: (_: unknown, record: NFCSceneRule) =>
+ record.startTime && record.endTime ? (
+
+ {record.startTime} - {record.endTime}
+
+ ) : (
+
+ {t['nfcRules.columns.allDay']}
+
+ ),
+ },
+ {
+ title: t['nfcRules.columns.weekdays'],
+ dataIndex: 'weekdays',
+ width: 160,
+ render: (weekdays: number[] | null | undefined) => {
+ if (!weekdays || weekdays.length === 0 || weekdays.length === 7) {
+ return (
+
+ {t['nfcRules.columns.everyday']}
+
+ );
+ }
+ return (
+
+ {weekdays.map(day => (
+
+ {isZh ? WEEKDAY_NAMES[day]?.zh : WEEKDAY_NAMES[day]?.en}
+
+ ))}
+
+ );
+ },
+ },
+ {
+ title: t['nfcRules.columns.countryCode'],
+ dataIndex: 'countryCode',
+ width: 110,
+ render: (code: string | null | undefined) =>
+ code ? (
+
+ {code}
+
+ ) : (
+
+ {t['nfcRules.columns.allCountries']}
+
+ ),
+ },
+ {
+ title: t['nfcRules.columns.status'],
+ dataIndex: 'enabled',
+ width: 100,
+ render: (enabled: boolean, record: NFCSceneRule) => (
+ handleToggleEnabled(record, checked)}
+ />
+ ),
+ },
+ {
+ title: t['nfcRules.columns.actions'],
+ width: 140,
+ fixed: 'right',
+ render: (_: unknown, record: NFCSceneRule) => (
+
+
+
handleDelete(record.id)}
+ okText={t['nfcRules.action.submit']}
+ cancelText={t['nfcRules.action.cancel']}
+ >
+
+
+
+ ),
+ },
+ ],
+ [t, isZh]
+ );
+
+ const paginationProps: PaginationProps = {
+ current: page,
+ pageSize: size,
+ total,
+ showTotal: true,
+ sizeCanChange: true,
+ onChange: (newPage: number, newSize: number) => {
+ fetchRules(newPage, newSize);
+ },
+ };
+
+ return (
+
+ {/* 顶部筛选与单一主 CTA 区域 */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 全局唯一主 CTA: 新建规则 */}
+
+
+
+
+
+
+ {/* 规则数据表格 */}
+
+
+
+
+ {/* 新建 / 编辑规则 Modal */}
+
setRuleModalVisible(false)}
+ confirmLoading={submitting}
+ okText={t['nfcRules.action.submit']}
+ cancelText={t['nfcRules.action.cancel']}
+ okButtonProps={{
+ style: {
+ backgroundColor: lightColors.primary,
+ borderColor: lightColors.primary,
+ borderRadius: radius.button,
+ },
+ }}
+ cancelButtonProps={{
+ style: { borderRadius: radius.button },
+ }}
+ style={{ borderRadius: radius.modal }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* 触卡场景路由模拟解析 Modal */}
+
setResolveModalVisible(false)}
+ footer={null}
+ style={{ borderRadius: radius.modal }}
+ >
+
+ {t['nfcRules.modal.resolveDesc']}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {resolveResult !== undefined && (
+
+ {resolveResult ? (
+
+
+
+ {t['nfcRules.resolve.winnerCard']}:
+ {' '}
+
+ Card #{resolveResult.targetCardId}
+
+
+
+
+ {t['nfcRules.resolve.winnerMode']}:
+ {' '}
+
+ {resolveResult.sceneMode === 'business'
+ ? t['nfcRules.mode.business']
+ : t['nfcRules.mode.social']}
+
+
+
+ ) : (
+
+ {t['nfcRules.resolve.noMatch']}
+
+ )}
+
+ )}
+
+
+ );
+};
+
+export default NfcRulesPage;
diff --git a/admin/src/pages/nfc/rules/locale/index.ts b/admin/src/pages/nfc/rules/locale/index.ts
new file mode 100644
index 0000000..6be89b1
--- /dev/null
+++ b/admin/src/pages/nfc/rules/locale/index.ts
@@ -0,0 +1,96 @@
+const i18n = {
+ 'en-US': {
+ 'menu.nfc.rules': 'Routing Rules',
+ 'nfcRules.columns.id': 'Rule ID',
+ 'nfcRules.columns.name': 'Rule Name',
+ 'nfcRules.columns.deviceId': 'Target Device',
+ 'nfcRules.columns.allDevices': 'All Devices (Tenant Default)',
+ 'nfcRules.columns.sceneMode': 'Scene Mode',
+ 'nfcRules.columns.targetCardId': 'Target Card ID',
+ 'nfcRules.columns.timeRange': 'Effective Hours',
+ 'nfcRules.columns.allDay': 'All Day (24h)',
+ 'nfcRules.columns.weekdays': 'Effective Weekdays',
+ 'nfcRules.columns.everyday': 'Every Day',
+ 'nfcRules.columns.countryCode': 'Geo Country',
+ 'nfcRules.columns.allCountries': 'Global (All)',
+ 'nfcRules.columns.status': 'Status',
+ 'nfcRules.columns.enabled': 'Enabled',
+ 'nfcRules.columns.disabled': 'Disabled',
+ 'nfcRules.columns.actions': 'Actions',
+ 'nfcRules.columns.updateTime': 'Updated At',
+ 'nfcRules.mode.business': 'Business',
+ 'nfcRules.mode.social': 'Social',
+ 'nfcRules.action.create': 'New Rule',
+ 'nfcRules.action.edit': 'Edit',
+ 'nfcRules.action.delete': 'Delete',
+ 'nfcRules.action.deleteConfirm': 'Are you sure to delete this routing rule?',
+ 'nfcRules.action.resolve': 'Test Routing',
+ 'nfcRules.action.search': 'Search',
+ 'nfcRules.action.reset': 'Reset',
+ 'nfcRules.action.submit': 'Confirm',
+ 'nfcRules.action.cancel': 'Cancel',
+ 'nfcRules.modal.createTitle': 'Create Routing Rule',
+ 'nfcRules.modal.editTitle': 'Edit Routing Rule',
+ 'nfcRules.modal.resolveTitle': 'Simulate NFC Tap Routing',
+ 'nfcRules.modal.resolveDesc':
+ 'Simulate real-time NFC tap context to test routing resolution and priority.',
+ 'nfcRules.resolve.winnerCard': 'Resolved Target Card',
+ 'nfcRules.resolve.winnerMode': 'Resolved Scene Mode',
+ 'nfcRules.resolve.noMatch': 'No rule matched (fallback to device default)',
+ 'nfcRules.resolve.testBtn': 'Run Simulation',
+ 'nfcRules.request.fail': 'Request failed, please try again.',
+ 'nfcRules.save.success': 'Rule saved successfully.',
+ 'nfcRules.delete.success': 'Rule deleted successfully.',
+ 'nfcRules.status.success': 'Status updated successfully.',
+ 'nfcRules.validation.name': 'Rule name is required.',
+ 'nfcRules.validation.targetCardId': 'Target card ID is required.',
+ },
+ 'zh-CN': {
+ 'menu.nfc.rules': '场景路由规则',
+ 'nfcRules.columns.id': '规则 ID',
+ 'nfcRules.columns.name': '规则名称',
+ 'nfcRules.columns.deviceId': '绑定设备',
+ 'nfcRules.columns.allDevices': '全部设备 (租户默认)',
+ 'nfcRules.columns.sceneMode': '场景模式',
+ 'nfcRules.columns.targetCardId': '目标微官网名片 ID',
+ 'nfcRules.columns.timeRange': '生效时段',
+ 'nfcRules.columns.allDay': '全天 24 小时',
+ 'nfcRules.columns.weekdays': '生效星期',
+ 'nfcRules.columns.everyday': '每天',
+ 'nfcRules.columns.countryCode': '地理国家',
+ 'nfcRules.columns.allCountries': '全球不限',
+ 'nfcRules.columns.status': '状态',
+ 'nfcRules.columns.enabled': '已启用',
+ 'nfcRules.columns.disabled': '已停用',
+ 'nfcRules.columns.actions': '操作',
+ 'nfcRules.columns.updateTime': '更新时间',
+ 'nfcRules.mode.business': '商务展会',
+ 'nfcRules.mode.social': '社交晚宴',
+ 'nfcRules.action.create': '新建规则',
+ 'nfcRules.action.edit': '编辑',
+ 'nfcRules.action.delete': '删除',
+ 'nfcRules.action.deleteConfirm': '确定删除此场景路由规则吗?',
+ 'nfcRules.action.resolve': '模拟路由解析',
+ 'nfcRules.action.search': '查询',
+ 'nfcRules.action.reset': '重置',
+ 'nfcRules.action.submit': '确定',
+ 'nfcRules.action.cancel': '取消',
+ 'nfcRules.modal.createTitle': '新建场景路由规则',
+ 'nfcRules.modal.editTitle': '编辑场景路由规则',
+ 'nfcRules.modal.resolveTitle': '触卡场景路由模拟解析',
+ 'nfcRules.modal.resolveDesc':
+ '模拟触卡时间与国家环境,验证场景路由解析与优先级命中结果。',
+ 'nfcRules.resolve.winnerCard': '命中名片 ID',
+ 'nfcRules.resolve.winnerMode': '命中场景模式',
+ 'nfcRules.resolve.noMatch': '未命中任何规则(将回退至设备默认名片)',
+ 'nfcRules.resolve.testBtn': '执行模拟解析',
+ 'nfcRules.request.fail': '请求失败,请稍后重试。',
+ 'nfcRules.save.success': '规则保存成功。',
+ 'nfcRules.delete.success': '规则删除成功。',
+ 'nfcRules.status.success': '状态更新成功。',
+ 'nfcRules.validation.name': '请输入规则名称。',
+ 'nfcRules.validation.targetCardId': '请输入目标名片 ID。',
+ },
+};
+
+export default i18n;
diff --git a/admin/src/pages/settings/audit-logs/index.tsx b/admin/src/pages/settings/audit-logs/index.tsx
new file mode 100644
index 0000000..0d4057e
--- /dev/null
+++ b/admin/src/pages/settings/audit-logs/index.tsx
@@ -0,0 +1,445 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import {
+ Card,
+ Table,
+ DatePicker,
+ Select,
+ Tag,
+ Input,
+ Button,
+ Space,
+ Modal,
+ Message,
+ Typography,
+ Grid,
+} from '@arco-design/web-react';
+import type { ColumnProps } from '@arco-design/web-react/es/Table';
+import axios from 'axios';
+import useLocale from '@/utils/useLocale';
+import { lightColors, spacing, radius } from '@/style/tokens';
+import type { BaseAuditLog } from '@/types/omni';
+import locale from './locale';
+
+const AUDIT_PAGE_API = '/api/admin/base/audit/page';
+
+interface CoolPageResponse {
+ code: number;
+ message?: string;
+ data?: {
+ list: T[];
+ pagination: {
+ total: number;
+ page: number;
+ size: number;
+ };
+ };
+}
+
+interface FilterParams {
+ keyWord: string;
+ module: string;
+ status: string;
+ dateRange: string[];
+}
+
+const MODULE_OPTIONS = ['auth', 'card', 'crm', 'nfc', 'team', 'settings'];
+
+function SettingsAuditLogsPage() {
+ const t = useLocale(locale) as Record;
+
+ const [loading, setLoading] = useState(false);
+ const [dataList, setDataList] = useState([]);
+ const [total, setTotal] = useState(0);
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(10);
+
+ // 筛选状态
+ const [keyWord, setKeyWord] = useState('');
+ const [selectedModule, setSelectedModule] = useState('');
+ const [selectedStatus, setSelectedStatus] = useState('');
+ const [dateRange, setDateRange] = useState([]);
+
+ // 详情弹窗状态
+ const [detailVisible, setDetailVisible] = useState(false);
+ const [activeLog, setActiveLog] = useState(null);
+
+ // 拉取日志列表数据
+ const fetchAuditLogs = useCallback(
+ async (
+ currentPage: number,
+ currentSize: number,
+ filters: FilterParams
+ ) => {
+ setLoading(true);
+ try {
+ const payload: Record = {
+ page: currentPage,
+ size: currentSize,
+ };
+
+ if (filters.keyWord) {
+ payload.keyWord = filters.keyWord;
+ }
+ if (filters.module) {
+ payload.module = filters.module;
+ }
+ if (filters.status !== '') {
+ payload.status = Number(filters.status);
+ }
+ if (filters.dateRange && filters.dateRange.length === 2) {
+ payload.startDate = filters.dateRange[0];
+ payload.endDate = filters.dateRange[1];
+ }
+
+ const { data: res } = await axios.post>(
+ AUDIT_PAGE_API,
+ payload
+ );
+
+ if (res.code === 1000 && res.data) {
+ setDataList(res.data.list || []);
+ setTotal(res.data.pagination?.total || 0);
+ } else {
+ setDataList([]);
+ setTotal(0);
+ Message.error(res.message || t['auditLogs.request.fail']);
+ }
+ } catch (err: unknown) {
+ setDataList([]);
+ setTotal(0);
+ Message.error(t['auditLogs.request.fail']);
+ } finally {
+ setLoading(false);
+ }
+ },
+ [t]
+ );
+
+ useEffect(() => {
+ void fetchAuditLogs(page, pageSize, {
+ keyWord,
+ module: selectedModule,
+ status: selectedStatus,
+ dateRange,
+ });
+ }, [fetchAuditLogs, page, pageSize, keyWord, selectedModule, selectedStatus, dateRange]);
+
+ // 查询与重置
+ const handleSearch = () => {
+ setPage(1);
+ void fetchAuditLogs(1, pageSize, {
+ keyWord,
+ module: selectedModule,
+ status: selectedStatus,
+ dateRange,
+ });
+ };
+
+ const handleReset = () => {
+ setKeyWord('');
+ setSelectedModule('');
+ setSelectedStatus('');
+ setDateRange([]);
+ setPage(1);
+ };
+
+ const handleOpenDetail = (record: BaseAuditLog) => {
+ setActiveLog(record);
+ setDetailVisible(true);
+ };
+
+ const columns: ColumnProps[] = [
+ {
+ title: t['auditLogs.column.id'],
+ dataIndex: 'id',
+ width: 80,
+ },
+ {
+ title: t['auditLogs.column.module'],
+ dataIndex: 'module',
+ width: 130,
+ render: (moduleVal: string) => {
+ const label = t[`auditLogs.module.${moduleVal}`] || moduleVal;
+ return (
+
+ {label}
+
+ );
+ },
+ },
+ {
+ title: t['auditLogs.column.action'],
+ dataIndex: 'action',
+ width: 160,
+ render: (actionVal: string) => (
+
+ {actionVal}
+
+ ),
+ },
+ {
+ title: t['auditLogs.column.operator'],
+ dataIndex: 'operatorName',
+ width: 150,
+ render: (name: string, item: BaseAuditLog) => (
+
+
+ {name || '-'}
+
+ {item.operatorId ? (
+
+ UID: {item.operatorId}
+
+ ) : null}
+
+ ),
+ },
+ {
+ title: t['auditLogs.column.ip'],
+ dataIndex: 'ip',
+ width: 140,
+ render: (ipVal: string) => (
+
+ {ipVal || '-'}
+
+ ),
+ },
+ {
+ title: t['auditLogs.column.status'],
+ dataIndex: 'status',
+ width: 100,
+ render: (statusVal: number) => {
+ const isSuccess = Number(statusVal) === 1;
+ return (
+
+ {isSuccess
+ ? t['auditLogs.filter.status.success']
+ : t['auditLogs.filter.status.fail']}
+
+ );
+ },
+ },
+ {
+ title: t['auditLogs.column.time'],
+ dataIndex: 'createTime',
+ width: 180,
+ render: (timeVal: string) => (
+
+ {timeVal ? String(timeVal) : '-'}
+
+ ),
+ },
+ {
+ title: t['auditLogs.column.operation'],
+ dataIndex: 'operation',
+ width: 110,
+ fixed: 'right',
+ render: (_, record: BaseAuditLog) => (
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ {/* 顶部标题与说明 */}
+
+
+ {t['auditLogs.title']}
+
+
+ {t['auditLogs.subtitle']}
+
+
+
+ {/* 筛选面板 */}
+
+
+ setKeyWord(val)}
+ onSearch={handleSearch}
+ />
+
+
+
+
+
+
+
+
+ setDateRange(dates ? [...dates] : [])}
+ />
+
+
+
+ {/* 全屏唯一主 CTA 按钮 */}
+
+
+
+
+
+
+ {/* 表格 */}
+ {
+ setPage(p);
+ setPageSize(ps);
+ },
+ }}
+ />
+
+
+ {/* 审计日志快照详情弹窗 */}
+ setDetailVisible(false)}
+ onCancel={() => setDetailVisible(false)}
+ footer={null}
+ style={{ borderRadius: radius.modal }}
+ >
+ {activeLog ? (
+
+
+
+ {t['auditLogs.column.module']}:{' '}
+
+
+ {t[`auditLogs.module.${activeLog.module}`] || activeLog.module}
+
+
+
+
+ {t['auditLogs.column.action']}:{' '}
+
+
+ {activeLog.action}
+
+
+
+
+ {t['auditLogs.column.operator']}:{' '}
+
+
+ {activeLog.operatorName || '-'} (ID: {activeLog.operatorId ?? '-'})
+
+
+
+
+ {t['auditLogs.column.ip']}:{' '}
+
+
+ {activeLog.ip || '-'}
+
+
+
+
+ {t['auditLogs.column.time']}:{' '}
+
+
+ {activeLog.createTime ? String(activeLog.createTime) : '-'}
+
+
+
+
+ {t['auditLogs.detail.title']}:
+
+
+ {activeLog.detail || t['auditLogs.detail.none']}
+
+
+
+ ) : null}
+
+
+ );
+}
+
+export default SettingsAuditLogsPage;
diff --git a/admin/src/pages/settings/audit-logs/locale/index.ts b/admin/src/pages/settings/audit-logs/locale/index.ts
new file mode 100644
index 0000000..a1962b3
--- /dev/null
+++ b/admin/src/pages/settings/audit-logs/locale/index.ts
@@ -0,0 +1,110 @@
+const i18n = {
+ 'en-US': {
+ 'menu.settings.auditLogs': 'Audit Logs',
+ 'auditLogs.title': 'Security Audit Logs',
+ 'auditLogs.subtitle':
+ 'Comprehensive security trail tracking operator activities and system events.',
+ 'auditLogs.search.keywordPlaceholder': 'Search operator, action or IP',
+ 'auditLogs.filter.module': 'Module',
+ 'auditLogs.filter.module.all': 'All Modules',
+ 'auditLogs.filter.status': 'Status',
+ 'auditLogs.filter.status.all': 'All Statuses',
+ 'auditLogs.filter.status.success': 'Success',
+ 'auditLogs.filter.status.fail': 'Failure',
+ 'auditLogs.filter.dateRange': 'Date Range',
+ 'auditLogs.action.query': 'Search',
+ 'auditLogs.action.reset': 'Reset',
+ 'auditLogs.action.refresh': 'Refresh',
+ 'auditLogs.action.viewDetail': 'View Detail',
+ 'auditLogs.column.id': 'ID',
+ 'auditLogs.column.module': 'Module',
+ 'auditLogs.column.action': 'Action',
+ 'auditLogs.column.operator': 'Operator',
+ 'auditLogs.column.ip': 'IP Address',
+ 'auditLogs.column.status': 'Status',
+ 'auditLogs.column.time': 'Timestamp',
+ 'auditLogs.column.operation': 'Operation',
+ 'auditLogs.detail.title': 'Audit Log Snapshot Detail',
+ 'auditLogs.detail.operatorId': 'Operator ID',
+ 'auditLogs.detail.none': 'No extra payload snapshot available',
+ 'auditLogs.request.fail': 'Failed to load audit logs',
+ 'auditLogs.module.auth': 'Authentication',
+ 'auditLogs.module.card': 'Digital Card',
+ 'auditLogs.module.crm': 'Radar CRM',
+ 'auditLogs.module.nfc': 'NFC Fleet',
+ 'auditLogs.module.team': 'Team & Org',
+ 'auditLogs.module.settings': 'System Settings',
+ },
+ 'zh-CN': {
+ 'menu.settings.auditLogs': '安全审计',
+ 'auditLogs.title': '系统安全审计日志',
+ 'auditLogs.subtitle': '追踪管理员与系统关键操作行为链路,满足安全与出海合规要求。',
+ 'auditLogs.search.keywordPlaceholder': '搜索操作人、行为名称或 IP 地址',
+ 'auditLogs.filter.module': '业务模块',
+ 'auditLogs.filter.module.all': '全部模块',
+ 'auditLogs.filter.status': '执行状态',
+ 'auditLogs.filter.status.all': '全部状态',
+ 'auditLogs.filter.status.success': '成功',
+ 'auditLogs.filter.status.fail': '失败',
+ 'auditLogs.filter.dateRange': '起止时间',
+ 'auditLogs.action.query': '查询',
+ 'auditLogs.action.reset': '重置',
+ 'auditLogs.action.refresh': '刷新',
+ 'auditLogs.action.viewDetail': '查看详情',
+ 'auditLogs.column.id': '编号',
+ 'auditLogs.column.module': '业务模块',
+ 'auditLogs.column.action': '操作行为',
+ 'auditLogs.column.operator': '操作人',
+ 'auditLogs.column.ip': 'IP 地址',
+ 'auditLogs.column.status': '状态',
+ 'auditLogs.column.time': '操作时间',
+ 'auditLogs.column.operation': '操作',
+ 'auditLogs.detail.title': '审计日志快照详情',
+ 'auditLogs.detail.operatorId': '操作人编号',
+ 'auditLogs.detail.none': '无扩展快照数据',
+ 'auditLogs.request.fail': '获取安全审计日志失败',
+ 'auditLogs.module.auth': '账号鉴权',
+ 'auditLogs.module.card': '数字名片',
+ 'auditLogs.module.crm': '访客雷达',
+ 'auditLogs.module.nfc': 'NFC 设备',
+ 'auditLogs.module.team': '企业团队',
+ 'auditLogs.module.settings': '系统设置',
+ },
+ 'ar-SA': {
+ 'menu.settings.auditLogs': 'سجلات التدقيق',
+ 'auditLogs.title': 'سجلات الأمان والتدقيق',
+ 'auditLogs.subtitle': 'تتبع أنشطة المشغلين والأحداث الأمنية.',
+ 'auditLogs.search.keywordPlaceholder': 'بحث عن المشغل أو النشاط أو IP',
+ 'auditLogs.filter.module': 'الوحدة',
+ 'auditLogs.filter.module.all': 'جميع الوحدات',
+ 'auditLogs.filter.status': 'الحالة',
+ 'auditLogs.filter.status.all': 'جميع الحالات',
+ 'auditLogs.filter.status.success': 'ناجح',
+ 'auditLogs.filter.status.fail': 'فشل',
+ 'auditLogs.filter.dateRange': 'النطاق الزمني',
+ 'auditLogs.action.query': 'بحث',
+ 'auditLogs.action.reset': 'إعادة ضبط',
+ 'auditLogs.action.refresh': 'تحديث',
+ 'auditLogs.action.viewDetail': 'عرض التفاصيل',
+ 'auditLogs.column.id': 'المعرف',
+ 'auditLogs.column.module': 'الوحدة',
+ 'auditLogs.column.action': 'النشاط',
+ 'auditLogs.column.operator': 'المشغل',
+ 'auditLogs.column.ip': 'عنوان IP',
+ 'auditLogs.column.status': 'الحالة',
+ 'auditLogs.column.time': 'الوقت',
+ 'auditLogs.column.operation': 'إجراء',
+ 'auditLogs.detail.title': 'تفاصيل السجل الأمني',
+ 'auditLogs.detail.operatorId': 'معرف المشغل',
+ 'auditLogs.detail.none': 'لا توجد بيانات إضافية',
+ 'auditLogs.request.fail': 'فشل تحميل سجلات الأمان',
+ 'auditLogs.module.auth': 'المصادقة',
+ 'auditLogs.module.card': 'البطاقات الرقمية',
+ 'auditLogs.module.crm': 'إدارة العملاء',
+ 'auditLogs.module.nfc': 'أجهزة NFC',
+ 'auditLogs.module.team': 'الفريق والمؤسسة',
+ 'auditLogs.module.settings': 'إعدادات النظام',
+ },
+};
+
+export default i18n;
diff --git a/admin/src/pages/settings/billing/index.tsx b/admin/src/pages/settings/billing/index.tsx
new file mode 100644
index 0000000..174f18b
--- /dev/null
+++ b/admin/src/pages/settings/billing/index.tsx
@@ -0,0 +1,222 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Alert,
+ Button,
+ Card,
+ Grid,
+ Message,
+ Progress,
+ Space,
+ Tag,
+ Typography,
+} from '@arco-design/web-react';
+import axios from 'axios';
+import useLocale from '@/utils/useLocale';
+import { lightColors } from '@/style/tokens';
+import type { BillingQuota, TenantPlanType } from '@/types/omni';
+import locale from './locale';
+
+const PLAN_API = '/api/admin/team/billing/currentPlan';
+
+const PLAN_TYPES: TenantPlanType[] = ['free', 'pro', 'enterprise'];
+
+interface CoolPlanResponse {
+ code: number;
+ message?: string;
+ data?: BillingQuota;
+}
+
+function isTenantPlanType(value: unknown): value is TenantPlanType {
+ return value === 'free' || value === 'pro' || value === 'enterprise';
+}
+
+function emptyQuota(): BillingQuota {
+ return {
+ planType: 'free',
+ seatsUsed: 0,
+ seatsLimit: 0,
+ cardsUsed: 0,
+ cardsLimit: 0,
+ };
+}
+
+function parseQuota(data: unknown): BillingQuota | undefined {
+ if (!data || typeof data !== 'object') {
+ return undefined;
+ }
+ const planType = Reflect.get(data, 'planType');
+ const seatsUsed = Reflect.get(data, 'seatsUsed');
+ const seatsLimit = Reflect.get(data, 'seatsLimit');
+ const cardsUsed = Reflect.get(data, 'cardsUsed');
+ const cardsLimit = Reflect.get(data, 'cardsLimit');
+ if (!isTenantPlanType(planType)) {
+ return undefined;
+ }
+ if (
+ typeof seatsUsed !== 'number' ||
+ typeof seatsLimit !== 'number' ||
+ typeof cardsUsed !== 'number' ||
+ typeof cardsLimit !== 'number'
+ ) {
+ return undefined;
+ }
+ return {
+ planType,
+ seatsUsed,
+ seatsLimit,
+ cardsUsed,
+ cardsLimit,
+ };
+}
+
+function readPayloadMessage(payload: unknown): string | undefined {
+ if (!payload || typeof payload !== 'object' || !('message' in payload)) {
+ return undefined;
+ }
+ const message = Reflect.get(payload, 'message');
+ return typeof message === 'string' && message ? message : undefined;
+}
+
+function resolveErrorMessage(error: unknown, fallback: string): string {
+ if (axios.isAxiosError(error)) {
+ const fromPayload = readPayloadMessage(error.response?.data);
+ if (fromPayload) {
+ return fromPayload;
+ }
+ if (error.message) {
+ return error.message;
+ }
+ }
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return fallback;
+}
+
+function quotaPercent(used: number, limit: number): number {
+ if (limit <= 0) {
+ return 0;
+ }
+ const percent = Math.round((used / limit) * 100);
+ if (percent < 0) {
+ return 0;
+ }
+ if (percent > 100) {
+ return 100;
+ }
+ return percent;
+}
+
+function SettingsBillingPage() {
+ const t = useLocale(locale) as Record;
+ const requestFailText = t['settingsBilling.request.fail'];
+ const [loading, setLoading] = useState(false);
+ const [quota, setQuota] = useState(emptyQuota);
+ const [reloadTick, setReloadTick] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const run = async () => {
+ setLoading(true);
+ try {
+ const { data: payload } = await axios.post(
+ PLAN_API,
+ {}
+ );
+ if (cancelled) {
+ return;
+ }
+ if (payload.code === 1000) {
+ const next = parseQuota(payload.data);
+ if (next) {
+ setQuota(next);
+ return;
+ }
+ }
+ setQuota(emptyQuota());
+ Message.error(payload.message || requestFailText);
+ } catch (error: unknown) {
+ if (cancelled) {
+ return;
+ }
+ setQuota(emptyQuota());
+ Message.error(resolveErrorMessage(error, requestFailText));
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void run();
+ return () => {
+ cancelled = true;
+ };
+ }, [reloadTick, requestFailText]);
+
+ const refresh = () => {
+ setReloadTick((tick) => tick + 1);
+ };
+
+ const planLabel = t[`settingsBilling.plan.${quota.planType}`];
+
+ return (
+
+
+
+
+ {t['settingsBilling.currentPlan']}: {planLabel}
+
+
+
+
+ {t['settingsBilling.quota.seats']}
+
+
+
+
+ {t['settingsBilling.quota.cards']}
+
+
+
+
+ {PLAN_TYPES.map((plan) => {
+ const current = quota.planType === plan;
+ return (
+
+
+ {t[`settingsBilling.plan.${plan}`]}
+
+
+ {t[`settingsBilling.plan.${plan}.desc`]}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+export default SettingsBillingPage;
diff --git a/admin/src/pages/settings/billing/locale/index.ts b/admin/src/pages/settings/billing/locale/index.ts
new file mode 100644
index 0000000..47529c7
--- /dev/null
+++ b/admin/src/pages/settings/billing/locale/index.ts
@@ -0,0 +1,54 @@
+const i18n = {
+ 'en-US': {
+ 'menu.settings.billing': 'Billing',
+ 'settingsBilling.currentPlan': 'Current plan',
+ 'settingsBilling.quota.seats': 'Seats',
+ 'settingsBilling.quota.cards': 'Cards',
+ 'settingsBilling.plan.free': 'Free',
+ 'settingsBilling.plan.free.desc': 'One seat and one published card',
+ 'settingsBilling.plan.pro': 'Pro',
+ 'settingsBilling.plan.pro.desc': 'Ten seats and five published cards',
+ 'settingsBilling.plan.enterprise': 'Enterprise',
+ 'settingsBilling.plan.enterprise.desc':
+ 'One hundred seats and a large card pool',
+ 'settingsBilling.alert.later':
+ 'International card linking and invoices will be connected later. This page only shows quota.',
+ 'settingsBilling.action.refresh': 'Refresh quota',
+ 'settingsBilling.request.fail': 'Unable to load the current plan',
+ },
+ 'zh-CN': {
+ 'menu.settings.billing': '订阅账单',
+ 'settingsBilling.currentPlan': '当前套餐',
+ 'settingsBilling.quota.seats': '席位',
+ 'settingsBilling.quota.cards': '名片',
+ 'settingsBilling.plan.free': '免费版',
+ 'settingsBilling.plan.free.desc': '1 个席位,1 张名片',
+ 'settingsBilling.plan.pro': '专业版',
+ 'settingsBilling.plan.pro.desc': '10 个席位,5 张名片',
+ 'settingsBilling.plan.enterprise': '企业版',
+ 'settingsBilling.plan.enterprise.desc': '100 个席位,大规模名片配额',
+ 'settingsBilling.alert.later':
+ '国际卡绑定与发票为后续接入,本页只展示配额。',
+ 'settingsBilling.action.refresh': '刷新配额',
+ 'settingsBilling.request.fail': '无法加载当前套餐',
+ },
+ 'ar-SA': {
+ 'menu.settings.billing': 'الفوترة',
+ 'settingsBilling.currentPlan': 'الخطة الحالية',
+ 'settingsBilling.quota.seats': 'المقاعد',
+ 'settingsBilling.quota.cards': 'البطاقات',
+ 'settingsBilling.plan.free': 'مجاني',
+ 'settingsBilling.plan.free.desc': 'مقعد واحد وبطاقة واحدة',
+ 'settingsBilling.plan.pro': 'احترافي',
+ 'settingsBilling.plan.pro.desc': 'عشرة مقاعد وخمس بطاقات',
+ 'settingsBilling.plan.enterprise': 'مؤسسي',
+ 'settingsBilling.plan.enterprise.desc':
+ 'مائة مقعد ومخزون بطاقات واسع',
+ 'settingsBilling.alert.later':
+ 'ربط البطاقات الدولية والفواتير سيُضاف لاحقا. تعرض هذه الصفحة الحصة فقط.',
+ 'settingsBilling.action.refresh': 'تحديث الحصة',
+ 'settingsBilling.request.fail': 'تعذر تحميل الخطة الحالية',
+ },
+};
+
+export default i18n;
diff --git a/admin/src/pages/settings/tenant/index.tsx b/admin/src/pages/settings/tenant/index.tsx
new file mode 100644
index 0000000..5440a54
--- /dev/null
+++ b/admin/src/pages/settings/tenant/index.tsx
@@ -0,0 +1,262 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Form,
+ Input,
+ Card,
+ Message,
+ Button,
+ Space,
+ Tag,
+ Alert,
+} from '@arco-design/web-react';
+import axios from 'axios';
+import useLocale from '@/utils/useLocale';
+import { lightColors } from '@/style/tokens';
+import type { DomainSslStatus, TenantDomain } from '@/types/omni';
+import locale from './locale';
+
+const PAGE_API = '/api/admin/team/domain/page';
+const ADD_API = '/api/admin/team/domain/add';
+const UPDATE_API = '/api/admin/team/domain/update';
+
+interface DomainFormValues {
+ customDomain?: string;
+}
+
+interface CoolPagination {
+ page?: number;
+ size?: number;
+ total?: number;
+}
+
+interface CoolPageData {
+ list?: TenantDomain[];
+ pagination?: CoolPagination;
+}
+
+interface CoolPageResponse {
+ code: number;
+ message?: string;
+ data?: CoolPageData;
+}
+
+interface CoolMutationResponse {
+ code: number;
+ message?: string;
+}
+
+interface DomainSavePayload {
+ id?: TenantDomain['id'];
+ customDomain: string;
+ sslStatus: DomainSslStatus;
+}
+
+function readPayloadMessage(payload: unknown): string | undefined {
+ if (!payload || typeof payload !== 'object' || !('message' in payload)) {
+ return undefined;
+ }
+ const message = Reflect.get(payload, 'message');
+ return typeof message === 'string' && message ? message : undefined;
+}
+
+function resolveErrorMessage(error: unknown, fallback: string): string {
+ if (axios.isAxiosError(error)) {
+ const fromPayload = readPayloadMessage(error.response?.data);
+ if (fromPayload) {
+ return fromPayload;
+ }
+ if (error.message) {
+ return error.message;
+ }
+ }
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return fallback;
+}
+
+function normalizeSslStatus(value: unknown): DomainSslStatus {
+ if (value === 'active' || value === 'failed' || value === 'pending') {
+ return value;
+ }
+ return 'pending';
+}
+
+function sslTagColor(status: DomainSslStatus): string {
+ if (status === 'active') {
+ return lightColors.success;
+ }
+ if (status === 'failed') {
+ return lightColors.error;
+ }
+ return lightColors.warning;
+}
+
+function emptyFormValues(): DomainFormValues {
+ return {
+ customDomain: '',
+ };
+}
+
+function toFormValues(record?: TenantDomain): DomainFormValues {
+ if (!record) {
+ return emptyFormValues();
+ }
+ return {
+ customDomain: record.customDomain ?? '',
+ };
+}
+
+function SettingsTenantPage() {
+ const t = useLocale(locale) as Record;
+ const requestFailText = t['settingsTenant.request.fail'];
+ const saveFailText = t['settingsTenant.request.saveFail'];
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [domainId, setDomainId] = useState(
+ undefined
+ );
+ const [loadedDomain, setLoadedDomain] = useState('');
+ const [sslStatus, setSslStatus] = useState('pending');
+ const [reloadTick, setReloadTick] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const run = async () => {
+ setLoading(true);
+ try {
+ const { data: payload } = await axios.post(PAGE_API, {
+ page: 1,
+ size: 1,
+ });
+ if (cancelled) {
+ return;
+ }
+ if (payload.code === 1000) {
+ const record = payload.data?.list?.[0];
+ const next = toFormValues(record);
+ setDomainId(record?.id);
+ setLoadedDomain(record?.customDomain?.trim() ?? '');
+ setSslStatus(normalizeSslStatus(record?.sslStatus));
+ form.setFieldsValue(next);
+ return;
+ }
+ setDomainId(undefined);
+ setLoadedDomain('');
+ setSslStatus('pending');
+ form.setFieldsValue(emptyFormValues());
+ Message.error(payload.message || requestFailText);
+ } catch (error: unknown) {
+ if (cancelled) {
+ return;
+ }
+ setDomainId(undefined);
+ setLoadedDomain('');
+ setSslStatus('pending');
+ form.setFieldsValue(emptyFormValues());
+ Message.error(resolveErrorMessage(error, requestFailText));
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void run();
+ return () => {
+ cancelled = true;
+ };
+ }, [form, reloadTick, requestFailText]);
+
+ const refresh = () => {
+ setReloadTick((tick) => tick + 1);
+ };
+
+ const handleSave = async (values: DomainFormValues) => {
+ const customDomain = values.customDomain?.trim() ?? '';
+ if (!customDomain) {
+ return;
+ }
+
+ const body: DomainSavePayload = {
+ customDomain,
+ sslStatus: 'pending',
+ };
+ if (domainId !== undefined) {
+ body.id = domainId;
+ if (customDomain === loadedDomain) {
+ body.sslStatus = sslStatus;
+ }
+ }
+
+ setSaving(true);
+ try {
+ const api = domainId !== undefined ? UPDATE_API : ADD_API;
+ const { data: payload } = await axios.post(
+ api,
+ body
+ );
+ if (payload.code === 1000) {
+ Message.success(
+ domainId !== undefined
+ ? t['settingsTenant.request.updateSuccess']
+ : t['settingsTenant.request.addSuccess']
+ );
+ refresh();
+ return;
+ }
+ Message.error(payload.message || saveFailText);
+ } catch (error: unknown) {
+ Message.error(resolveErrorMessage(error, saveFailText));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const sslLabelKey = `settingsTenant.ssl.${sslStatus}` as const;
+
+ return (
+
+
+
+
+
+ {t['settingsTenant.form.sslStatus']}
+
+ {t[sslLabelKey]}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default SettingsTenantPage;
diff --git a/admin/src/pages/settings/tenant/locale/index.ts b/admin/src/pages/settings/tenant/locale/index.ts
new file mode 100644
index 0000000..9f34fa0
--- /dev/null
+++ b/admin/src/pages/settings/tenant/locale/index.ts
@@ -0,0 +1,58 @@
+const i18n = {
+ 'en-US': {
+ 'menu.settings.tenant': 'Domain & SSL',
+ 'settingsTenant.form.customDomain': 'Custom domain',
+ 'settingsTenant.form.customDomain.placeholder':
+ 'Hostname only, such as cards.brand.com',
+ 'settingsTenant.form.save': 'Save',
+ 'settingsTenant.form.sslStatus': 'Certificate status',
+ 'settingsTenant.validation.customDomain': 'Enter a custom domain',
+ 'settingsTenant.ssl.pending': 'Pending check',
+ 'settingsTenant.ssl.active': 'Check passed',
+ 'settingsTenant.ssl.failed': 'Check failed',
+ 'settingsTenant.ssl.alert':
+ 'Certificate status is a detection result only. It does not mean a trusted SSL certificate has been issued.',
+ 'settingsTenant.request.fail': 'Unable to load the domain',
+ 'settingsTenant.request.saveFail': 'Unable to save the domain',
+ 'settingsTenant.request.addSuccess': 'Domain created',
+ 'settingsTenant.request.updateSuccess': 'Domain updated',
+ },
+ 'zh-CN': {
+ 'menu.settings.tenant': '域名与证书',
+ 'settingsTenant.form.customDomain': '自定义域名',
+ 'settingsTenant.form.customDomain.placeholder':
+ '仅填写主机名,例如 cards.brand.com',
+ 'settingsTenant.form.save': '保存',
+ 'settingsTenant.form.sslStatus': '证书状态',
+ 'settingsTenant.validation.customDomain': '请填写自定义域名',
+ 'settingsTenant.ssl.pending': '检测中',
+ 'settingsTenant.ssl.active': '检测通过',
+ 'settingsTenant.ssl.failed': '检测失败',
+ 'settingsTenant.ssl.alert':
+ '证书状态仅为检测结果展示,不代表已签发真实可信的 SSL 证书。',
+ 'settingsTenant.request.fail': '域名加载失败',
+ 'settingsTenant.request.saveFail': '域名保存失败',
+ 'settingsTenant.request.addSuccess': '已创建域名',
+ 'settingsTenant.request.updateSuccess': '已更新域名',
+ },
+ 'ar-SA': {
+ 'menu.settings.tenant': 'النطاق والشهادة',
+ 'settingsTenant.form.customDomain': 'نطاق مخصص',
+ 'settingsTenant.form.customDomain.placeholder':
+ 'اسم المضيف فقط، مثل cards.brand.com',
+ 'settingsTenant.form.save': 'حفظ',
+ 'settingsTenant.form.sslStatus': 'حالة الشهادة',
+ 'settingsTenant.validation.customDomain': 'أدخل نطاقا مخصصا',
+ 'settingsTenant.ssl.pending': 'قيد الفحص',
+ 'settingsTenant.ssl.active': 'نجح الفحص',
+ 'settingsTenant.ssl.failed': 'فشل الفحص',
+ 'settingsTenant.ssl.alert':
+ 'حالة الشهادة نتيجة فحص فقط، ولا تعني إصدار شهادة SSL موثوقة.',
+ 'settingsTenant.request.fail': 'تعذر تحميل النطاق',
+ 'settingsTenant.request.saveFail': 'تعذر حفظ النطاق',
+ 'settingsTenant.request.addSuccess': 'تم إنشاء النطاق',
+ 'settingsTenant.request.updateSuccess': 'تم تحديث النطاق',
+ },
+};
+
+export default i18n;
diff --git a/admin/src/pages/team/brand-kit/index.tsx b/admin/src/pages/team/brand-kit/index.tsx
new file mode 100644
index 0000000..2f28dec
--- /dev/null
+++ b/admin/src/pages/team/brand-kit/index.tsx
@@ -0,0 +1,344 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Form,
+ Input,
+ Card,
+ Message,
+ Button,
+ Grid,
+ Space,
+ Tag,
+ Typography,
+ Image,
+} from '@arco-design/web-react';
+import axios from 'axios';
+import useLocale from '@/utils/useLocale';
+import { lightColors } from '@/style/tokens';
+import type { BrandKit, TenantProfile } from '@/types/omni';
+import locale from './locale';
+
+const PAGE_API = '/api/admin/team/brand/page';
+const ADD_API = '/api/admin/team/brand/add';
+const UPDATE_API = '/api/admin/team/brand/update';
+
+interface BrandFormValues {
+ companyName?: string;
+ logoUrl?: string;
+ primaryColor?: string;
+}
+
+type BrandRecord = Pick & Partial;
+
+interface CoolPagination {
+ page?: number;
+ size?: number;
+ total?: number;
+}
+
+interface CoolPageData {
+ list?: BrandRecord[];
+ pagination?: CoolPagination;
+}
+
+interface CoolPageResponse {
+ code: number;
+ message?: string;
+ data?: CoolPageData;
+}
+
+interface CoolMutationResponse {
+ code: number;
+ message?: string;
+}
+
+interface BrandSavePayload {
+ id?: TenantProfile['id'];
+ companyName: string;
+ logoUrl: string;
+ primaryColor: string;
+}
+
+function readPayloadMessage(payload: unknown): string | undefined {
+ if (!payload || typeof payload !== 'object' || !('message' in payload)) {
+ return undefined;
+ }
+ const message = Reflect.get(payload, 'message');
+ return typeof message === 'string' && message ? message : undefined;
+}
+
+function resolveErrorMessage(error: unknown, fallback: string): string {
+ if (axios.isAxiosError(error)) {
+ const fromPayload = readPayloadMessage(error.response?.data);
+ if (fromPayload) {
+ return fromPayload;
+ }
+ if (error.message) {
+ return error.message;
+ }
+ }
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return fallback;
+}
+
+function isBrandPrimaryColor(value: string): boolean {
+ if (!/^#[0-9A-Fa-f]{6}$/.test(value)) {
+ return false;
+ }
+ return value.slice(1).toLowerCase() !== '000000';
+}
+
+function resolvePreviewColor(value?: string): string {
+ const color = value?.trim() || lightColors.primary;
+ return isBrandPrimaryColor(color) ? color : lightColors.primary;
+}
+
+function emptyFormValues(): BrandFormValues {
+ return {
+ companyName: '',
+ logoUrl: '',
+ primaryColor: lightColors.primary,
+ };
+}
+
+function toFormValues(record?: BrandRecord): BrandFormValues {
+ if (!record) {
+ return emptyFormValues();
+ }
+ return {
+ companyName: record.companyName ?? '',
+ logoUrl: record.logoUrl ?? '',
+ primaryColor: record.primaryColor || lightColors.primary,
+ };
+}
+
+function toPreviewProfile(
+ values: BrandFormValues
+): Pick {
+ const brandKit: BrandKit = {
+ logoUrl: values.logoUrl?.trim() ?? '',
+ primaryColor: resolvePreviewColor(values.primaryColor),
+ };
+ return {
+ companyName: values.companyName?.trim() ?? '',
+ brandKit,
+ };
+}
+
+function TeamBrandKitPage() {
+ const t = useLocale(locale) as Record;
+ const requestFailText = t['teamBrandKit.request.fail'];
+ const saveFailText = t['teamBrandKit.request.saveFail'];
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const [brandId, setBrandId] = useState(
+ undefined
+ );
+ const [preview, setPreview] = useState(emptyFormValues);
+ const [reloadTick, setReloadTick] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+
+ const run = async () => {
+ setLoading(true);
+ try {
+ const { data: payload } = await axios.post(PAGE_API, {
+ page: 1,
+ size: 1,
+ });
+ if (cancelled) {
+ return;
+ }
+ if (payload.code === 1000) {
+ const record = payload.data?.list?.[0];
+ const next = toFormValues(record);
+ setBrandId(record?.id);
+ form.setFieldsValue(next);
+ setPreview(next);
+ return;
+ }
+ setBrandId(undefined);
+ form.setFieldsValue(emptyFormValues());
+ setPreview(emptyFormValues());
+ Message.error(payload.message || requestFailText);
+ } catch (error: unknown) {
+ if (cancelled) {
+ return;
+ }
+ setBrandId(undefined);
+ form.setFieldsValue(emptyFormValues());
+ setPreview(emptyFormValues());
+ Message.error(resolveErrorMessage(error, requestFailText));
+ } finally {
+ if (!cancelled) {
+ setLoading(false);
+ }
+ }
+ };
+
+ void run();
+ return () => {
+ cancelled = true;
+ };
+ }, [form, reloadTick, requestFailText]);
+
+ const refresh = () => {
+ setReloadTick((tick) => tick + 1);
+ };
+
+ const handleValuesChange = (_changed: BrandFormValues, values: BrandFormValues) => {
+ setPreview(values);
+ };
+
+ const handleSave = async (values: BrandFormValues) => {
+ const companyName = values.companyName?.trim() ?? '';
+ const logoUrl = values.logoUrl?.trim() ?? '';
+ const primaryColor = values.primaryColor?.trim() || lightColors.primary;
+
+ if (!companyName || !isBrandPrimaryColor(primaryColor)) {
+ return;
+ }
+
+ const body: BrandSavePayload = {
+ companyName,
+ logoUrl,
+ primaryColor,
+ };
+ if (brandId !== undefined) {
+ body.id = brandId;
+ }
+
+ setSaving(true);
+ try {
+ const api = brandId !== undefined ? UPDATE_API : ADD_API;
+ const { data: payload } = await axios.post(
+ api,
+ body
+ );
+ if (payload.code === 1000) {
+ Message.success(
+ brandId !== undefined
+ ? t['teamBrandKit.request.updateSuccess']
+ : t['teamBrandKit.request.addSuccess']
+ );
+ refresh();
+ return;
+ }
+ Message.error(payload.message || saveFailText);
+ } catch (error: unknown) {
+ Message.error(resolveErrorMessage(error, saveFailText));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const validatePrimaryColor = (
+ value: unknown,
+ callback: (error?: string) => void
+ ) => {
+ if (typeof value !== 'string' || !isBrandPrimaryColor(value.trim())) {
+ callback(t['teamBrandKit.validation.primaryColor']);
+ return;
+ }
+ callback();
+ };
+
+ const previewProfile = toPreviewProfile(preview);
+ const previewColor = previewProfile.brandKit.primaryColor;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {previewProfile.companyName ||
+ t['teamBrandKit.preview.emptyName']}
+
+ {previewProfile.brandKit.logoUrl ? (
+
+ ) : (
+
+ {t['teamBrandKit.preview.emptyLogo']}
+
+ )}
+
+
+ {t['teamBrandKit.preview.tokenPrimary']}
+
+
+ {t['teamBrandKit.preview.formPrimary']}
+
+
+
+
+
+
+
+ );
+}
+
+export default TeamBrandKitPage;
diff --git a/admin/src/pages/team/brand-kit/locale/index.ts b/admin/src/pages/team/brand-kit/locale/index.ts
new file mode 100644
index 0000000..8a517a5
--- /dev/null
+++ b/admin/src/pages/team/brand-kit/locale/index.ts
@@ -0,0 +1,72 @@
+const i18n = {
+ 'en-US': {
+ 'menu.team.brandKit': 'Brand Kit',
+ 'teamBrandKit.form.companyName': 'Company name',
+ 'teamBrandKit.form.companyName.placeholder':
+ 'Official company or team name',
+ 'teamBrandKit.form.logoUrl': 'Logo URL',
+ 'teamBrandKit.form.logoUrl.placeholder': 'Public HTTPS logo address',
+ 'teamBrandKit.form.primaryColor': 'Brand color',
+ 'teamBrandKit.form.primaryColor.placeholder':
+ 'Hex color, cobalt by default',
+ 'teamBrandKit.form.save': 'Save',
+ 'teamBrandKit.validation.companyName': 'Enter the company name',
+ 'teamBrandKit.validation.primaryColor':
+ 'Enter a six-digit hex color, not black',
+ 'teamBrandKit.preview.title': 'Preview',
+ 'teamBrandKit.preview.tokenPrimary': 'Cobalt token',
+ 'teamBrandKit.preview.formPrimary': 'Brand color',
+ 'teamBrandKit.preview.emptyName': 'Company name',
+ 'teamBrandKit.preview.emptyLogo': 'No logo yet',
+ 'teamBrandKit.request.fail': 'Unable to load the brand kit',
+ 'teamBrandKit.request.saveFail': 'Unable to save the brand kit',
+ 'teamBrandKit.request.addSuccess': 'Brand kit created',
+ 'teamBrandKit.request.updateSuccess': 'Brand kit updated',
+ },
+ 'zh-CN': {
+ 'menu.team.brandKit': '品牌规范',
+ 'teamBrandKit.form.companyName': '企业名称',
+ 'teamBrandKit.form.companyName.placeholder': '官方企业或团队名称',
+ 'teamBrandKit.form.logoUrl': 'Logo 地址',
+ 'teamBrandKit.form.logoUrl.placeholder': '可公开访问的 HTTPS 图片地址',
+ 'teamBrandKit.form.primaryColor': '品牌主色',
+ 'teamBrandKit.form.primaryColor.placeholder': '十六进制颜色,默认钴蓝',
+ 'teamBrandKit.form.save': '保存',
+ 'teamBrandKit.validation.companyName': '请填写企业名称',
+ 'teamBrandKit.validation.primaryColor': '请填写非黑色的六位十六进制颜色',
+ 'teamBrandKit.preview.title': '预览',
+ 'teamBrandKit.preview.tokenPrimary': '钴蓝 token',
+ 'teamBrandKit.preview.formPrimary': '品牌色',
+ 'teamBrandKit.preview.emptyName': '企业名称',
+ 'teamBrandKit.preview.emptyLogo': '尚未设置 Logo',
+ 'teamBrandKit.request.fail': '品牌规范加载失败',
+ 'teamBrandKit.request.saveFail': '品牌规范保存失败',
+ 'teamBrandKit.request.addSuccess': '已创建品牌规范',
+ 'teamBrandKit.request.updateSuccess': '已更新品牌规范',
+ },
+ 'ar-SA': {
+ 'menu.team.brandKit': 'هوية العلامة',
+ 'teamBrandKit.form.companyName': 'اسم الشركة',
+ 'teamBrandKit.form.companyName.placeholder': 'الاسم الرسمي للشركة أو الفريق',
+ 'teamBrandKit.form.logoUrl': 'رابط الشعار',
+ 'teamBrandKit.form.logoUrl.placeholder': 'عنوان شعار HTTPS عام',
+ 'teamBrandKit.form.primaryColor': 'لون العلامة',
+ 'teamBrandKit.form.primaryColor.placeholder':
+ 'لون سداسي، الكوبالت افتراضيا',
+ 'teamBrandKit.form.save': 'حفظ',
+ 'teamBrandKit.validation.companyName': 'أدخل اسم الشركة',
+ 'teamBrandKit.validation.primaryColor':
+ 'أدخل لونا سداسيا من ستة أرقام، غير الأسود',
+ 'teamBrandKit.preview.title': 'معاينة',
+ 'teamBrandKit.preview.tokenPrimary': 'رمز الكوبالت',
+ 'teamBrandKit.preview.formPrimary': 'لون العلامة',
+ 'teamBrandKit.preview.emptyName': 'اسم الشركة',
+ 'teamBrandKit.preview.emptyLogo': 'لا يوجد شعار بعد',
+ 'teamBrandKit.request.fail': 'تعذر تحميل هوية العلامة',
+ 'teamBrandKit.request.saveFail': 'تعذر حفظ هوية العلامة',
+ 'teamBrandKit.request.addSuccess': 'تم إنشاء هوية العلامة',
+ 'teamBrandKit.request.updateSuccess': 'تم تحديث هوية العلامة',
+ },
+};
+
+export default i18n;
diff --git a/admin/src/pages/team/handover/index.tsx b/admin/src/pages/team/handover/index.tsx
new file mode 100644
index 0000000..b8deea2
--- /dev/null
+++ b/admin/src/pages/team/handover/index.tsx
@@ -0,0 +1,200 @@
+import React, { useState } from 'react';
+import {
+ Form,
+ Input,
+ Card,
+ Alert,
+ Message,
+ Button,
+ Popconfirm,
+ Space,
+} from '@arco-design/web-react';
+import axios from 'axios';
+import useLocale from '@/utils/useLocale';
+import { lightColors } from '@/style/tokens';
+import type { HandoverRequest } from '@/types/omni';
+import locale from './locale';
+
+const TRANSFER_API = '/api/admin/team/handover/transfer';
+
+interface HandoverFormValues {
+ cardId?: string;
+ fromUserId?: string;
+ toUserId?: string;
+}
+
+interface CoolMutationResponse {
+ code: number;
+ message?: string;
+}
+
+function readPayloadMessage(payload: unknown): string | undefined {
+ if (!payload || typeof payload !== 'object' || !('message' in payload)) {
+ return undefined;
+ }
+ const message = Reflect.get(payload, 'message');
+ return typeof message === 'string' && message ? message : undefined;
+}
+
+function resolveErrorMessage(error: unknown, fallback: string): string {
+ if (axios.isAxiosError(error)) {
+ const fromPayload = readPayloadMessage(error.response?.data);
+ if (fromPayload) {
+ return fromPayload;
+ }
+ if (error.message) {
+ return error.message;
+ }
+ }
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return fallback;
+}
+
+function readRequiredId(value: unknown): string | undefined {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ const text = String(value).trim();
+ return text || undefined;
+ }
+ if (typeof value === 'string') {
+ const text = value.trim();
+ return text || undefined;
+ }
+ return undefined;
+}
+
+function emptyFormValues(): HandoverFormValues {
+ return {
+ cardId: '',
+ fromUserId: '',
+ toUserId: '',
+ };
+}
+
+function TeamHandoverPage() {
+ const t = useLocale(locale) as Record;
+ const failText = t['teamHandover.request.fail'];
+ const [form] = Form.useForm();
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleTransfer = async () => {
+ const values = await form.validate();
+ const cardId = readRequiredId(values.cardId);
+ const fromUserId = readRequiredId(values.fromUserId);
+ const toUserId = readRequiredId(values.toUserId);
+
+ if (!cardId || !fromUserId || !toUserId) {
+ return;
+ }
+ if (fromUserId === toUserId) {
+ Message.error(t['teamHandover.validation.sameUser']);
+ return;
+ }
+
+ const body: HandoverRequest = {
+ cardId,
+ fromUserId,
+ toUserId,
+ };
+
+ setSubmitting(true);
+ try {
+ const { data: payload } = await axios.post(
+ TRANSFER_API,
+ body
+ );
+ if (payload.code === 1000) {
+ Message.success(t['teamHandover.request.success']);
+ form.resetFields();
+ return;
+ }
+ Message.error(payload.message || failText);
+ } catch (error: unknown) {
+ Message.error(resolveErrorMessage(error, failText));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default TeamHandoverPage;
diff --git a/admin/src/pages/team/handover/locale/index.ts b/admin/src/pages/team/handover/locale/index.ts
new file mode 100644
index 0000000..7f71ff9
--- /dev/null
+++ b/admin/src/pages/team/handover/locale/index.ts
@@ -0,0 +1,70 @@
+const i18n = {
+ 'en-US': {
+ 'menu.team.handover': 'Handover',
+ 'teamHandover.alert.keepSlug':
+ 'The short link stays the same. Only the card owner changes.',
+ 'teamHandover.form.cardId': 'Card ID',
+ 'teamHandover.form.cardId.placeholder': 'Card to transfer',
+ 'teamHandover.form.fromUserId': 'Current owner',
+ 'teamHandover.form.fromUserId.placeholder': 'User ID of the departing owner',
+ 'teamHandover.form.toUserId': 'Successor',
+ 'teamHandover.form.toUserId.placeholder': 'User ID of the new owner',
+ 'teamHandover.form.submit': 'Transfer',
+ 'teamHandover.confirm.title':
+ 'Transfer this card? The short link will stay the same.',
+ 'teamHandover.confirm.ok': 'Transfer',
+ 'teamHandover.confirm.cancel': 'Cancel',
+ 'teamHandover.validation.cardId': 'Enter the card ID',
+ 'teamHandover.validation.fromUserId': 'Enter the current owner ID',
+ 'teamHandover.validation.toUserId': 'Enter the successor ID',
+ 'teamHandover.validation.sameUser':
+ 'The successor must be different from the current owner',
+ 'teamHandover.request.success': 'Card transferred',
+ 'teamHandover.request.fail': 'Unable to transfer the card',
+ },
+ 'zh-CN': {
+ 'menu.team.handover': '离职交接',
+ 'teamHandover.alert.keepSlug': '短链保持不变,仅变更名片负责人。',
+ 'teamHandover.form.cardId': '名片 ID',
+ 'teamHandover.form.cardId.placeholder': '待交接的名片',
+ 'teamHandover.form.fromUserId': '原负责人',
+ 'teamHandover.form.fromUserId.placeholder': '离职员工的用户 ID',
+ 'teamHandover.form.toUserId': '接替人',
+ 'teamHandover.form.toUserId.placeholder': '接替人的用户 ID',
+ 'teamHandover.form.submit': '确认交接',
+ 'teamHandover.confirm.title': '确认交接这张名片?短链不会改变。',
+ 'teamHandover.confirm.ok': '确认交接',
+ 'teamHandover.confirm.cancel': '取消',
+ 'teamHandover.validation.cardId': '请填写名片 ID',
+ 'teamHandover.validation.fromUserId': '请填写原负责人 ID',
+ 'teamHandover.validation.toUserId': '请填写接替人 ID',
+ 'teamHandover.validation.sameUser': '接替人不能与原负责人相同',
+ 'teamHandover.request.success': '交接完成',
+ 'teamHandover.request.fail': '交接失败',
+ },
+ 'ar-SA': {
+ 'menu.team.handover': 'تسليم الأصول',
+ 'teamHandover.alert.keepSlug':
+ 'يبقى الرابط المختصر كما هو. يتغير مالك البطاقة فقط.',
+ 'teamHandover.form.cardId': 'معرّف البطاقة',
+ 'teamHandover.form.cardId.placeholder': 'البطاقة المطلوب نقلها',
+ 'teamHandover.form.fromUserId': 'المالك الحالي',
+ 'teamHandover.form.fromUserId.placeholder': 'معرّف المستخدم للمالك المغادر',
+ 'teamHandover.form.toUserId': 'الخليفة',
+ 'teamHandover.form.toUserId.placeholder': 'معرّف المستخدم للمالك الجديد',
+ 'teamHandover.form.submit': 'نقل',
+ 'teamHandover.confirm.title':
+ 'نقل هذه البطاقة؟ سيبقى الرابط المختصر كما هو.',
+ 'teamHandover.confirm.ok': 'نقل',
+ 'teamHandover.confirm.cancel': 'إلغاء',
+ 'teamHandover.validation.cardId': 'أدخل معرّف البطاقة',
+ 'teamHandover.validation.fromUserId': 'أدخل معرّف المالك الحالي',
+ 'teamHandover.validation.toUserId': 'أدخل معرّف الخليفة',
+ 'teamHandover.validation.sameUser':
+ 'يجب أن يختلف الخليفة عن المالك الحالي',
+ 'teamHandover.request.success': 'تم نقل البطاقة',
+ 'teamHandover.request.fail': 'تعذر نقل البطاقة',
+ },
+};
+
+export default i18n;
diff --git a/admin/src/pages/team/members/index.tsx b/admin/src/pages/team/members/index.tsx
new file mode 100644
index 0000000..3297c83
--- /dev/null
+++ b/admin/src/pages/team/members/index.tsx
@@ -0,0 +1,664 @@
+import React, { useEffect, useState, useMemo } from 'react';
+import {
+ Card,
+ Grid,
+ Tree,
+ Table,
+ Button,
+ Input,
+ Select,
+ Modal,
+ Message,
+ Space,
+ Tag,
+ Typography,
+ Popconfirm,
+ Spin,
+} from '@arco-design/web-react';
+import type { ColumnProps } from '@arco-design/web-react/es/Table';
+import axios from 'axios';
+import useLocale from '@/utils/useLocale';
+import { lightColors } from '@/style/tokens';
+import type {
+ AssignCardRequest,
+ CardProfile,
+ DepartmentTreeNode,
+ MoveMembersRequest,
+ TeamMemberItem,
+} from '@/types/omni';
+import locale from './locale';
+
+const { Row, Col } = Grid;
+const { Text } = Typography;
+
+const DEPT_TREE_API = '/api/admin/team/members/getDepartmentTree';
+const MEMBER_PAGE_API = '/api/admin/team/members/getMemberPage';
+const ASSIGN_CARD_API = '/api/admin/team/members/assignCard';
+const MOVE_MEMBERS_API = '/api/admin/team/members/moveMembers';
+const ADD_DEPT_API = '/api/admin/team/members/addDepartment';
+const DELETE_DEPT_API = '/api/admin/team/members/deleteDepartment';
+const CARDS_PAGE_API = '/api/admin/card/info/page';
+
+interface CoolResponse {
+ code: number;
+ message?: string;
+ data?: T;
+}
+
+interface MemberPagePayload {
+ list: TeamMemberItem[];
+ pagination: {
+ page: number;
+ size: number;
+ total: number;
+ };
+}
+
+interface CardPagePayload {
+ list: CardProfile[];
+}
+
+function TeamMembersPage() {
+ const t = useLocale(locale) as Record;
+
+ // 部门树状态
+ const [deptTree, setDeptTree] = useState([]);
+ const [selectedDeptId, setSelectedDeptId] = useState();
+ const [treeLoading, setTreeLoading] = useState(false);
+
+ // 成员表格状态
+ const [members, setMembers] = useState([]);
+ const [tableLoading, setTableLoading] = useState(false);
+ const [selectedMemberIds, setSelectedMemberIds] = useState([]);
+ const [keyword, setKeyword] = useState('');
+ const [pagination, setPagination] = useState({
+ current: 1,
+ pageSize: 20,
+ total: 0,
+ });
+
+ // 名片分配弹窗
+ const [assignModalVisible, setAssignModalVisible] = useState(false);
+ const [assigningMember, setAssigningMember] = useState(null);
+ const [cardOptions, setCardOptions] = useState([]);
+ const [selectedCardId, setSelectedCardId] = useState();
+ const [assignSubmitting, setAssignSubmitting] = useState(false);
+
+ // 调整部门弹窗
+ const [moveModalVisible, setMoveModalVisible] = useState(false);
+ const [moveTargetDeptId, setMoveTargetDeptId] = useState();
+ const [movingMemberIds, setMovingMemberIds] = useState([]);
+ const [moveSubmitting, setMoveSubmitting] = useState(false);
+
+ // 新增部门弹窗
+ const [addDeptModalVisible, setAddDeptModalVisible] = useState(false);
+ const [newDeptName, setNewDeptName] = useState('');
+ const [newDeptParentId, setNewDeptParentId] = useState();
+ const [addDeptSubmitting, setAddDeptSubmitting] = useState(false);
+
+ // 拉取部门树
+ const fetchDeptTree = async () => {
+ setTreeLoading(true);
+ try {
+ const { data } = await axios.post>(
+ DEPT_TREE_API,
+ {}
+ );
+ if (data.code === 1000 && Array.isArray(data.data)) {
+ setDeptTree(data.data);
+ } else {
+ Message.error(data.message || t['teamMembers.request.fail']);
+ }
+ } catch {
+ Message.error(t['teamMembers.request.fail']);
+ } finally {
+ setTreeLoading(false);
+ }
+ };
+
+ // 拉取成员分页数据
+ const fetchMembers = async (
+ page = pagination.current,
+ size = pagination.pageSize,
+ deptId = selectedDeptId,
+ kw = keyword
+ ) => {
+ setTableLoading(true);
+ try {
+ const { data } = await axios.post>(
+ MEMBER_PAGE_API,
+ {
+ page,
+ size,
+ departmentId: deptId,
+ keyword: kw || undefined,
+ }
+ );
+ if (data.code === 1000 && data.data) {
+ setMembers(data.data.list || []);
+ setPagination({
+ current: data.data.pagination.page,
+ pageSize: data.data.pagination.size,
+ total: data.data.pagination.total,
+ });
+ } else {
+ Message.error(data.message || t['teamMembers.request.fail']);
+ }
+ } catch {
+ Message.error(t['teamMembers.request.fail']);
+ } finally {
+ setTableLoading(false);
+ }
+ };
+
+ // 拉取可选名片资产
+ const fetchCardAssets = async () => {
+ try {
+ const { data } = await axios.post>(
+ CARDS_PAGE_API,
+ {
+ page: 1,
+ size: 100,
+ }
+ );
+ if (data.code === 1000 && data.data?.list) {
+ setCardOptions(data.data.list);
+ }
+ } catch {
+ // 容错处理
+ }
+ };
+
+ useEffect(() => {
+ fetchDeptTree();
+ fetchMembers(1, 20, undefined, '');
+ fetchCardAssets();
+ }, []);
+
+ // 展开扁平化部门选项
+ const flatDeptOptions = useMemo(() => {
+ const list: Array<{ label: string; value: number }> = [];
+ const walk = (nodes: DepartmentTreeNode[], level = 0) => {
+ for (const node of nodes) {
+ const prefix = level > 0 ? ' '.repeat(level * 2) : '';
+ list.push({
+ label: `${prefix}${node.name}`,
+ value: node.id,
+ });
+ if (node.children && node.children.length > 0) {
+ walk(node.children, level + 1);
+ }
+ }
+ };
+ walk(deptTree);
+ return list;
+ }, [deptTree]);
+
+ // 打开分配名片弹窗
+ const handleOpenAssign = (record: TeamMemberItem) => {
+ setAssigningMember(record);
+ setSelectedCardId(record.card?.id ? Number(record.card.id) : undefined);
+ setAssignModalVisible(true);
+ };
+
+ // 提交分配名片
+ const handleConfirmAssign = async () => {
+ if (!assigningMember || !selectedCardId) {
+ return;
+ }
+ setAssignSubmitting(true);
+ try {
+ const body: AssignCardRequest = {
+ memberId: assigningMember.id,
+ cardId: selectedCardId,
+ };
+ const { data } = await axios.post>(
+ ASSIGN_CARD_API,
+ body
+ );
+ if (data.code === 1000) {
+ Message.success(t['teamMembers.request.success']);
+ setAssignModalVisible(false);
+ fetchMembers();
+ } else {
+ Message.error(data.message || t['teamMembers.request.fail']);
+ }
+ } catch {
+ Message.error(t['teamMembers.request.fail']);
+ } finally {
+ setAssignSubmitting(false);
+ }
+ };
+
+ // 打开批量调部弹窗
+ const handleOpenBatchMove = () => {
+ if (selectedMemberIds.length === 0) {
+ Message.error(t['teamMembers.validation.noSelection']);
+ return;
+ }
+ setMovingMemberIds(selectedMemberIds);
+ setMoveTargetDeptId(undefined);
+ setMoveModalVisible(true);
+ };
+
+ // 单成员调部
+ const handleOpenSingleMove = (record: TeamMemberItem) => {
+ setMovingMemberIds([record.id]);
+ setMoveTargetDeptId(record.departmentId ? Number(record.departmentId) : undefined);
+ setMoveModalVisible(true);
+ };
+
+ // 提交调整部门
+ const handleConfirmMove = async () => {
+ if (!moveTargetDeptId || movingMemberIds.length === 0) {
+ return;
+ }
+ setMoveSubmitting(true);
+ try {
+ const body: MoveMembersRequest = {
+ memberIds: movingMemberIds,
+ departmentId: moveTargetDeptId,
+ };
+ const { data } = await axios.post>(
+ MOVE_MEMBERS_API,
+ body
+ );
+ if (data.code === 1000) {
+ Message.success(t['teamMembers.request.success']);
+ setMoveModalVisible(false);
+ setSelectedMemberIds([]);
+ fetchMembers();
+ fetchDeptTree();
+ } else {
+ Message.error(data.message || t['teamMembers.request.fail']);
+ }
+ } catch {
+ Message.error(t['teamMembers.request.fail']);
+ } finally {
+ setMoveSubmitting(false);
+ }
+ };
+
+ // 提交新增部门
+ const handleConfirmAddDept = async () => {
+ if (!newDeptName.trim()) {
+ return;
+ }
+ setAddDeptSubmitting(true);
+ try {
+ const { data } = await axios.post>(ADD_DEPT_API, {
+ name: newDeptName.trim(),
+ parentId: newDeptParentId,
+ });
+ if (data.code === 1000) {
+ Message.success(t['teamMembers.request.success']);
+ setAddDeptModalVisible(false);
+ setNewDeptName('');
+ setNewDeptParentId(undefined);
+ fetchDeptTree();
+ } else {
+ Message.error(data.message || t['teamMembers.request.fail']);
+ }
+ } catch {
+ Message.error(t['teamMembers.request.fail']);
+ } finally {
+ setAddDeptSubmitting(false);
+ }
+ };
+
+ // 删除部门
+ const handleDeleteDept = async (deptId: number) => {
+ try {
+ const { data } = await axios.post>(
+ DELETE_DEPT_API,
+ { id: deptId }
+ );
+ if (data.code === 1000) {
+ Message.success(t['teamMembers.request.success']);
+ if (selectedDeptId === deptId) {
+ setSelectedDeptId(undefined);
+ fetchMembers(1, pagination.pageSize, undefined);
+ }
+ fetchDeptTree();
+ } else {
+ Message.error(data.message || t['teamMembers.request.fail']);
+ }
+ } catch {
+ Message.error(t['teamMembers.request.fail']);
+ }
+ };
+
+ // 递归树节点渲染
+ const renderTreeData = (nodes: DepartmentTreeNode[]): unknown[] => {
+ return nodes.map((node) => ({
+ key: String(node.id),
+ title: `${node.name} (${node.memberCount ?? 0})`,
+ children:
+ node.children && node.children.length > 0
+ ? renderTreeData(node.children)
+ : undefined,
+ }));
+ };
+
+ // 表格列定义
+ const columns: ColumnProps[] = [
+ {
+ title: t['teamMembers.memberTable.name'],
+ dataIndex: 'name',
+ render: (val: string) => (
+
+ {val || '-'}
+
+ ),
+ },
+ {
+ title: t['teamMembers.memberTable.username'],
+ dataIndex: 'username',
+ render: (val: string) => (
+ {val || '-'}
+ ),
+ },
+ {
+ title: t['teamMembers.memberTable.phone'],
+ dataIndex: 'phone',
+ render: (val: string) => (
+ {val || '-'}
+ ),
+ },
+ {
+ title: t['teamMembers.memberTable.department'],
+ dataIndex: 'departmentName',
+ render: (val: string) =>
+ val ? (
+ {val}
+ ) : (
+ -
+ ),
+ },
+ {
+ title: t['teamMembers.memberTable.card'],
+ dataIndex: 'card',
+ render: (card: TeamMemberItem['card']) => {
+ if (!card) {
+ return (
+
+ {t['teamMembers.memberTable.noCard']}
+
+ );
+ }
+ return (
+
+
+ {card.name}
+
+ {card.title && (
+
+ {card.title}
+
+ )}
+
+ );
+ },
+ },
+ {
+ title: t['teamMembers.memberTable.actions'],
+ render: (_: unknown, record: TeamMemberItem) => (
+
+
+
+
+ ),
+ },
+ ];
+
+ return (
+
+
+ {/* 左侧部门树 */}
+
+ {
+ setNewDeptName('');
+ setNewDeptParentId(selectedDeptId);
+ setAddDeptModalVisible(true);
+ }}
+ >
+ {t['teamMembers.deptTree.add']}
+
+ }
+ style={{
+ backgroundColor: lightColors.surface,
+ borderColor: lightColors.border,
+ }}
+ >
+
+
+
+
+ {
+ const id = keys.length ? Number(keys[0]) : undefined;
+ setSelectedDeptId(id);
+ fetchMembers(1, pagination.pageSize, id);
+ }}
+ />
+
+ {selectedDeptId && (
+
+
handleDeleteDept(selectedDeptId)}
+ >
+
+
+
+ )}
+
+
+
+ {/* 右侧成员列表 */}
+
+
+ {/* 顶栏操作区:单屏单一主 CTA */}
+
+ setKeyword(val)}
+ onSearch={() => fetchMembers(1, pagination.pageSize)}
+ onPressEnter={() => fetchMembers(1, pagination.pageSize)}
+ />
+
+
+
+ setSelectedMemberIds(keys.map(Number)),
+ }}
+ pagination={{
+ current: pagination.current,
+ pageSize: pagination.pageSize,
+ total: pagination.total,
+ showTotal: true,
+ onChange: (page, size) => fetchMembers(page, size),
+ }}
+ />
+
+
+
+
+ {/* 分配名片资产弹窗 */}
+ setAssignModalVisible(false)}
+ >
+
+
+
+ {t['teamMembers.modal.assignMember']}:
+
+
+ {assigningMember?.name} ({assigningMember?.username})
+
+
+
+
+ {t['teamMembers.modal.selectCard']}:
+
+
+
+
+
+
+ {/* 调整部门弹窗 */}
+ setMoveModalVisible(false)}
+ >
+
+
+
+ {t['teamMembers.modal.selectDept']}:
+
+
+
+
+
+
+ {/* 新增部门弹窗 */}
+ setAddDeptModalVisible(false)}
+ >
+
+
+
+ {t['teamMembers.deptTree.name']}:
+
+ setNewDeptName(val)}
+ />
+
+
+
+ {t['teamMembers.deptTree.title']} (上级部门):
+
+
+
+
+
+
+ );
+}
+
+export default TeamMembersPage;
diff --git a/admin/src/pages/team/members/locale/index.ts b/admin/src/pages/team/members/locale/index.ts
new file mode 100644
index 0000000..e616c36
--- /dev/null
+++ b/admin/src/pages/team/members/locale/index.ts
@@ -0,0 +1,76 @@
+const i18n = {
+ 'en-US': {
+ 'menu.team.members': 'Members & Structure',
+ 'teamMembers.deptTree.title': 'Organization Structure',
+ 'teamMembers.deptTree.all': 'All Departments',
+ 'teamMembers.deptTree.add': 'Add Dept',
+ 'teamMembers.deptTree.edit': 'Edit Dept',
+ 'teamMembers.deptTree.delete': 'Delete Dept',
+ 'teamMembers.deptTree.name': 'Department Name',
+ 'teamMembers.deptTree.namePlaceholder': 'Enter department name',
+ 'teamMembers.deptTree.order': 'Sort Order',
+ 'teamMembers.deptTree.confirmDelete': 'Are you sure you want to delete this department?',
+ 'teamMembers.memberTable.title': 'Team Members',
+ 'teamMembers.memberTable.name': 'Name',
+ 'teamMembers.memberTable.username': 'Username',
+ 'teamMembers.memberTable.phone': 'Phone',
+ 'teamMembers.memberTable.department': 'Department',
+ 'teamMembers.memberTable.card': 'Assigned Card',
+ 'teamMembers.memberTable.noCard': 'Unassigned',
+ 'teamMembers.memberTable.actions': 'Actions',
+ 'teamMembers.memberTable.assignCard': 'Assign Card',
+ 'teamMembers.memberTable.moveDept': 'Move Dept',
+ 'teamMembers.memberTable.batchMove': 'Batch Move Department',
+ 'teamMembers.memberTable.searchPlaceholder': 'Search by name, username or phone',
+ 'teamMembers.modal.assignTitle': 'Assign Digital Card',
+ 'teamMembers.modal.assignMember': 'Target Member',
+ 'teamMembers.modal.selectCard': 'Select Card Asset',
+ 'teamMembers.modal.selectCardPlaceholder': 'Choose a published digital card',
+ 'teamMembers.modal.moveTitle': 'Move Department',
+ 'teamMembers.modal.selectDept': 'Target Department',
+ 'teamMembers.modal.selectDeptPlaceholder': 'Select destination department',
+ 'teamMembers.modal.submit': 'Confirm',
+ 'teamMembers.modal.cancel': 'Cancel',
+ 'teamMembers.request.success': 'Operation completed successfully',
+ 'teamMembers.request.fail': 'Operation failed, please try again',
+ 'teamMembers.validation.noSelection': 'Please select at least one member',
+ },
+ 'zh-CN': {
+ 'menu.team.members': '组织架构与成员',
+ 'teamMembers.deptTree.title': '组织架构树',
+ 'teamMembers.deptTree.all': '全部部门',
+ 'teamMembers.deptTree.add': '新增部门',
+ 'teamMembers.deptTree.edit': '编辑部门',
+ 'teamMembers.deptTree.delete': '删除部门',
+ 'teamMembers.deptTree.name': '部门名称',
+ 'teamMembers.deptTree.namePlaceholder': '请输入部门名称',
+ 'teamMembers.deptTree.order': '排序号',
+ 'teamMembers.deptTree.confirmDelete': '确认删除该部门?',
+ 'teamMembers.memberTable.title': '成员列表',
+ 'teamMembers.memberTable.name': '姓名',
+ 'teamMembers.memberTable.username': '用户名',
+ 'teamMembers.memberTable.phone': '联系电话',
+ 'teamMembers.memberTable.department': '所属部门',
+ 'teamMembers.memberTable.card': '名片资产',
+ 'teamMembers.memberTable.noCard': '未分配',
+ 'teamMembers.memberTable.actions': '操作',
+ 'teamMembers.memberTable.assignCard': '分配名片',
+ 'teamMembers.memberTable.moveDept': '调整部门',
+ 'teamMembers.memberTable.batchMove': '批量调整部门',
+ 'teamMembers.memberTable.searchPlaceholder': '按姓名、用户名或手机号搜索',
+ 'teamMembers.modal.assignTitle': '分配名片资产',
+ 'teamMembers.modal.assignMember': '目标成员',
+ 'teamMembers.modal.selectCard': '选择名片资产',
+ 'teamMembers.modal.selectCardPlaceholder': '请选择已发布的数字名片',
+ 'teamMembers.modal.moveTitle': '调整部门',
+ 'teamMembers.modal.selectDept': '目标部门',
+ 'teamMembers.modal.selectDeptPlaceholder': '请选择目标部门',
+ 'teamMembers.modal.submit': '确定',
+ 'teamMembers.modal.cancel': '取消',
+ 'teamMembers.request.success': '操作成功',
+ 'teamMembers.request.fail': '操作失败,请重试',
+ 'teamMembers.validation.noSelection': '请先勾选需要调整的成员',
+ },
+};
+
+export default i18n;
diff --git a/admin/src/pages/welcome/locale/index.ts b/admin/src/pages/welcome/locale/index.ts
index b3a79b7..9e9fcd7 100644
--- a/admin/src/pages/welcome/locale/index.ts
+++ b/admin/src/pages/welcome/locale/index.ts
@@ -14,7 +14,7 @@ const i18n = {
'welcome.step.content.result': 'Then, you get a workplace page easily.',
'welcome.title.material':
'For more materials, please check the following link',
- 'welcome.link.material-pro': 'Arco Design Pro material collection',
+ 'welcome.link.material-pro': 'OmniCard material collection',
'welcome.link.material-all': 'All materials',
},
'zh-CN': {
@@ -30,7 +30,7 @@ const i18n = {
'得到包名后,您就可以通过如下命令安装该物料',
'welcome.step.content.result': '这样您就能轻松获得一个 workplace 页面',
'welcome.title.material': '更多物料请查看以下链接',
- 'welcome.link.material-pro': 'Arco Design Pro 物料合集',
+ 'welcome.link.material-pro': 'OmniCard 物料合集',
'welcome.link.material-all': '所有物料',
},
};
diff --git a/admin/src/routes.ts b/admin/src/routes.ts
index fbef1ad..0ba4b4b 100644
--- a/admin/src/routes.ts
+++ b/admin/src/routes.ts
@@ -27,6 +27,10 @@ export const routes: IRoute[] = [
{ resource: 'menu.dashboard.monitor', actions: ['write'] },
],
},
+ {
+ name: 'menu.dashboard.radarMap',
+ key: 'dashboard/radar-map',
+ },
],
},
{
@@ -55,6 +59,7 @@ export const routes: IRoute[] = [
children: [
{ name: 'menu.nfc.devices', key: 'nfc/devices' },
{ name: 'menu.nfc.batch', key: 'nfc/batch' },
+ { name: 'menu.nfc.rules', key: 'nfc/rules' },
],
},
{
@@ -62,12 +67,15 @@ export const routes: IRoute[] = [
key: 'ai',
children: [
{ name: 'menu.ai.knowledge', key: 'ai/knowledge' },
+ { name: 'menu.ai.concierge', key: 'ai/concierge' },
+ { name: 'menu.ai.translation', key: 'ai/translation' },
],
},
{
name: 'menu.team',
key: 'team',
children: [
+ { name: 'menu.team.members', key: 'team/members' },
{ name: 'menu.team.brandKit', key: 'team/brand-kit' },
{ name: 'menu.team.handover', key: 'team/handover' },
],
@@ -78,6 +86,7 @@ export const routes: IRoute[] = [
children: [
{ name: 'menu.settings.tenant', key: 'settings/tenant' },
{ name: 'menu.settings.billing', key: 'settings/billing' },
+ { name: 'menu.settings.auditLogs', key: 'settings/audit-logs' },
],
},
{
diff --git a/admin/src/types/omni.ts b/admin/src/types/omni.ts
index 7854ad4..71c638f 100644
--- a/admin/src/types/omni.ts
+++ b/admin/src/types/omni.ts
@@ -27,6 +27,8 @@ export interface CardProfile {
userId: string | number;
/** 姓名 */
name: string;
+ /** 个性化短链标识 (Slug) */
+ slug?: string;
/** 职业头衔/职位 */
title: string;
/** 所属企业/组织名称 */
@@ -65,6 +67,10 @@ export interface CardProfile {
city?: string;
/** 国家 */
country?: string;
+ /** 短链重定向类型 (none: 无, card: 接替人名片, portal: 企业公共微官网) */
+ redirectType?: HandoverRedirectType | string | null;
+ /** 短链重定向目标 (接替人名片 ID/Slug 或 企业公共微官网 URL) */
+ redirectTarget?: string | null;
}
/**
@@ -301,6 +307,39 @@ export interface HandoverRequest {
toUserId: string | number;
}
+export type HandoverRedirectType = 'none' | 'card' | 'portal';
+export type HandoverLeadAction = 'none' | 'transfer' | 'pool';
+export type HandoverStatus = 'pending' | 'processing' | 'completed' | 'failed';
+
+export interface ExecuteHandoverDTO {
+ fromUserId: number | string;
+ toUserId?: number | string | null;
+ cardId?: number | string | null;
+ redirectType?: HandoverRedirectType;
+ redirectTarget?: string | null;
+ leadAction?: HandoverLeadAction;
+ operatorId?: number | string | null;
+ remark?: string | null;
+}
+
+export interface TeamHandoverLog {
+ id?: number | string;
+ tenantId?: number | string;
+ handoverNo: string;
+ fromUserId: number;
+ toUserId?: number | null;
+ cardId?: number | null;
+ redirectType: HandoverRedirectType;
+ redirectTarget?: string | null;
+ leadAction: HandoverLeadAction;
+ migratedLeadCount: number;
+ operatorId?: number | null;
+ status: HandoverStatus;
+ remark?: string | null;
+ snapshot?: string | null;
+ createTime?: Date | string;
+}
+
export type DomainSslStatus = 'pending' | 'active' | 'failed';
export interface TenantDomain {
@@ -405,6 +444,100 @@ export interface AiKnowledgeDoc {
locale?: string;
}
+/**
+ * AI 顾问预设人设类型
+ */
+export type ConciergePersonaType =
+ | 'trade_expert'
+ | 'business_rep'
+ | 'tech_advisor'
+ | 'custom';
+
+/**
+ * AI 顾问语气风格
+ */
+export type ConciergeToneStyle =
+ | 'professional'
+ | 'direct'
+ | 'enthusiastic'
+ | 'friendly';
+
+/**
+ * AI 顾问人设与意向留资策略配置契约
+ */
+export interface AiConciergeConfig {
+ id?: string | number;
+ cardId: string | number;
+ personaType: ConciergePersonaType;
+ welcomeMsg: string;
+ triggerLeadThreshold: number;
+ promptTemplate: string;
+ toneStyle: ConciergeToneStyle;
+ isEnabled: boolean;
+}
+
+export type LocalizeTone = 'professional' | 'direct' | 'storytelling';
+
+export interface LocalizeRequest {
+ text: string;
+ targetLocale: string;
+ tone?: LocalizeTone;
+ domain?: string;
+}
+
+export interface LocalizeReply {
+ text: string;
+ locale: string;
+ tone: LocalizeTone;
+}
+
+export interface AiGlossaryItem {
+ id?: string | number;
+ sourceTerm: string;
+ targetTerm: string;
+ sourceLang?: string;
+ targetLang: string;
+ domain?: string;
+ notes?: string;
+ createTime?: Date | string;
+ updateTime?: Date | string;
+}
+
+/**
+ * RAG 知识库切片块契约
+ */
+export interface KnowledgeChunk {
+ id?: string | number;
+ index: number;
+ content: string;
+ length: number;
+ score?: number;
+}
+
+/**
+ * 知识库沙盒调试请求契约
+ */
+export interface KnowledgeSandboxRequest {
+ query: string;
+ content?: string;
+ cardId?: string | number;
+ id?: string | number;
+ chunkSize?: number;
+ overlap?: number;
+ topK?: number;
+ threshold?: number;
+ chunks?: KnowledgeChunk[];
+}
+
+/**
+ * 知识库沙盒调试响应契约
+ */
+export interface KnowledgeSandboxResult {
+ totalChunks: number;
+ chunks: KnowledgeChunk[];
+ recallList: KnowledgeChunk[];
+}
+
/**
* OpenGraph 社交元数据契约
*/
@@ -414,6 +547,18 @@ export interface ArticleOgMeta {
image: string;
}
+/**
+ * 名片微官网 OpenGraph 社交元数据契约
+ */
+export interface CardOgMeta {
+ title: string;
+ description: string;
+ image: string;
+ url?: string;
+ type?: string;
+ siteName?: string;
+}
+
/**
* 博客文章与动态契约
*/
@@ -449,4 +594,135 @@ export interface CardCase {
updateTime?: string | Date;
}
+/**
+ * 安全审计日志数据契约
+ */
+export interface BaseAuditLog {
+ id?: number | string;
+ module: string;
+ action: string;
+ operatorId?: number | null;
+ operatorName?: string | null;
+ ip?: string | null;
+ detail?: string | null;
+ status: number;
+ tenantId?: number | string | null;
+ createTime?: Date | string;
+ updateTime?: Date | string;
+}
+
+/**
+ * 部门树形节点数据契约
+ */
+export interface DepartmentTreeNode {
+ id: number;
+ name: string;
+ parentId?: number | null;
+ orderNum?: number;
+ parentName?: string;
+ memberCount?: number;
+ key?: number | string;
+ title?: string;
+ children?: DepartmentTreeNode[];
+}
+
+/**
+ * 团队成员与分配名片契约
+ */
+export interface TeamMemberItem {
+ id: number;
+ name: string;
+ username: string;
+ phone?: string;
+ email?: string;
+ departmentId?: number | null;
+ departmentName?: string;
+ status: number;
+ card?: {
+ id: number;
+ name: string;
+ title?: string;
+ company?: string;
+ slug?: string;
+ status?: string;
+ } | null;
+}
+
+/**
+ * 成员名片分配请求契约
+ */
+export interface AssignCardRequest {
+ memberId: number;
+ cardId: number;
+}
+
+/**
+ * 批量调整部门请求契约
+ */
+export interface MoveMembersRequest {
+ memberIds: number[];
+ departmentId: number;
+}
+
+// ==========================================
+// 11. 展会现场团队获客实时排行榜大屏广播契约 (Team Live Rank & Broadcast)
+// ==========================================
+
+/**
+ * 团队成员实时排行榜条目
+ */
+export interface TeamMemberRankItem {
+ /** 成员用户 ID */
+ memberId: number;
+ /** 成员姓名 */
+ memberName: string;
+ /** 头像 URL */
+ avatarUrl?: string;
+ /** 所属部门 ID */
+ departmentId?: number;
+ /** 所属部门名称 */
+ departmentName?: string;
+ /** 关联名片 ID */
+ cardId?: number;
+ /** 线索获取数 (Leads) */
+ leads: number;
+ /** 名片触碰量 (Touches) */
+ touches: number;
+ /** 加权综合得分 (每线索 10 分,每触碰 1 分) */
+ score: number;
+ /** 排名序号 (1-indexed,支持同分并列) */
+ rank: number;
+ /** 是否与他人同分并列 */
+ isTied?: boolean;
+}
+
+/**
+ * 实时排行榜广播元数据
+ */
+export interface TeamRankBroadcastMeta {
+ tenantId?: string;
+ eventId?: string;
+ topScore?: number;
+ totalLeads?: number;
+ totalTouches?: number;
+}
+
+/**
+ * 结构化 WebSocket 广播事件载荷
+ */
+export interface TeamRankBroadcastPayload {
+ /** 事件类型标识 (默认为 'team.live_rank') */
+ event: string;
+ /** 毫秒级时间戳 */
+ timestamp: number;
+ /** 排行榜成员总数 */
+ total: number;
+ /** 排行榜列表 */
+ ranks: TeamMemberRankItem[];
+ /** 排行榜列表别名 (兼容标准 WS 载荷 data 字段) */
+ data: TeamMemberRankItem[];
+ /** 聚合元数据统计 */
+ meta?: TeamRankBroadcastMeta;
+}
+
diff --git a/admin/tests/ai-concierge.test.ts b/admin/tests/ai-concierge.test.ts
new file mode 100644
index 0000000..3a633be
--- /dev/null
+++ b/admin/tests/ai-concierge.test.ts
@@ -0,0 +1,93 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+import { routes } from '../src/routes.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const conciergePagePath = path.join(adminRoot, 'src/pages/ai/concierge/index.tsx');
+const conciergeLocalePath = path.join(adminRoot, 'src/pages/ai/concierge/locale/index.ts');
+
+test('1. ai/concierge 页面必须使用 Arco Form/Select/Slider/Switch/Card/Radio 并接入 API', () => {
+ assert.ok(fs.existsSync(conciergePagePath), 'AI 顾问配置页面必须存在');
+ const content = fs.readFileSync(conciergePagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('admin/ai/concierge'),
+ '必须请求 /api/admin/ai/concierge 相关接口'
+ );
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(content.includes('Select'), '必须使用 Arco Select');
+ assert.ok(content.includes('Slider'), '必须使用 Arco Slider');
+ assert.ok(content.includes('Switch'), '必须使用 Arco Switch');
+ assert.ok(content.includes('Card'), '必须使用 Arco Card');
+ assert.ok(content.includes('Radio'), '必须使用 Arco Radio');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(!content.includes('Mock.js') && !content.includes("from 'mockjs'"), '禁止使用 Mock.js');
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('2. ai/concierge 必须遵循设计 Token,禁止 emoji 与硬编码颜色,支持预设人设与阈值', () => {
+ const content = fs.readFileSync(conciergePagePath, 'utf-8');
+
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
+ assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token');
+ assert.ok(!/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content), '禁止硬编码十六进制颜色');
+
+ // 禁止 emoji
+ const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
+ assert.ok(!emojiRegex.test(content), '页面中严禁使用 emoji 图标或字符');
+
+ // 人设切换支持
+ assert.ok(content.includes('trade_expert'), '必须支持外贸专家预设 trade_expert');
+ assert.ok(content.includes('business_rep'), '必须支持商务代表预设 business_rep');
+ assert.ok(content.includes('tech_advisor'), '必须支持技术顾问预设 tech_advisor');
+
+ // 意向留资策略与欢迎语
+ assert.ok(content.includes('welcomeMsg'), '必须包含欢迎语 welcomeMsg 字段');
+ assert.ok(content.includes('triggerLeadThreshold'), '必须包含留资触发阈值 triggerLeadThreshold');
+ assert.ok(content.includes('promptTemplate'), '必须包含提示词模板 promptTemplate');
+ assert.ok(content.includes('toneStyle'), '必须包含语气风格 toneStyle');
+ assert.ok(content.includes('isEnabled'), '必须包含启停状态 isEnabled');
+
+ // 单一主 CTA
+ const primaryButtons = content.match(/type=["']primary["']/g) || [];
+ assert.equal(primaryButtons.length, 1, '一屏只允许 1 个主 CTA 按钮');
+});
+
+test('3. ai/concierge 路由与国际化配置必须齐备', () => {
+ // 检查 routes.ts 包含 ai/concierge
+ const aiRoute = routes.find(r => r.key === 'ai');
+ assert.ok(aiRoute, 'menu.ai 路由分组必须存在');
+ const conciergeRoute = aiRoute.children?.find(c => c.key === 'ai/concierge');
+ assert.ok(conciergeRoute, 'ai/concierge 路由项必须在 routes.ts 中注册');
+ assert.equal(conciergeRoute?.name, 'menu.ai.concierge');
+
+ // 检查语言包
+ assert.ok(fs.existsSync(conciergeLocalePath), '页面语言包必须存在');
+ const localeContent = fs.readFileSync(conciergeLocalePath, 'utf-8');
+ assert.ok(localeContent.includes("'en-US'"), '必须包含英文案');
+ assert.ok(localeContent.includes("'zh-CN'"), '必须包含中文案');
+ assert.ok(localeContent.includes("'menu.ai.concierge'"), '页面语言包必须包含 menu.ai.concierge');
+ assert.ok(!/\bany\b/.test(localeContent), 'locale 禁止使用 any');
+
+ // 检查全局语言包
+ assert.ok(
+ defaultI18n['en-US']['menu.ai.concierge'],
+ 'src/locale/index.ts 必须包含 en-US menu.ai.concierge'
+ );
+ assert.ok(
+ defaultI18n['zh-CN']['menu.ai.concierge'],
+ 'src/locale/index.ts 必须包含 zh-CN menu.ai.concierge'
+ );
+});
diff --git a/admin/tests/ai-knowledge.test.ts b/admin/tests/ai-knowledge.test.ts
new file mode 100644
index 0000000..38abdd9
--- /dev/null
+++ b/admin/tests/ai-knowledge.test.ts
@@ -0,0 +1,104 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const knowledgePagePath = path.join(adminRoot, 'src/pages/ai/knowledge/index.tsx');
+const knowledgeLocalePath = path.join(adminRoot, 'src/pages/ai/knowledge/locale/index.ts');
+
+test('1. ai/knowledge 页面必须请求 knowledge/page 与 add,并使用 Arco Table/Form/Modal', () => {
+ assert.ok(fs.existsSync(knowledgePagePath), '知识库页面必须存在');
+ const content = fs.readFileSync(knowledgePagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('admin/ai/knowledge/page'),
+ '必须请求 POST /api/admin/ai/knowledge/page'
+ );
+ assert.ok(
+ content.includes('admin/ai/knowledge/add'),
+ '必须请求 POST /api/admin/ai/knowledge/add'
+ );
+ assert.ok(content.includes('Table'), '必须使用 Arco Table');
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(content.includes('Modal'), '必须使用 Arco Modal');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(content.includes('AiKnowledgeDoc'), '必须使用 AiKnowledgeDoc 类型');
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(!content.includes('Mock.js') && !content.includes("from 'mockjs'"), '禁止用 Mock.js 假数据冒充成功');
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('2. ai/knowledge 必须支持筛选、摘要列、编辑删除与空状态主 CTA', () => {
+ const content = fs.readFileSync(knowledgePagePath, 'utf-8');
+
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
+ assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token');
+ assert.ok(!/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content), '禁止硬编码颜色');
+
+ assert.ok(content.includes('title'), '必须展示 title');
+ assert.ok(content.includes('cardId'), '必须展示并筛选 cardId');
+ assert.ok(content.includes('locale'), '必须展示并筛选 locale');
+ assert.ok(content.includes('keyWord'), '筛选必须支持 keyWord');
+ assert.ok(content.includes('content'), '必须展示 content 摘要');
+
+ assert.ok(
+ content.includes('admin/ai/knowledge/update'),
+ '编辑必须请求 POST /api/admin/ai/knowledge/update'
+ );
+ assert.ok(
+ content.includes('admin/ai/knowledge/delete'),
+ '删除必须请求 POST /api/admin/ai/knowledge/delete'
+ );
+ assert.ok(content.includes('Popconfirm'), '删除必须使用 Arco Popconfirm');
+ assert.ok(content.includes('Upload'), '可选文本导入必须使用 Arco Upload');
+ assert.ok(
+ content.includes('readAsText') || content.includes('FileReader'),
+ 'Upload 只允许把文本读进 content'
+ );
+ assert.ok(
+ !content.includes('oss') && !content.includes('OSS'),
+ '禁止手写 OSS 上传'
+ );
+
+ assert.ok(content.includes('type="primary"'), '必须有一个主 CTA');
+ assert.ok(
+ content.includes('aiKnowledge.action.add') || content.includes('aiKnowledge.empty'),
+ '空状态必须复用新增 CTA 文案'
+ );
+ assert.ok(
+ content.includes('noDataElement') || content.includes('aiKnowledge.empty'),
+ '必须提供空状态'
+ );
+ assert.ok(!content.includes(' {
+ assert.ok(fs.existsSync(knowledgeLocalePath), '知识库语言包必须存在');
+ const content = fs.readFileSync(knowledgeLocalePath, 'utf-8');
+
+ assert.ok(content.includes("'menu.ai.knowledge'"), '页面语言包必须包含 menu.ai.knowledge');
+ assert.ok(content.includes("'en-US'"), '必须包含英文案');
+ assert.ok(content.includes("'zh-CN'"), '必须包含中文案');
+ assert.ok(content.includes('aiKnowledge.request.fail'), '必须包含失败提示文案');
+ assert.ok(content.includes('aiKnowledge.action.add'), '必须包含新增主 CTA 文案');
+ assert.ok(content.includes('aiKnowledge.empty'), '必须包含空状态文案');
+ assert.ok(content.includes('aiKnowledge.filter.cardId'), '必须包含 cardId 筛选文案');
+ assert.ok(content.includes('aiKnowledge.filter.locale'), '必须包含 locale 筛选文案');
+ assert.ok(content.includes('aiKnowledge.filter.keyWord'), '必须包含 keyWord 筛选文案');
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+
+ assert.equal(defaultI18n['en-US']['menu.ai.knowledge'], 'Knowledge Base');
+ assert.equal(defaultI18n['zh-CN']['menu.ai.knowledge'], '知识库');
+});
diff --git a/admin/tests/ai-sandbox.test.ts b/admin/tests/ai-sandbox.test.ts
new file mode 100644
index 0000000..e00d3f3
--- /dev/null
+++ b/admin/tests/ai-sandbox.test.ts
@@ -0,0 +1,68 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const knowledgePagePath = path.join(adminRoot, 'src/pages/ai/knowledge/index.tsx');
+const knowledgeLocalePath = path.join(adminRoot, 'src/pages/ai/knowledge/locale/index.ts');
+
+test('1. 知识库页面必须包含召回沙盒调试入口与 Arco Drawer/Progress 组件', () => {
+ assert.ok(fs.existsSync(knowledgePagePath), '知识库页面必须存在');
+ const content = fs.readFileSync(knowledgePagePath, 'utf-8');
+
+ assert.ok(content.includes('Drawer'), '必须使用 Arco Drawer 抽屉展示沙盒调试器');
+ assert.ok(content.includes('Progress'), '必须使用 Arco Progress 进度条展示置信度');
+ assert.ok(
+ content.includes('admin/ai/knowledge/sandbox-test'),
+ '必须调用 POST /api/admin/ai/knowledge/sandbox-test 调试接口'
+ );
+ assert.ok(
+ content.includes('KnowledgeChunk') || content.includes('KnowledgeSandboxResult'),
+ '必须使用 KnowledgeChunk 或 KnowledgeSandboxResult 契约类型'
+ );
+ assert.ok(content.includes('lightColors'), '必须引用 lightColors 设计 Token');
+ assert.ok(!/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content), '严禁硬编码十六进制颜色');
+ assert.ok(!/\bany\b/.test(content), '页面严禁使用 any');
+ // 严禁包含 emoji
+ assert.ok(!/[\uD83C-\uDBFF\uDC00-\uDFFF\u2600-\u27BF]/.test(content), '严禁使用 emoji');
+});
+
+test('2. 召回沙盒支持输入测试提问、切片卡片渲染与置信度分数进度条', () => {
+ const content = fs.readFileSync(knowledgePagePath, 'utf-8');
+
+ assert.ok(content.includes('sandboxVisible') || content.includes('sandboxDrawer') || content.includes('openSandbox'), '必须包含沙盒显示控制状态');
+ assert.ok(content.includes('query') || content.includes('sandboxQuery'), '必须支持测试提问 query 输入');
+ assert.ok(content.includes('recallList') || content.includes('recalledList') || content.includes('recallResults'), '必须维护召回切片结果列表');
+ assert.ok(content.includes('score'), '必须展示切片置信度 score');
+ assert.ok(content.includes('percent'), 'Progress 必须配置 percent 计算属性');
+ assert.ok(content.includes('chunkSize') || content.includes('topK'), '沙盒应支持调试参数配置');
+});
+
+test('3. 知识库多语言字典必须包含沙盒调试相关词条,且无 any 与无 emoji', () => {
+ assert.ok(fs.existsSync(knowledgeLocalePath), '语言包必须存在');
+ const content = fs.readFileSync(knowledgeLocalePath, 'utf-8');
+
+ assert.ok(content.includes('aiKnowledge.sandbox.title'), '必须包含沙盒标题文案');
+ assert.ok(content.includes('aiKnowledge.sandbox.query'), '必须包含测试提问文案');
+ assert.ok(content.includes('aiKnowledge.sandbox.test'), '必须包含沙盒测试执行按钮文案');
+ assert.ok(content.includes('aiKnowledge.sandbox.score'), '必须包含置信度分数文案');
+ assert.ok(!/\bany\b/.test(content), '语言包严禁使用 any');
+ assert.ok(!/[\uD83C-\uDBFF\uDC00-\uDFFF\u2600-\u27BF]/.test(content), '语言包严禁使用 emoji');
+});
+
+test('4. 沙盒置信度分数与进度条数值转换契约测试', () => {
+ // 模拟置信度归一化算法,验证 0~1 的 score 转换为 0~100 的 percent
+ const calculatePercent = (score?: number): number => {
+ if (score === undefined || Number.isNaN(score) || score <= 0) return 0;
+ if (score >= 1) return 100;
+ return Math.round(score * 100);
+ };
+
+ assert.equal(calculatePercent(0), 0);
+ assert.equal(calculatePercent(0.852), 85);
+ assert.equal(calculatePercent(0.999), 100);
+ assert.equal(calculatePercent(1.0), 100);
+ assert.equal(calculatePercent(undefined), 0);
+});
diff --git a/admin/tests/ai-translation.test.ts b/admin/tests/ai-translation.test.ts
new file mode 100644
index 0000000..dd870f4
--- /dev/null
+++ b/admin/tests/ai-translation.test.ts
@@ -0,0 +1,120 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const translationPagePath = path.join(adminRoot, 'src/pages/ai/translation/index.tsx');
+const translationLocalePath = path.join(adminRoot, 'src/pages/ai/translation/locale/index.ts');
+const routesPath = path.join(adminRoot, 'src/routes.ts');
+
+test('1. 路由与主词典必须注册 ai/translation 与 menu.ai.translation', () => {
+ assert.ok(fs.existsSync(routesPath), 'routes.ts 必须存在');
+ const routesContent = fs.readFileSync(routesPath, 'utf-8');
+
+ assert.ok(
+ routesContent.includes("'ai/translation'") || routesContent.includes('"ai/translation"'),
+ 'routes.ts 必须注册 key 为 ai/translation 的路由'
+ );
+ assert.ok(
+ routesContent.includes("'menu.ai.translation'") || routesContent.includes('"menu.ai.translation"'),
+ 'routes.ts 必须注册 name 为 menu.ai.translation'
+ );
+
+ assert.ok(defaultI18n['en-US']['menu.ai.translation'], '主词典 en-US 必须包含 menu.ai.translation');
+ assert.ok(defaultI18n['zh-CN']['menu.ai.translation'], '主词典 zh-CN 必须包含 menu.ai.translation');
+});
+
+test('2. ai/translation 国际化语言包必须包含中英文案且零 any', () => {
+ assert.ok(fs.existsSync(translationLocalePath), '翻译页面语言包必须存在');
+ const localeContent = fs.readFileSync(translationLocalePath, 'utf-8');
+
+ assert.ok(localeContent.includes("'en-US'"), '必须包含 en-US 词条');
+ assert.ok(localeContent.includes("'zh-CN'"), '必须包含 zh-CN 词条');
+ assert.ok(localeContent.includes('aiTranslation.playground.submit'), '必须包含试炼器提交按钮文案');
+ assert.ok(localeContent.includes('aiTranslation.glossary.action.add'), '必须包含术语库添加按钮文案');
+ assert.ok(localeContent.includes('aiTranslation.glossary.columns.sourceTerm'), '必须包含源术语表头文案');
+ assert.ok(localeContent.includes('aiTranslation.glossary.columns.targetTerm'), '必须包含目标术语表头文案');
+ assert.ok(!/\bany\b/.test(localeContent), 'locale 文件禁止使用 any');
+});
+
+test('3. ai/translation 页面必须实现实时润色与术语库管理,使用 Arco 组件与规范 API', () => {
+ assert.ok(fs.existsSync(translationPagePath), '润色中心页面必须存在');
+ const content = fs.readFileSync(translationPagePath, 'utf-8');
+
+ // Arco 组件库引入
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(content.includes('Table'), '必须使用 Arco Table');
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(content.includes('Modal'), '必须使用 Arco Modal');
+ assert.ok(content.includes('Select'), '必须使用 Arco Select');
+ assert.ok(content.includes('Tag'), '必须使用 Arco Tag');
+ assert.ok(content.includes('Input'), '必须使用 Arco Input');
+ assert.ok(content.includes('Popconfirm'), '必须使用 Arco Popconfirm');
+ assert.ok(content.includes('Upload'), '必须使用 Arco Upload 实现批量导入');
+
+ // 接口请求
+ assert.ok(
+ content.includes('/api/admin/ai/localize/localize') || content.includes('admin/ai/localize/localize'),
+ '必须调用润色接口 /api/admin/ai/localize/localize'
+ );
+ assert.ok(
+ content.includes('admin/ai/glossary/page'),
+ '必须请求术语库分页接口 /api/admin/ai/glossary/page'
+ );
+ assert.ok(
+ content.includes('admin/ai/glossary/add'),
+ '必须请求术语库新增接口 /api/admin/ai/glossary/add'
+ );
+ assert.ok(
+ content.includes('admin/ai/glossary/update'),
+ '必须请求术语库更新接口 /api/admin/ai/glossary/update'
+ );
+ assert.ok(
+ content.includes('admin/ai/glossary/delete'),
+ '必须请求术语库删除接口 /api/admin/ai/glossary/delete'
+ );
+ assert.ok(
+ content.includes('admin/ai/glossary/batchImport'),
+ '必须请求术语库批量导入接口 /api/admin/ai/glossary/batchImport'
+ );
+
+ // 业务契约复用与规范
+ assert.ok(
+ content.includes("from '@/types/omni'"),
+ '必须复用 admin/src/types/omni.ts 中的契约类型'
+ );
+ assert.ok(content.includes('AiGlossaryItem'), '必须使用 AiGlossaryItem 契约类型');
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 规范判断响应成功'
+ );
+ assert.ok(content.includes('Message.error'), '错误提示必须使用 Arco Message.error');
+ assert.ok(!content.includes('Mock.js') && !content.includes("from 'mockjs'"), '禁止用 Mock.js 假数据冒充成功');
+ assert.ok(!content.includes(' {
+ const content = fs.readFileSync(translationPagePath, 'utf-8');
+
+ assert.ok(!/\bany\b/.test(content), '页面严禁使用 any 类型');
+ assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token');
+ assert.ok(!/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content), '禁止硬编码 HEX 颜色');
+
+ // 禁止 emoji (常见 emoji Unicode 范围)
+ const emojiRegex = /[\u{1F300}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
+ assert.ok(!emojiRegex.test(content), '页面禁止包含任何 emoji 表情符号');
+
+ // 确保只有一个主 CTA (type="primary")
+ const primaryButtonMatches = content.match(/type="primary"/g) || [];
+ assert.equal(
+ primaryButtonMatches.length,
+ 1,
+ '瑞士国际主义风格约束:整屏只允许 1 个主 CTA (type="primary")'
+ );
+});
diff --git a/admin/tests/branding.test.ts b/admin/tests/branding.test.ts
new file mode 100644
index 0000000..68eb2ab
--- /dev/null
+++ b/admin/tests/branding.test.ts
@@ -0,0 +1,65 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+import { getOmniText } from '../src/locale/omni.ts';
+import loginLocale from '../src/pages/login/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const repoRoot = path.resolve(adminRoot, '..');
+
+function read(rel: string): string {
+ return fs.readFileSync(path.join(repoRoot, rel), 'utf8');
+}
+
+test('1. 跨端词典 common.app_name 必须为 OmniCard', () => {
+ assert.equal(getOmniText('zh-CN', 'common.app_name'), 'OmniCard');
+ assert.equal(getOmniText('en-US', 'common.app_name'), 'OmniCard');
+ assert.equal(getOmniText('ar-SA', 'common.app_name'), 'OmniCard');
+ assert.equal(defaultI18n['zh-CN']['common.app_name'], 'OmniCard');
+ assert.equal(defaultI18n['en-US']['common.app_name'], 'OmniCard');
+});
+
+test('2. 管理后台登录、导航、页脚、文档标题不得再出现 Arco 模板名', () => {
+ const files = [
+ 'admin/index.html',
+ 'admin/src/pages/login/index.tsx',
+ 'admin/src/pages/login/locale/index.ts',
+ 'admin/src/components/NavBar/index.tsx',
+ 'admin/src/components/Footer/index.tsx',
+ ];
+ for (const file of files) {
+ const content = read(file);
+ assert.ok(!content.includes('Arco Design Pro'), `${file} 不得再出现 Arco Design Pro`);
+ assert.ok(!content.includes('Arco Pro'), `${file} 不得再出现 Arco Pro`);
+ assert.ok(content.includes('OmniCard') || content.includes('common.app_name'), `${file} 应使用 OmniCard 品牌名`);
+ }
+
+ assert.equal(loginLocale['zh-CN']['login.form.title'], '登录 OmniCard');
+ assert.equal(loginLocale['en-US']['login.form.title'], 'Sign in to OmniCard');
+});
+
+test('3. 后端欢迎页与 Swagger 标题必须为 OmniCard', () => {
+ const welcome = read('backend/public/index.html');
+ assert.ok(welcome.includes('OmniCard'));
+ assert.ok(!welcome.includes('COOL-ADMIN'));
+ assert.ok(!welcome.includes('cool-admin'));
+
+ const swagger = read('backend/src/modules/swagger/config.ts');
+ assert.ok(swagger.includes('OmniCard'));
+ assert.ok(!swagger.includes('Cool Admin'));
+});
+
+test('4. 用户端应用名必须为 OmniCard', () => {
+ const manifest = read('mpuniapp/manifest.json');
+ assert.ok(manifest.includes('"name" : "OmniCard"') || manifest.includes('"name": "OmniCard"'));
+
+ const pages = read('mpuniapp/pages.json');
+ assert.ok(pages.includes('"navigationBarTitleText": "OmniCard"'));
+
+ const locale = read('mpuniapp/common/locale.uts');
+ assert.ok(locale.includes("'common.app_name'"));
+ assert.ok(locale.includes('OmniCard'));
+});
diff --git a/admin/tests/card-builder.test.ts b/admin/tests/card-builder.test.ts
new file mode 100644
index 0000000..cf1c7e7
--- /dev/null
+++ b/admin/tests/card-builder.test.ts
@@ -0,0 +1,118 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { routes } from '../src/routes.ts';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const builderPagePath = path.join(adminRoot, 'src/pages/card/builder/index.tsx');
+const builderLocalePath = path.join(
+ adminRoot,
+ 'src/pages/card/builder/locale/index.ts'
+);
+
+function flattenRouteKeys(items: typeof routes): string[] {
+ return items.flatMap((item) => [
+ item.key,
+ ...(item.children ? flattenRouteKeys(item.children) : []),
+ ]);
+}
+
+test('1. routes.ts 已注册 card/builder,对应 pages/card/builder', () => {
+ const keys = flattenRouteKeys(routes);
+ assert.ok(keys.includes('card/builder'), '应存在路由 key: card/builder');
+
+ const cardGroup = routes.find((item) => item.key === 'card');
+ assert.ok(
+ cardGroup?.children?.some(
+ (child) =>
+ child.key === 'card/builder' && child.name === 'menu.card.builder'
+ ),
+ 'card 分组下应有 menu.card.builder / card/builder'
+ );
+ assert.ok(fs.existsSync(builderPagePath), '必须存在 pages/card/builder/index.tsx');
+});
+
+test('2. locale 必须包含 menu.card.builder 中英文案', () => {
+ assert.equal(defaultI18n['en-US']['menu.card.builder'], 'Bento Builder');
+ assert.equal(defaultI18n['zh-CN']['menu.card.builder'], 'Bento 搭建器');
+});
+
+test('3. builder 必须请求 getBlocksByCardId / batchSaveLayout 并支持 colSpan', () => {
+ const content = fs.readFileSync(builderPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('getBlocksByCardId'),
+ '必须请求 POST /api/admin/card/bento/getBlocksByCardId'
+ );
+ assert.ok(
+ content.includes('batchSaveLayout'),
+ '必须请求 POST /api/admin/card/bento/batchSaveLayout'
+ );
+ assert.ok(content.includes('colSpan'), '必须读写 colSpan');
+ assert.ok(content.includes('rowSpan'), '必须读写 rowSpan');
+ assert.ok(
+ content.includes('{ cardId: Number(id) }'),
+ '拉取块必须提交 { cardId: Number(id) }'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+});
+
+test('4. builder 必须覆盖 hero/case/article 等 Bento 类型', () => {
+ const content = fs.readFileSync(builderPagePath, 'utf-8');
+
+ assert.ok(content.includes('hero'), '组件库必须包含 hero');
+ assert.ok(content.includes('case'), '组件库必须包含 case');
+ assert.ok(content.includes('article'), '组件库必须包含 article');
+ assert.ok(content.includes('social'), '组件库必须包含 social');
+ assert.ok(content.includes('video'), '组件库必须包含 video');
+ assert.ok(content.includes('form'), '组件库必须包含 form');
+ assert.ok(content.includes('BentoBlock'), '必须复用 BentoBlock 类型');
+ assert.ok(content.includes('BentoBlockType'), '必须复用 BentoBlockType 类型');
+});
+
+test('5. builder 必须用 query id、Arco 三栏与单一保存 CTA', () => {
+ const content = fs.readFileSync(builderPagePath, 'utf-8');
+
+ assert.ok(content.includes('useLocation'), '名片 id 必须来自 useLocation');
+ assert.ok(
+ content.includes('query-string') || content.includes('queryString'),
+ '必须用 query-string 解析 id'
+ );
+ assert.ok(content.includes('Layout'), '必须使用 Arco Layout');
+ assert.ok(content.includes('Input.TextArea'), '必须使用 Input.TextArea');
+ assert.ok(content.includes('Tabs'), '必须使用 Arco Tabs');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(
+ content.includes("from '@/style/tokens'"),
+ '颜色必须引用 admin/src/style/tokens'
+ );
+ assert.ok(content.includes('cardBuilder.save') || content.includes('保存排版'), '必须提供保存排版主 CTA');
+ assert.ok(content.includes('Empty') || content.includes('cardBuilder.empty'), '缺 id 必须 Empty 提示');
+ assert.ok(content.includes('/card/list'), '缺 id 必须提示回资产库');
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
+ '禁止使用 emoji'
+ );
+});
+
+test('6. builder 页面文案走 i18n 且无 emoji', () => {
+ assert.ok(fs.existsSync(builderLocalePath), '必须存在 builder locale');
+ const localeContent = fs.readFileSync(builderLocalePath, 'utf-8');
+ assert.ok(localeContent.includes('cardBuilder.save'), '必须有保存排版词条');
+ assert.ok(localeContent.includes('cardBuilder.empty'), '必须有缺 id 空状态词条');
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(localeContent),
+ 'locale 禁止使用 emoji'
+ );
+ assert.ok(!/\bany\b/.test(localeContent), 'locale 禁止使用 any');
+});
diff --git a/admin/tests/card-cases.test.ts b/admin/tests/card-cases.test.ts
new file mode 100644
index 0000000..c56f6a7
--- /dev/null
+++ b/admin/tests/card-cases.test.ts
@@ -0,0 +1,117 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { routes } from '../src/routes.ts';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const casesPagePath = path.join(adminRoot, 'src/pages/card/cases/index.tsx');
+const casesLocalePath = path.join(adminRoot, 'src/pages/card/cases/locale/index.ts');
+
+function flattenRouteKeys(items: typeof routes): string[] {
+ return items.flatMap((item) => [
+ item.key,
+ ...(item.children ? flattenRouteKeys(item.children) : []),
+ ]);
+}
+
+test('1. routes.ts 已注册 card/cases,对应 pages/card/cases', () => {
+ const keys = flattenRouteKeys(routes);
+ assert.ok(keys.includes('card/cases'), '应存在路由 key: card/cases');
+
+ const cardGroup = routes.find((item) => item.key === 'card');
+ assert.ok(
+ cardGroup?.children?.some(
+ (child) =>
+ child.key === 'card/cases' && child.name === 'menu.card.cases'
+ ),
+ 'card 分组下应有 menu.card.cases / card/cases'
+ );
+ assert.ok(fs.existsSync(casesPagePath), '必须存在 pages/card/cases/index.tsx');
+});
+
+test('2. locale 必须包含 menu.card.cases 中英文案', () => {
+ assert.equal(defaultI18n['en-US']['menu.card.cases'], 'Case Studies');
+ assert.equal(defaultI18n['zh-CN']['menu.card.cases'], '出海案例作品集');
+});
+
+test('3. cases 页面必须请求 CRUD 接口并遵守 Cool-Admin 协议', () => {
+ const content = fs.readFileSync(casesPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('/api/admin/card/cases/page'),
+ '必须请求 POST /api/admin/card/cases/page 分页拉取'
+ );
+ assert.ok(
+ content.includes('/api/admin/card/cases/add'),
+ '必须请求 POST /api/admin/card/cases/add 新增案例'
+ );
+ assert.ok(
+ content.includes('/api/admin/card/cases/update'),
+ '必须请求 POST /api/admin/card/cases/update 编辑案例'
+ );
+ assert.ok(
+ content.includes('/api/admin/card/cases/delete'),
+ '必须请求 POST /api/admin/card/cases/delete 删除案例'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断请求成功'
+ );
+});
+
+test('4. cases 页面必须使用 Arco Design 的 Table, Form, Modal, Tag, Upload 组件', () => {
+ const content = fs.readFileSync(casesPagePath, 'utf-8');
+
+ assert.ok(content.includes('Table'), '必须使用 Arco Table 组件');
+ assert.ok(content.includes('Form'), '必须使用 Arco Form 组件');
+ assert.ok(content.includes('Modal'), '必须使用 Arco Modal 弹窗组件');
+ assert.ok(content.includes('Tag'), '必须使用 Arco Tag 标签组件进行成果 KPI 展示');
+ assert.ok(content.includes('Upload'), '必须使用 Arco Upload 组件上传封面与 PDF');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+});
+
+test('5. cases 页面契约字段覆盖 (title, client, duration, metrics, coverImage, pdfUrl, cardId)', () => {
+ const content = fs.readFileSync(casesPagePath, 'utf-8');
+
+ assert.ok(content.includes('title'), '必须包含 title 字段');
+ assert.ok(content.includes('client'), '必须包含 client 字段');
+ assert.ok(content.includes('duration'), '必须包含 duration 字段');
+ assert.ok(content.includes('metrics'), '必须包含 metrics 字段');
+ assert.ok(content.includes('coverImage'), '必须包含 coverImage 字段');
+ assert.ok(content.includes('pdfUrl'), '必须包含 pdfUrl 字段');
+ assert.ok(content.includes('cardId'), '必须包含 cardId 字段');
+ assert.ok(content.includes('CardCase'), '必须复用 CardCase 数据契约');
+});
+
+test('6. 严格使用设计 Token,严禁 any 与 emoji', () => {
+ const content = fs.readFileSync(casesPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes("from '@/style/tokens'") || content.includes('tokens'),
+ '必须引用设计 Token'
+ );
+ assert.ok(!/\bany\b/.test(content), '页面严禁使用 any');
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
+ '页面严禁使用 emoji'
+ );
+});
+
+test('7. cases 国际化文件完备且无 emoji', () => {
+ assert.ok(fs.existsSync(casesLocalePath), '必须存在 cases 页面 locale 文件');
+ const localeContent = fs.readFileSync(casesLocalePath, 'utf-8');
+
+ assert.ok(localeContent.includes('cases.title'), '必须包含案例标题词条');
+ assert.ok(localeContent.includes('cases.metrics'), '必须包含成果指标词条');
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(localeContent),
+ 'locale 严禁使用 emoji'
+ );
+ assert.ok(!/\bany\b/.test(localeContent), 'locale 严禁使用 any');
+});
diff --git a/admin/tests/card-list.test.ts b/admin/tests/card-list.test.ts
new file mode 100644
index 0000000..39c1993
--- /dev/null
+++ b/admin/tests/card-list.test.ts
@@ -0,0 +1,81 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { routes } from '../src/routes.ts';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const listPagePath = path.join(adminRoot, 'src/pages/card/list/index.tsx');
+
+function flattenRouteKeys(items: typeof routes): string[] {
+ return items.flatMap((item) => [
+ item.key,
+ ...(item.children ? flattenRouteKeys(item.children) : []),
+ ]);
+}
+
+test('1. routes.ts 必须包含 key card/list,供 layout glob 加载', () => {
+ const keys = flattenRouteKeys(routes);
+ assert.ok(keys.includes('card'), '应存在一级菜单 key: card');
+ assert.ok(keys.includes('card/list'), '应存在路由 key: card/list');
+
+ const cardGroup = routes.find((item) => item.key === 'card');
+ assert.ok(cardGroup, '应存在 menu.card 分组');
+ assert.equal(cardGroup?.name, 'menu.card');
+ assert.ok(
+ cardGroup?.children?.some(
+ (child) => child.key === 'card/list' && child.name === 'menu.card.list'
+ ),
+ 'card 分组下应有 menu.card.list / card/list'
+ );
+
+ const demoKeys = ['dashboard', 'visualization', 'list', 'form', 'profile'];
+ for (const key of demoKeys) {
+ assert.ok(keys.includes(key), `不得删除已有演示路由: ${key}`);
+ }
+});
+
+test('2. locale 必须包含 menu.card / menu.card.list 中英文案', () => {
+ assert.equal(defaultI18n['en-US']['menu.card'], 'Digital Cards');
+ assert.equal(defaultI18n['en-US']['menu.card.list'], 'Card Library');
+ assert.equal(defaultI18n['zh-CN']['menu.card'], '数字名片');
+ assert.equal(defaultI18n['zh-CN']['menu.card.list'], '名片资产库');
+});
+
+test('3. list/index.tsx 必须请求 admin/card/info/page 并使用 Arco Table', () => {
+ assert.ok(fs.existsSync(listPagePath), '名片资产库页面必须存在');
+ const content = fs.readFileSync(listPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('admin/card/info/page'),
+ '必须请求 POST /api/admin/card/info/page'
+ );
+ assert.ok(content.includes('Table'), '必须使用 Arco Table');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(content.includes('code === 1000') || content.includes('code ===1000'), '必须按 Cool-Admin code === 1000 判断成功');
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(!content.includes('Mock.js') && !content.includes("from 'mockjs'"), '禁止用 Mock.js 假数据冒充成功');
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('4. list/index.tsx 必须支持复制短链与发布/撤回', () => {
+ const content = fs.readFileSync(listPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('/admin/card/info/update'),
+ '必须请求 POST /api/admin/card/info/update'
+ );
+ assert.ok(content.includes('published'), '发布必须提交 status: published');
+ assert.ok(content.includes('Popconfirm'), '发布必须使用 Arco Popconfirm');
+ assert.ok(content.includes('Message.success'), '复制短链成功必须 Message.success');
+ assert.ok(
+ content.includes('https://card.omnicard.ai/'),
+ '复制短链必须使用公开域名 https://card.omnicard.ai/{slug}'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
diff --git a/admin/tests/crm-lead-profile.test.ts b/admin/tests/crm-lead-profile.test.ts
new file mode 100644
index 0000000..8b95ffa
--- /dev/null
+++ b/admin/tests/crm-lead-profile.test.ts
@@ -0,0 +1,138 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const profilePagePath = path.join(
+ adminRoot,
+ 'src/pages/crm/lead-profile/index.tsx'
+);
+const profileLocalePath = path.join(
+ adminRoot,
+ 'src/pages/crm/lead-profile/locale/index.ts'
+);
+
+const emojiPattern = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
+
+test('1. crm/lead-profile 必须 GET lead/info 并 POST radar/page', () => {
+ assert.ok(fs.existsSync(profilePagePath), '线索画像页面必须存在');
+ const content = fs.readFileSync(profilePagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('admin/crm/lead/info'),
+ '必须请求 GET /api/admin/crm/lead/info'
+ );
+ assert.ok(
+ content.includes('axios.get'),
+ 'lead/info 必须使用 GET,而不是 POST'
+ );
+ assert.ok(
+ content.includes('admin/crm/radar/page'),
+ '必须请求 POST /api/admin/crm/radar/page'
+ );
+ assert.ok(content.includes('axios.post'), '足迹必须使用 POST radar/page');
+ assert.ok(
+ content.includes('keyWord') && content.includes('visitorId'),
+ '足迹请求必须用 visitorId 作为 keyWord'
+ );
+ assert.ok(content.includes('cardId'), '足迹请求必须支持 cardId 字段筛选');
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(
+ !content.includes('Mock.js') &&
+ !content.includes("from 'mockjs'") &&
+ !content.includes('mockjs'),
+ '禁止用 Mock.js 假数据冒充成功'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+ assert.ok(!emojiPattern.test(content), '禁止使用 emoji');
+});
+
+test('2. crm/lead-profile 必须用 Descriptions 或 Card + Table 展示画像与足迹', () => {
+ const content = fs.readFileSync(profilePagePath, 'utf-8');
+
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(content.includes('Descriptions') || content.includes('Card'), '必须使用 Descriptions 或 Card');
+ assert.ok(content.includes('Table'), '必须使用 Arco Table');
+ assert.ok(content.includes('LeadEntity'), '必须使用 LeadEntity 类型');
+ assert.ok(content.includes('RadarTrack'), '必须使用 RadarTrack 类型');
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
+ assert.ok(content.includes("from '@/style/tokens'"), '颜色必须引用设计 token');
+
+ for (const field of [
+ 'name',
+ 'company',
+ 'email',
+ 'phone',
+ 'status',
+ 'intentScore',
+ 'source',
+ 'notes',
+ ]) {
+ assert.ok(content.includes(field), `画像必须展示字段: ${field}`);
+ }
+ for (const field of [
+ 'touchType',
+ 'stayDurationMs',
+ 'scrollDepth',
+ 'viewedBlocks',
+ 'country',
+ 'city',
+ ]) {
+ assert.ok(content.includes(field), `足迹表必须展示字段: ${field}`);
+ }
+});
+
+test('3. crm/lead-profile 必须从 URL search 读取 id,无 id 给空状态主 CTA', () => {
+ const content = fs.readFileSync(profilePagePath, 'utf-8');
+
+ assert.ok(content.includes('useLocation'), '必须用 react-router useLocation 读路由');
+ assert.ok(content.includes('location.search'), '必须从 location.search 读取查询串');
+ assert.ok(
+ content.includes('query-string') ||
+ content.includes('queryString') ||
+ content.includes('URLSearchParams'),
+ '必须用 querystring 或 URLSearchParams 解析 id'
+ );
+ assert.ok(
+ content.includes("get('id')") ||
+ content.includes('.id') ||
+ content.includes("['id']"),
+ '必须读取 query 中的 id'
+ );
+ assert.ok(
+ content.includes('empty') || content.includes('Empty'),
+ '无 id 必须有空状态'
+ );
+ assert.ok(content.includes('type="primary"') || content.includes("type='primary'"), '空状态必须有一个主 CTA');
+ assert.ok(content.includes('/crm/leads'), '空状态主 CTA 应回到线索说明页');
+});
+
+test('4. locale 必须包含 menu.crm.leadProfile 中英文案,且禁止 any / mockjs / emoji', () => {
+ assert.ok(fs.existsSync(profileLocalePath), '线索画像语言包必须存在');
+ const content = fs.readFileSync(profileLocalePath, 'utf-8');
+
+ assert.ok(
+ content.includes("'menu.crm.leadProfile'"),
+ '页面语言包必须包含 menu.crm.leadProfile'
+ );
+ assert.equal(defaultI18n['en-US']['menu.crm.leadProfile'], 'Lead Profile');
+ assert.equal(defaultI18n['zh-CN']['menu.crm.leadProfile'], '线索画像');
+ assert.ok(content.includes('crmLeadProfile.request.fail'), '必须包含失败提示文案');
+ assert.ok(content.includes('crmLeadProfile.empty'), '必须包含空状态文案');
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes('mockjs'),
+ 'locale 禁止 Mock.js'
+ );
+ assert.ok(!emojiPattern.test(content), 'locale 禁止使用 emoji');
+});
diff --git a/admin/tests/crm-leads.test.ts b/admin/tests/crm-leads.test.ts
new file mode 100644
index 0000000..558d6d5
--- /dev/null
+++ b/admin/tests/crm-leads.test.ts
@@ -0,0 +1,61 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const leadsPagePath = path.join(adminRoot, 'src/pages/crm/leads/index.tsx');
+const leadsLocalePath = path.join(adminRoot, 'src/pages/crm/leads/locale/index.ts');
+
+test('1. crm/leads 页面必须请求 admin/crm/lead/page 并使用 Arco Table', () => {
+ assert.ok(fs.existsSync(leadsPagePath), '潜客线索页面必须存在');
+ const content = fs.readFileSync(leadsPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('admin/crm/lead/page'),
+ '必须请求 POST /api/admin/crm/lead/page'
+ );
+ assert.ok(content.includes('Table'), '必须使用 Arco Table');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(content.includes('LeadStatus'), '必须使用 LeadStatus 类型');
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(!content.includes('Mock.js') && !content.includes("from 'mockjs'"), '禁止用 Mock.js 假数据冒充成功');
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('2. crm/leads 必须复用 LeadEntity 契约与状态/来源筛选', () => {
+ const content = fs.readFileSync(leadsPagePath, 'utf-8');
+
+ assert.ok(content.includes('LeadEntity'), '必须使用 LeadEntity 类型');
+ assert.ok(content.includes('LeadSource'), '必须使用 LeadSource 类型');
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(content.includes('Select'), '必须使用 Arco Select');
+ assert.ok(content.includes('Tag'), '状态必须使用 Arco Tag');
+ assert.ok(content.includes('intentScore'), '必须展示 intentScore');
+ assert.ok(content.includes('notes'), '必须展示 notes 摘要');
+
+ for (const status of ['new', 'contacted', 'hot', 'won', 'archived']) {
+ assert.ok(content.includes(`'${status}'`), `必须支持状态: ${status}`);
+ }
+ for (const source of ['nfc', 'qr', 'link', 'ai_chat']) {
+ assert.ok(content.includes(`'${source}'`), `必须支持来源: ${source}`);
+ }
+});
+
+test('3. crm/leads locale 必须包含中英文案', () => {
+ assert.ok(fs.existsSync(leadsLocalePath), '潜客线索语言包必须存在');
+ const content = fs.readFileSync(leadsLocalePath, 'utf-8');
+
+ assert.ok(content.includes("'menu.crm.leads'"), '页面语言包必须包含 menu.crm.leads');
+ assert.ok(content.includes('crmLeads.request.fail'), '必须包含失败提示文案');
+ assert.ok(content.includes('crmLeads.status.new'), '必须包含状态文案');
+ assert.ok(content.includes('crmLeads.source.ai_chat'), '必须包含来源文案');
+});
diff --git a/admin/tests/crm-pipeline.test.ts b/admin/tests/crm-pipeline.test.ts
new file mode 100644
index 0000000..fa3ad83
--- /dev/null
+++ b/admin/tests/crm-pipeline.test.ts
@@ -0,0 +1,84 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const pipelinePagePath = path.join(adminRoot, 'src/pages/crm/pipeline/index.tsx');
+const pipelineLocalePath = path.join(
+ adminRoot,
+ 'src/pages/crm/pipeline/locale/index.ts'
+);
+
+test('1. crm/pipeline 必须请求 lead/page 与 updateStatus,并用 Arco Grid/Card', () => {
+ assert.ok(fs.existsSync(pipelinePagePath), '商机看板页面必须存在');
+ const content = fs.readFileSync(pipelinePagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('admin/crm/lead/page'),
+ '必须请求 POST /api/admin/crm/lead/page'
+ );
+ assert.ok(
+ content.includes('admin/crm/lead/updateStatus'),
+ '必须请求 POST /api/admin/crm/lead/updateStatus'
+ );
+ assert.ok(content.includes('Grid'), '必须使用 Arco Grid');
+ assert.ok(content.includes('Card'), '必须使用 Arco Card');
+ assert.ok(content.includes('Select'), '状态流转必须使用 Arco Select');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(content.includes('100'), '必须按 size=100 拉一批');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js 假数据冒充成功'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+ assert.ok(
+ !content.includes('react-beautiful-dnd') &&
+ !content.includes('dnd-kit') &&
+ !content.includes('react-dnd'),
+ '禁止自制或引入拖拽库'
+ );
+});
+
+test('2. crm/pipeline 必须复用 LeadEntity 并渲染五列状态看板', () => {
+ const content = fs.readFileSync(pipelinePagePath, 'utf-8');
+
+ assert.ok(content.includes('LeadEntity'), '必须使用 LeadEntity 类型');
+ assert.ok(content.includes('LeadStatus'), '必须使用 LeadStatus 类型');
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 omni 契约');
+ assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors');
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(content.includes('name'), '卡片必须展示 name');
+ assert.ok(content.includes('company'), '卡片必须展示 company');
+ assert.ok(content.includes('intentScore'), '卡片必须展示 intentScore');
+ assert.ok(content.includes('source'), '卡片必须展示 source');
+ assert.ok(content.includes('cardId'), '必须支持 cardId 筛选');
+ assert.ok(content.includes('keyWord'), '必须支持 keyWord 筛选');
+ assert.ok(content.includes('id') && content.includes('status'), 'updateStatus 必须传 id 与 status');
+
+ for (const status of ['new', 'contacted', 'hot', 'won', 'archived']) {
+ assert.ok(content.includes(`'${status}'`), `必须支持状态列: ${status}`);
+ }
+});
+
+test('3. crm/pipeline locale 必须包含 menu.crm.pipeline 与看板文案', () => {
+ assert.ok(fs.existsSync(pipelineLocalePath), '商机看板语言包必须存在');
+ const content = fs.readFileSync(pipelineLocalePath, 'utf-8');
+
+ assert.ok(content.includes("'menu.crm.pipeline'"), '页面语言包必须包含 menu.crm.pipeline');
+ assert.ok(content.includes('crmPipeline.request.fail'), '必须包含失败提示文案');
+ assert.ok(content.includes('crmPipeline.form.refresh'), '必须包含刷新主 CTA 文案');
+ assert.ok(content.includes('crmPipeline.column.empty'), '必须包含空列文案');
+ assert.ok(content.includes('crmPipeline.status.new'), '必须包含状态文案');
+ assert.ok(content.includes('crmPipeline.status.archived'), '必须包含归档状态文案');
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+});
diff --git a/admin/tests/crm-radar-log.test.ts b/admin/tests/crm-radar-log.test.ts
new file mode 100644
index 0000000..902d53b
--- /dev/null
+++ b/admin/tests/crm-radar-log.test.ts
@@ -0,0 +1,105 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { routes } from '../src/routes.ts';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const radarLogPagePath = path.join(adminRoot, 'src/pages/crm/radar-log/index.tsx');
+const radarLogLocalePath = path.join(
+ adminRoot,
+ 'src/pages/crm/radar-log/locale/index.ts'
+);
+
+function flattenRouteKeys(items: typeof routes): string[] {
+ return items.flatMap((item) => [
+ item.key,
+ ...(item.children ? flattenRouteKeys(item.children) : []),
+ ]);
+}
+
+test('1. routes.ts 必须包含 key crm/radar-log,供 layout glob 加载', () => {
+ const keys = flattenRouteKeys(routes);
+ assert.ok(keys.includes('crm'), '应存在一级菜单 key: crm');
+ assert.ok(keys.includes('crm/radar-log'), '应存在路由 key: crm/radar-log');
+
+ const crmGroup = routes.find((item) => item.key === 'crm');
+ assert.ok(crmGroup, '应存在 menu.crm 分组');
+ assert.equal(crmGroup?.name, 'menu.crm');
+ assert.ok(
+ crmGroup?.children?.some(
+ (child) =>
+ child.key === 'crm/radar-log' && child.name === 'menu.crm.radarLog'
+ ),
+ 'crm 分组下应有 menu.crm.radarLog / crm/radar-log'
+ );
+});
+
+test('2. locale 必须包含 menu.crm.radarLog 中英文案', () => {
+ assert.equal(defaultI18n['en-US']['menu.crm.radarLog'], 'Visitor Radar');
+ assert.equal(defaultI18n['zh-CN']['menu.crm.radarLog'], '访客足迹');
+});
+
+test('3. radar-log/index.tsx 必须请求 admin/crm/radar/page 并使用 Arco Table', () => {
+ assert.ok(fs.existsSync(radarLogPagePath), '访客足迹页面必须存在');
+ const content = fs.readFileSync(radarLogPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('admin/crm/radar/page'),
+ '必须请求 POST /api/admin/crm/radar/page'
+ );
+ assert.ok(content.includes('Table'), '必须使用 Arco Table');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(content.includes('Select'), '必须使用 Arco Select');
+ assert.ok(content.includes('Tag'), '必须使用 Arco Tag');
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js 假数据冒充成功'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('4. radar-log/index.tsx 必须按 RadarTrack / TouchType 渲染足迹列与筛选', () => {
+ const content = fs.readFileSync(radarLogPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('RadarTrack') && content.includes('TouchType'),
+ '必须引用 RadarTrack / TouchType'
+ );
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
+ assert.ok(content.includes('touchType'), '必须包含 touchType 筛选与列');
+ assert.ok(content.includes("'nfc'") && content.includes("'qr'") && content.includes("'link'"), 'touchType 必须覆盖 nfc/qr/link');
+ assert.ok(content.includes('cardId'), '必须支持 cardId 筛选');
+ assert.ok(content.includes('country'), '必须支持 country 筛选');
+ assert.ok(content.includes('keyWord'), '请求体必须包含 keyWord');
+ assert.ok(
+ content.includes('createTime') && content.includes('createdAt'),
+ '必须兼容 createTime / createdAt'
+ );
+ assert.ok(content.includes('visitorId'), '必须展示 visitorId');
+ assert.ok(content.includes('stayDurationMs'), '必须展示 stayDurationMs');
+ assert.ok(content.includes('scrollDepth'), '必须展示 scrollDepth');
+ assert.ok(content.includes('viewedBlocks'), '必须展示 viewedBlocks');
+ assert.ok(content.includes('setData([])'), '失败必须清空表格');
+});
+
+test('5. 页面 locale 必须覆盖筛选与列文案,且禁止 any', () => {
+ assert.ok(fs.existsSync(radarLogLocalePath), '访客足迹 locale 必须存在');
+ const content = fs.readFileSync(radarLogLocalePath, 'utf-8');
+ assert.ok(content.includes('radarLog.filter.cardId'), '必须有 cardId 筛选文案');
+ assert.ok(content.includes('radarLog.filter.touchType'), '必须有 touchType 筛选文案');
+ assert.ok(content.includes('radarLog.filter.country'), '必须有 country 筛选文案');
+ assert.ok(content.includes('radarLog.columns.viewedBlocks'), '必须有 viewedBlocks 列文案');
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+});
diff --git a/admin/tests/dashboard-radar-map.test.ts b/admin/tests/dashboard-radar-map.test.ts
new file mode 100644
index 0000000..555546a
--- /dev/null
+++ b/admin/tests/dashboard-radar-map.test.ts
@@ -0,0 +1,110 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { routes } from '../src/routes.ts';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const radarMapPagePath = path.join(adminRoot, 'src/pages/dashboard/radar-map/index.tsx');
+const radarMapLocalePath = path.join(adminRoot, 'src/pages/dashboard/radar-map/locale/index.ts');
+
+function flattenRouteKeys(items: typeof routes): string[] {
+ return items.flatMap((item) => [
+ item.key,
+ ...(item.children ? flattenRouteKeys(item.children) : []),
+ ]);
+}
+
+test('1. routes.ts 必须在 dashboard 分组下注册 dashboard/radar-map', () => {
+ const keys = flattenRouteKeys(routes);
+ assert.ok(keys.includes('dashboard/radar-map'), 'routes.ts 必须包含 dashboard/radar-map 路由');
+
+ const dashboardGroup = routes.find((item) => item.key === 'dashboard');
+ assert.ok(dashboardGroup, '应存在 dashboard 路由分组');
+ assert.ok(
+ dashboardGroup?.children?.some(
+ (child) => child.key === 'dashboard/radar-map' && child.name === 'menu.dashboard.radarMap'
+ ),
+ 'dashboard 分组下必须包含 menu.dashboard.radarMap (dashboard/radar-map)'
+ );
+});
+
+test('2. 主词典 index.ts 必须同步包含 menu.dashboard.radarMap 的中英文案', () => {
+ assert.ok(
+ defaultI18n['en-US']['menu.dashboard.radarMap'],
+ 'en-US 主词典必须包含 menu.dashboard.radarMap'
+ );
+ assert.ok(
+ defaultI18n['zh-CN']['menu.dashboard.radarMap'],
+ 'zh-CN 主词典必须包含 menu.dashboard.radarMap'
+ );
+ assert.equal(defaultI18n['zh-CN']['menu.dashboard.radarMap'], '全球访客雷达');
+});
+
+test('3. 大屏页面必须存在且使用 Arco Design 官方核心组件', () => {
+ assert.ok(fs.existsSync(radarMapPagePath), 'radar-map/index.tsx 页面文件必须存在');
+ const content = fs.readFileSync(radarMapPagePath, 'utf-8');
+
+ // 组件引入校验
+ assert.ok(content.includes("from '@arco-design/web-react'"), '必须从 @arco-design/web-react 引入组件');
+ assert.ok(content.includes('Card'), '必须使用 Arco Card');
+ assert.ok(content.includes('Grid') || (content.includes('Row') && content.includes('Col')), '必须使用 Arco Grid (Row, Col)');
+ assert.ok(content.includes('Spin'), '必须使用 Arco Spin 加载反馈');
+ assert.ok(content.includes('Tag'), '必须使用 Arco Tag 状态与排名标签');
+ assert.ok(content.includes('Table'), '必须使用 Arco Table 展示国家排行');
+ assert.ok(content.includes('Progress'), '必须使用 Arco Progress 展示占比进度条');
+});
+
+test('4. 大屏页面必须对接雷达聚合分析 API 且只设置 1 个主 CTA', () => {
+ const content = fs.readFileSync(radarMapPagePath, 'utf-8');
+
+ // 接口校验
+ assert.ok(
+ content.includes('/api/admin/crm/radar/analytics') || content.includes('/api/admin/crm/radar/distribution'),
+ '必须对接雷达分析或国家分布 API'
+ );
+
+ // 瑞士国际主义与一屏单主 CTA 原则: 统计 type="primary" 按钮数量
+ const primaryButtonMatches = content.match(/type=["']primary["']/g) || [];
+ assert.equal(
+ primaryButtonMatches.length,
+ 1,
+ '一屏只允许存在 1 个主 CTA 按钮 (type="primary")'
+ );
+});
+
+test('5. 必须使用设计 Token 系统且保持严谨纯粹 (零 any、零 emoji)', () => {
+ const content = fs.readFileSync(radarMapPagePath, 'utf-8');
+
+ // 设计 Token
+ assert.ok(
+ content.includes('@/style/tokens') || content.includes('tokens'),
+ '必须引用规范设计 Token 系统'
+ );
+
+ // 严格 TypeScript,禁止 any
+ assert.ok(!content.includes(': any'), '必须严格定义 TypeScript 类型,严禁使用 : any');
+ assert.ok(!content.includes('as any'), '必须严格定义 TypeScript 类型,严禁使用 as any');
+
+ // 严禁 Emoji 字符
+ const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
+ assert.ok(!emojiRegex.test(content), '页面中严禁硬编码或使用 Emoji 符号');
+});
+
+test('6. 页面专属国际化 locale 必须完整且中英文对齐', () => {
+ assert.ok(fs.existsSync(radarMapLocalePath), 'radar-map/locale/index.ts 必须存在');
+ const localeContent = fs.readFileSync(radarMapLocalePath, 'utf-8');
+
+ // 检查关键字典项
+ assert.ok(localeContent.includes("'en-US'"), '必须包含 en-US');
+ assert.ok(localeContent.includes("'zh-CN'"), '必须包含 zh-CN');
+ assert.ok(localeContent.includes('radarMap.title'), '必须包含 radarMap.title');
+ assert.ok(localeContent.includes('radarMap.topCountries'), '必须包含 radarMap.topCountries');
+ assert.ok(localeContent.includes('radarMap.deviceAnalytics'), '必须包含 radarMap.deviceAnalytics');
+
+ // 国际化文案中严禁 Emoji
+ const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
+ assert.ok(!emojiRegex.test(localeContent), '国际化语言包中严禁出现 Emoji 符号');
+});
diff --git a/admin/tests/nfc-batch.test.ts b/admin/tests/nfc-batch.test.ts
new file mode 100644
index 0000000..fc7df42
--- /dev/null
+++ b/admin/tests/nfc-batch.test.ts
@@ -0,0 +1,82 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const batchPagePath = path.join(adminRoot, 'src/pages/nfc/batch/index.tsx');
+const batchLocalePath = path.join(adminRoot, 'src/pages/nfc/batch/locale/index.ts');
+
+test('1. nfc/batch 页面必须循环请求 admin/nfc/device/add', () => {
+ assert.ok(fs.existsSync(batchPagePath), '批量制卡页面必须存在');
+ const content = fs.readFileSync(batchPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('admin/nfc/device/add'),
+ '必须请求 POST /api/admin/nfc/device/add'
+ );
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(content.includes('unbound'), '入库 payload 必须带 status: unbound');
+ assert.ok(content.includes('batchNo'), '入库 payload 必须带 batchNo');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js 假数据冒充成功'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('2. nfc/batch 必须使用 Form + Table + copy', () => {
+ const content = fs.readFileSync(batchPagePath, 'utf-8');
+
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(content.includes('Table'), '必须使用 Arco Table');
+ assert.ok(
+ content.includes("from 'copy-to-clipboard'") ||
+ content.includes('copy-to-clipboard'),
+ '必须使用 copy-to-clipboard 复制短链'
+ );
+ assert.ok(
+ content.includes('portal?uid=') || content.includes('portal?uid='),
+ '短链必须包含 portal?uid='
+ );
+ assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token');
+ assert.ok(content.includes('NFCMaterial'), '必须使用 NFCMaterial 类型');
+ assert.ok(content.includes("'wood'"), '必须支持材质 wood');
+ assert.ok(content.includes("'metal'"), '必须支持材质 metal');
+ assert.ok(content.includes("'pvc'"), '必须支持材质 pvc');
+ assert.ok(!/\bPagination\b/.test(content), '禁止手写自定义分页器');
+});
+
+test('3. nfc/batch 必须有 count 上限或校验文案', () => {
+ const pageContent = fs.readFileSync(batchPagePath, 'utf-8');
+ const localeContent = fs.readFileSync(batchLocalePath, 'utf-8');
+
+ const hasCountCap =
+ pageContent.includes('200') ||
+ pageContent.includes('max={200}') ||
+ localeContent.includes('nfcBatch.validation.count');
+ assert.ok(hasCountCap, '必须限制 count 上限 200 或提供校验文案');
+ assert.ok(pageContent.includes('count'), '表单必须包含 count');
+});
+
+test('4. nfc/batch locale 必须包含 menu.nfc.batch', () => {
+ assert.ok(fs.existsSync(batchLocalePath), '批量制卡语言包必须存在');
+ const content = fs.readFileSync(batchLocalePath, 'utf-8');
+
+ assert.ok(content.includes("'menu.nfc.batch'"), '页面语言包必须包含 menu.nfc.batch');
+ assert.ok(content.includes('nfcBatch.request.fail'), '必须包含失败提示文案');
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ 'locale 禁止 Mock.js'
+ );
+});
diff --git a/admin/tests/nfc-devices.test.ts b/admin/tests/nfc-devices.test.ts
new file mode 100644
index 0000000..d44b54c
--- /dev/null
+++ b/admin/tests/nfc-devices.test.ts
@@ -0,0 +1,91 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const devicesPagePath = path.join(adminRoot, 'src/pages/nfc/devices/index.tsx');
+const devicesLocalePath = path.join(adminRoot, 'src/pages/nfc/devices/locale/index.ts');
+
+test('1. nfc/devices 页面必须请求 admin/nfc/device/page 并使用 Arco Table', () => {
+ assert.ok(fs.existsSync(devicesPagePath), 'NFC 设备池页面必须存在');
+ const content = fs.readFileSync(devicesPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('admin/nfc/device/page'),
+ '必须请求 POST /api/admin/nfc/device/page'
+ );
+ assert.ok(content.includes('Table'), '必须使用 Arco Table');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(content.includes('NFCRecord'), '必须使用 NFCRecord 类型');
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(!content.includes('Mock.js') && !content.includes("from 'mockjs'"), '禁止用 Mock.js 假数据冒充成功');
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('2. nfc/devices 必须复用 NFC 契约、筛选列与挂失/重置', () => {
+ const content = fs.readFileSync(devicesPagePath, 'utf-8');
+
+ assert.ok(content.includes('NFCStatus'), '必须使用 NFCStatus 类型');
+ assert.ok(content.includes('NFCMaterial'), '必须使用 NFCMaterial 类型');
+ assert.ok(content.includes('NFCSceneMode'), '必须使用 NFCSceneMode 类型');
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(content.includes('Select'), '必须使用 Arco Select');
+ assert.ok(content.includes('Tag'), '必须使用 Arco Tag');
+ assert.ok(content.includes('Badge'), '必须使用 Arco Badge');
+ assert.ok(content.includes('Popconfirm'), '挂失/重置必须使用 Arco Popconfirm');
+ assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token');
+ assert.ok(!/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content), '禁止硬编码颜色');
+
+ assert.ok(content.includes('uid'), '必须展示 uid');
+ assert.ok(content.includes('batchNo'), '必须展示 batchNo');
+ assert.ok(content.includes('material'), '必须展示 material');
+ assert.ok(content.includes('cardId'), '必须展示 cardId');
+ assert.ok(content.includes('status'), '必须展示 status');
+ assert.ok(content.includes('sceneMode'), '必须展示 sceneMode');
+ assert.ok(content.includes('lastScannedAt'), '必须展示 lastScannedAt');
+ assert.ok(content.includes('keyWord'), '筛选必须支持 keyWord');
+
+ for (const status of ['active', 'suspended', 'unbound']) {
+ assert.ok(content.includes(`'${status}'`), `必须支持状态: ${status}`);
+ }
+ for (const material of ['wood', 'metal', 'pvc']) {
+ assert.ok(content.includes(`'${material}'`), `必须支持材质: ${material}`);
+ }
+
+ assert.ok(
+ content.includes('admin/nfc/security/freeze'),
+ '必须请求 POST /api/admin/nfc/security/freeze'
+ );
+ assert.ok(
+ content.includes('admin/nfc/security/reset'),
+ '必须请求 POST /api/admin/nfc/security/reset'
+ );
+ assert.ok(content.includes('type="primary"'), '查询必须是主按钮');
+ assert.ok(content.includes('type="secondary"'), '挂失/重置必须是次按钮');
+ assert.ok(!content.includes(' {
+ assert.ok(fs.existsSync(devicesLocalePath), 'NFC 设备池语言包必须存在');
+ const content = fs.readFileSync(devicesLocalePath, 'utf-8');
+
+ assert.ok(content.includes("'menu.nfc.devices'"), '页面语言包必须包含 menu.nfc.devices');
+ assert.ok(content.includes("'en-US'"), '必须包含英文案');
+ assert.ok(content.includes("'zh-CN'"), '必须包含中文案');
+ assert.ok(content.includes('nfcDevices.request.fail'), '必须包含失败提示文案');
+ assert.ok(content.includes('nfcDevices.status.active'), '必须包含状态文案');
+ assert.ok(content.includes('nfcDevices.material.wood'), '必须包含材质文案');
+ assert.ok(content.includes('nfcDevices.action.freeze'), '必须包含挂失文案');
+ assert.ok(content.includes('nfcDevices.action.reset'), '必须包含重置文案');
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+});
diff --git a/admin/tests/nfc-rules.test.ts b/admin/tests/nfc-rules.test.ts
new file mode 100644
index 0000000..2e0dbc0
--- /dev/null
+++ b/admin/tests/nfc-rules.test.ts
@@ -0,0 +1,129 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const rulesPagePath = path.join(adminRoot, 'src/pages/nfc/rules/index.tsx');
+const rulesLocalePath = path.join(adminRoot, 'src/pages/nfc/rules/locale/index.ts');
+const routesPath = path.join(adminRoot, 'src/routes.ts');
+const mainLocalePath = path.join(adminRoot, 'src/locale/index.ts');
+
+test('1. nfc/rules 页面必须与后端 /admin/nfc/rule 接口契约完全匹配', () => {
+ assert.ok(fs.existsSync(rulesPagePath), 'NFC 场景路由规则页面必须存在');
+ const content = fs.readFileSync(rulesPagePath, 'utf-8');
+
+ assert.ok(
+ content.includes('admin/nfc/rule/page'),
+ '必须请求 POST /api/admin/nfc/rule/page'
+ );
+ assert.ok(
+ content.includes('admin/nfc/rule/add'),
+ '必须请求 POST /api/admin/nfc/rule/add'
+ );
+ assert.ok(
+ content.includes('admin/nfc/rule/update'),
+ '必须请求 POST /api/admin/nfc/rule/update'
+ );
+ assert.ok(
+ content.includes('admin/nfc/rule/delete'),
+ '必须请求 POST /api/admin/nfc/rule/delete'
+ );
+ assert.ok(
+ content.includes('admin/nfc/rule/resolveScene'),
+ '必须支持调用 POST /api/admin/nfc/rule/resolveScene 进行场景解析'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(content.includes('Message.success'), '成功必须使用 Arco Message.success');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js 假数据冒充成功'
+ );
+ assert.ok(!/\bany\b/.test(content), '页面中禁止使用 any');
+});
+
+test('2. nfc/rules 必须复用 Arco 组件、设计 Token 与场景路由契约', () => {
+ const content = fs.readFileSync(rulesPagePath, 'utf-8');
+
+ // Arco 组件
+ assert.ok(content.includes('Table'), '必须使用 Arco Table');
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(content.includes('Modal'), '必须使用 Arco Modal');
+ assert.ok(content.includes('TimePicker'), '必须使用 Arco TimePicker');
+ assert.ok(content.includes('Select'), '必须使用 Arco Select');
+ assert.ok(content.includes('Switch'), '必须使用 Arco Switch');
+ assert.ok(content.includes('Tag'), '必须使用 Arco Tag');
+ assert.ok(content.includes('Popconfirm'), '必须使用 Arco Popconfirm');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(!content.includes(' {
+ // 检查 routes.ts
+ assert.ok(fs.existsSync(routesPath), 'routes.ts 必须存在');
+ const routesContent = fs.readFileSync(routesPath, 'utf-8');
+ assert.ok(
+ routesContent.includes("key: 'nfc/rules'"),
+ 'routes.ts 中必须注册 key: nfc/rules'
+ );
+ assert.ok(
+ routesContent.includes("name: 'menu.nfc.rules'"),
+ 'routes.ts 中必须声明 name: menu.nfc.rules'
+ );
+
+ // 检查主语言包
+ assert.ok(fs.existsSync(mainLocalePath), '主语言包 index.ts 必须存在');
+ const mainLocaleContent = fs.readFileSync(mainLocalePath, 'utf-8');
+ assert.ok(
+ mainLocaleContent.includes("'menu.nfc.rules'"),
+ '主语言包必须包含 menu.nfc.rules 词条'
+ );
+
+ // 检查页面语言包
+ assert.ok(fs.existsSync(rulesLocalePath), '规则页面语言包必须存在');
+ const rulesLocaleContent = fs.readFileSync(rulesLocalePath, 'utf-8');
+ assert.ok(rulesLocaleContent.includes("'en-US'"), '页面语言包必须包含英文案');
+ assert.ok(rulesLocaleContent.includes("'zh-CN'"), '页面语言包必须包含中文案');
+ assert.ok(rulesLocaleContent.includes('nfcRules.request.fail'), '必须包含失败提示文案');
+ assert.ok(rulesLocaleContent.includes('nfcRules.columns.name'), '必须包含规则名称文案');
+ assert.ok(rulesLocaleContent.includes('nfcRules.columns.sceneMode'), '必须包含场景模式文案');
+ assert.ok(rulesLocaleContent.includes('nfcRules.columns.timeRange'), '必须包含生效时段文案');
+ assert.ok(rulesLocaleContent.includes('nfcRules.columns.countryCode'), '必须包含地理国家文案');
+ assert.ok(rulesLocaleContent.includes('nfcRules.action.create'), '必须包含新建规则文案');
+ assert.ok(rulesLocaleContent.includes('nfcRules.action.resolve'), '必须包含路由解析文案');
+ assert.ok(!/\bany\b/.test(rulesLocaleContent), 'locale 禁止使用 any');
+});
diff --git a/admin/tests/settings-audit-logs.test.ts b/admin/tests/settings-audit-logs.test.ts
new file mode 100644
index 0000000..7f4fedd
--- /dev/null
+++ b/admin/tests/settings-audit-logs.test.ts
@@ -0,0 +1,116 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const auditPagePath = path.join(adminRoot, 'src/pages/settings/audit-logs/index.tsx');
+const auditLocalePath = path.join(
+ adminRoot,
+ 'src/pages/settings/audit-logs/locale/index.ts'
+);
+const routesPath = path.join(adminRoot, 'src/routes.ts');
+
+function readPage(): string {
+ assert.ok(fs.existsSync(auditPagePath), '安全审计日志页面文件必须存在');
+ return fs.readFileSync(auditPagePath, 'utf-8');
+}
+
+test('1. 路由配置中必须包含 settings/audit-logs 并在主语言包中声明菜单词条', () => {
+ assert.ok(fs.existsSync(routesPath), 'routes.ts 必须存在');
+ const routesContent = fs.readFileSync(routesPath, 'utf-8');
+
+ assert.ok(
+ routesContent.includes("'settings/audit-logs'") ||
+ routesContent.includes('"settings/audit-logs"'),
+ 'routes.ts 必须注册 settings/audit-logs'
+ );
+ assert.ok(
+ routesContent.includes('menu.settings.auditLogs'),
+ 'routes.ts 必须使用 menu.settings.auditLogs 作为国际化 key'
+ );
+
+ assert.equal(
+ defaultI18n['en-US']['menu.settings.auditLogs'],
+ 'Audit Logs',
+ 'en-US 主词典必须包含 menu.settings.auditLogs'
+ );
+ assert.ok(
+ defaultI18n['zh-CN']['menu.settings.auditLogs'] === '安全审计' ||
+ defaultI18n['zh-CN']['menu.settings.auditLogs'] === '审计日志',
+ 'zh-CN 主词典必须包含 menu.settings.auditLogs'
+ );
+});
+
+test('2. settings/audit-logs 页面必须采用 Arco Table, DatePicker, Select, Tag, Input 等官方组件', () => {
+ const content = readPage();
+
+ assert.ok(content.includes('Table'), '必须使用 Arco Table');
+ assert.ok(content.includes('DatePicker'), '必须使用 Arco DatePicker');
+ assert.ok(content.includes('Select'), '必须使用 Arco Select');
+ assert.ok(content.includes('Tag'), '必须使用 Arco Tag');
+ assert.ok(content.includes('Input'), '必须使用 Arco Input/Input.Search');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(
+ content.includes('admin/base/audit/page') ||
+ content.includes('/api/admin/base/audit/page'),
+ '必须请求 POST /api/admin/base/audit/page'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+});
+
+test('3. 严格遵循瑞士国际主义风格、单一主 CTA、引用设计 Token,且零 any、零 emoji', () => {
+ const content = readPage();
+
+ // 单一主 CTA: 统计 type="primary"
+ const primaryButtons = content.match(/type=["']primary["']/g) || [];
+ assert.equal(
+ primaryButtons.length,
+ 1,
+ '页面必须严格保持单一主 CTA (仅有 1 个 type="primary")'
+ );
+
+ // 严格 Token 引用,禁止硬编码颜色与纯黑
+ assert.ok(content.includes('lightColors'), '必须引用 lightColors token');
+ assert.ok(
+ !/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content),
+ '禁止硬编码 HEX 颜色值'
+ );
+ assert.ok(!content.includes('#000'), '禁止纯黑 #000');
+
+ // 零 any
+ assert.ok(!/\bany\b/.test(content), '页面严禁使用 any');
+
+ // 零 emoji
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
+ '页面严禁包含 emoji'
+ );
+});
+
+test('4. settings/audit-logs 语言包必须包含中英文案且零 emoji、零 any', () => {
+ assert.ok(fs.existsSync(auditLocalePath), '审计日志语言包文件必须存在');
+ const localeContent = fs.readFileSync(auditLocalePath, 'utf-8');
+
+ assert.ok(localeContent.includes("'en-US'"), '必须包含英文词典');
+ assert.ok(localeContent.includes("'zh-CN'"), '必须包含中文词典');
+ assert.ok(
+ localeContent.includes('menu.settings.auditLogs'),
+ '必须声明 menu.settings.auditLogs'
+ );
+ assert.ok(!/\bany\b/.test(localeContent), '语言包严禁使用 any');
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(
+ localeContent
+ ),
+ '语言包严禁包含 emoji'
+ );
+});
diff --git a/admin/tests/settings-billing.test.ts b/admin/tests/settings-billing.test.ts
new file mode 100644
index 0000000..a64d2e0
--- /dev/null
+++ b/admin/tests/settings-billing.test.ts
@@ -0,0 +1,136 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const billingPagePath = path.join(
+ adminRoot,
+ 'src/pages/settings/billing/index.tsx'
+);
+const billingLocalePath = path.join(
+ adminRoot,
+ 'src/pages/settings/billing/locale/index.ts'
+);
+
+function readPage(): string {
+ assert.ok(fs.existsSync(billingPagePath), '订阅账单页面必须存在');
+ return fs.readFileSync(billingPagePath, 'utf-8');
+}
+
+function readLocale(): string {
+ assert.ok(fs.existsSync(billingLocalePath), '订阅账单语言包必须存在');
+ return fs.readFileSync(billingLocalePath, 'utf-8');
+}
+
+test('1. settings/billing 必须请求 billing/currentPlan', () => {
+ const content = readPage();
+
+ assert.ok(
+ content.includes('billing/currentPlan'),
+ '必须请求 POST /api/admin/team/billing/currentPlan'
+ );
+ assert.ok(
+ content.includes('admin/team/billing/currentPlan'),
+ '必须请求完整后台路径 /api/admin/team/billing/currentPlan'
+ );
+ assert.ok(content.includes('axios'), '必须使用 axios 发请求');
+ assert.ok(
+ content.includes('{}'),
+ '进入页必须 POST currentPlan {}'
+ );
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(content.includes('BillingQuota'), '必须使用 BillingQuota 类型');
+ assert.ok(content.includes('TenantPlanType'), '必须使用 TenantPlanType 类型');
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js 假数据冒充成功'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+ assert.ok(
+ !content.includes('stripe.com') && !content.includes('sk_live'),
+ '源码不含 stripe.com 或 sk_live'
+ );
+});
+
+test('2. settings/billing 必须使用 Card / Progress / Tag', () => {
+ const content = readPage();
+
+ assert.ok(content.includes('Card'), '必须使用 Arco Card');
+ assert.ok(content.includes('Progress'), '必须使用 Arco Progress');
+ assert.ok(content.includes('Tag'), '必须使用 Arco Tag');
+ assert.ok(content.includes('Alert'), '必须使用 Arco Alert 说明后续接入');
+ assert.ok(content.includes('planType'), 'Card 必须展示 planType');
+ assert.ok(content.includes('seatsUsed'), 'Progress 必须展示 seatsUsed');
+ assert.ok(content.includes('seatsLimit'), 'Progress 必须展示 seatsLimit');
+ assert.ok(content.includes('cardsUsed'), 'Progress 必须展示 cardsUsed');
+ assert.ok(content.includes('cardsLimit'), 'Progress 必须展示 cardsLimit');
+ assert.ok(content.includes("'free'"), '必须说明 free 套餐');
+ assert.ok(content.includes("'pro'"), '必须说明 pro 套餐');
+ assert.ok(content.includes("'enterprise'"), '必须说明 enterprise 套餐');
+ assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token');
+ assert.ok(content.includes('type="primary"'), '必须有一个主 CTA 刷新配额');
+ assert.ok(
+ !/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content),
+ '禁止硬编码颜色'
+ );
+ assert.ok(
+ !content.includes('#000') && !content.includes('#000000'),
+ '禁止纯黑'
+ );
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+ assert.ok(
+ !content.includes('stripe.com') && !content.includes('sk_live'),
+ '源码不含 stripe.com 或 sk_live'
+ );
+});
+
+test('3. settings/billing locale 必须包含 menu.settings.billing', () => {
+ const content = readLocale();
+
+ assert.ok(
+ content.includes("'menu.settings.billing'"),
+ '页面语言包必须包含 menu.settings.billing'
+ );
+ assert.ok(content.includes("'en-US'"), '必须包含英文案');
+ assert.ok(content.includes("'zh-CN'"), '必须包含中文案');
+ assert.ok(
+ content.includes('settingsBilling.request.fail'),
+ '必须包含失败提示文案'
+ );
+ assert.ok(
+ content.includes('settingsBilling.action.refresh'),
+ '必须包含刷新配额主 CTA 文案'
+ );
+ assert.ok(
+ content.includes('settingsBilling.alert'),
+ '必须包含国际卡与发票后续接入提示'
+ );
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
+ 'locale 禁止使用 emoji'
+ );
+ assert.ok(
+ !content.includes('stripe.com') && !content.includes('sk_live'),
+ '源码不含 stripe.com 或 sk_live'
+ );
+
+ assert.equal(defaultI18n['en-US']['menu.settings.billing'], 'Billing');
+ assert.equal(defaultI18n['zh-CN']['menu.settings.billing'], '订阅账单');
+});
diff --git a/admin/tests/settings-tenant.test.ts b/admin/tests/settings-tenant.test.ts
new file mode 100644
index 0000000..487fe61
--- /dev/null
+++ b/admin/tests/settings-tenant.test.ts
@@ -0,0 +1,123 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const tenantPagePath = path.join(adminRoot, 'src/pages/settings/tenant/index.tsx');
+const tenantLocalePath = path.join(
+ adminRoot,
+ 'src/pages/settings/tenant/locale/index.ts'
+);
+
+function readPage(): string {
+ assert.ok(fs.existsSync(tenantPagePath), '租户域名页面必须存在');
+ return fs.readFileSync(tenantPagePath, 'utf-8');
+}
+
+test('1. settings/tenant 必须请求 domain/page 与 add 或 update,并使用 Arco Form', () => {
+ const content = readPage();
+
+ assert.ok(
+ content.includes('admin/team/domain/page'),
+ '必须请求 POST /api/admin/team/domain/page'
+ );
+ assert.ok(
+ content.includes('admin/team/domain/add') ||
+ content.includes('admin/team/domain/update'),
+ '必须请求 POST /api/admin/team/domain/add 或 update'
+ );
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js 假数据冒充成功'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('2. settings/tenant 必须拉取 size=1、按 id 分流保存,并展示 Tag 与 Alert', () => {
+ const content = readPage();
+
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
+ assert.ok(content.includes('TenantDomain'), '必须使用 TenantDomain 类型');
+ assert.ok(content.includes('DomainSslStatus'), '必须使用 DomainSslStatus 类型');
+ assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token');
+ assert.ok(!/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content), '禁止硬编码颜色');
+
+ assert.ok(content.includes('customDomain'), '表单必须包含 customDomain');
+ assert.ok(content.includes('sslStatus'), '必须展示 sslStatus');
+ assert.ok(content.includes('Tag'), 'sslStatus 必须使用 Arco Tag');
+ assert.ok(content.includes('Alert'), '证书检测必须使用 Arco Alert 展示');
+ assert.ok(
+ !/<(Alert)[^>]*type="success"/.test(content),
+ 'Alert 不得用 success 假装已签真实 SSL'
+ );
+ assert.ok(
+ content.includes('settingsTenant.ssl.alert'),
+ 'Alert 必须使用证书检测说明文案'
+ );
+ assert.ok(
+ content.includes("sslStatus: 'pending'") ||
+ content.includes('sslStatus: "pending"'),
+ '新域默认 sslStatus 必须为 pending'
+ );
+ assert.ok(
+ content.includes('size: 1') || content.includes('size:1'),
+ 'page 必须 size=1 取当前域名'
+ );
+ assert.ok(
+ content.includes('admin/team/domain/add'),
+ '无 id 保存必须请求 add'
+ );
+ assert.ok(
+ content.includes('admin/team/domain/update'),
+ '有 id 保存必须请求 update'
+ );
+ assert.ok(content.includes('type="primary"'), '必须有一个主 CTA');
+ assert.ok(
+ !content.includes('#000') && !content.includes('#000000'),
+ '禁止纯黑'
+ );
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('3. settings/tenant locale 必须包含 menu.settings.tenant 与中英文案', () => {
+ assert.ok(fs.existsSync(tenantLocalePath), '租户域名语言包必须存在');
+ const content = fs.readFileSync(tenantLocalePath, 'utf-8');
+
+ assert.ok(
+ content.includes("'menu.settings.tenant'"),
+ '页面语言包必须包含 menu.settings.tenant'
+ );
+ assert.ok(content.includes("'en-US'"), '必须包含英文案');
+ assert.ok(content.includes("'zh-CN'"), '必须包含中文案');
+ assert.ok(content.includes('settingsTenant.request.fail'), '必须包含失败提示文案');
+ assert.ok(content.includes('settingsTenant.form.save'), '必须包含保存主 CTA 文案');
+ assert.ok(content.includes('customDomain'), '必须包含 customDomain 文案');
+ assert.ok(content.includes('sslStatus') || content.includes('settingsTenant.ssl'), '必须包含 ssl 状态文案');
+ assert.ok(content.includes('settingsTenant.ssl.alert'), '必须包含证书检测 Alert 文案');
+ assert.ok(content.includes('pending'), '必须包含 pending 状态文案');
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
+ 'locale 禁止使用 emoji'
+ );
+
+ assert.equal(defaultI18n['en-US']['menu.settings.tenant'], 'Domain & SSL');
+ assert.equal(defaultI18n['zh-CN']['menu.settings.tenant'], '域名与证书');
+});
diff --git a/admin/tests/team-brand-kit.test.ts b/admin/tests/team-brand-kit.test.ts
new file mode 100644
index 0000000..cb47704
--- /dev/null
+++ b/admin/tests/team-brand-kit.test.ts
@@ -0,0 +1,114 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const brandKitPagePath = path.join(
+ adminRoot,
+ 'src/pages/team/brand-kit/index.tsx'
+);
+const brandKitLocalePath = path.join(
+ adminRoot,
+ 'src/pages/team/brand-kit/locale/index.ts'
+);
+
+function readPage(): string {
+ assert.ok(fs.existsSync(brandKitPagePath), 'Brand Kit 页面必须存在');
+ return fs.readFileSync(brandKitPagePath, 'utf-8');
+}
+
+test('1. team/brand-kit 必须请求 brand/page 与 add 或 update,并使用 Arco Form', () => {
+ const content = readPage();
+
+ assert.ok(
+ content.includes('admin/team/brand/page'),
+ '必须请求 POST /api/admin/team/brand/page'
+ );
+ assert.ok(
+ content.includes('admin/team/brand/add') ||
+ content.includes('admin/team/brand/update'),
+ '必须请求 POST /api/admin/team/brand/add 或 update'
+ );
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js 假数据冒充成功'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('2. team/brand-kit 必须拉取 size=1、保存按 id 分流,并预览 token 主色', () => {
+ const content = readPage();
+
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
+ assert.ok(content.includes('BrandKit'), '必须使用 BrandKit 类型');
+ assert.ok(content.includes('TenantProfile'), '必须使用 TenantProfile 类型');
+ assert.ok(
+ content.includes('lightColors') || content.includes('#0B5CFF'),
+ '必须引用 lightColors.primary 或默认 #0B5CFF'
+ );
+ assert.ok(content.includes('lightColors.primary') || content.includes('#0B5CFF'));
+
+ assert.ok(content.includes('companyName'), '表单必须包含 companyName');
+ assert.ok(content.includes('logoUrl'), '表单必须包含 logoUrl');
+ assert.ok(content.includes('primaryColor'), '表单必须包含 primaryColor');
+ assert.ok(
+ content.includes('size: 1') || content.includes('size:1'),
+ 'page 必须 size=1 取当前租户品牌'
+ );
+ assert.ok(
+ content.includes('admin/team/brand/add'),
+ '无 id 保存必须请求 add'
+ );
+ assert.ok(
+ content.includes('admin/team/brand/update'),
+ '有 id 保存必须请求 update'
+ );
+ assert.ok(content.includes('type="primary"'), '必须有一个主 CTA');
+ assert.ok(
+ !content.includes('#000') && !content.includes('#000000'),
+ '禁止纯黑'
+ );
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('3. team/brand-kit locale 必须包含 menu.team.brandKit 与中英文案', () => {
+ assert.ok(fs.existsSync(brandKitLocalePath), 'Brand Kit 语言包必须存在');
+ const content = fs.readFileSync(brandKitLocalePath, 'utf-8');
+
+ assert.ok(
+ content.includes("'menu.team.brandKit'"),
+ '页面语言包必须包含 menu.team.brandKit'
+ );
+ assert.ok(content.includes("'en-US'"), '必须包含英文案');
+ assert.ok(content.includes("'zh-CN'"), '必须包含中文案');
+ assert.ok(content.includes('teamBrandKit.request.fail'), '必须包含失败提示文案');
+ assert.ok(content.includes('teamBrandKit.form.save'), '必须包含保存主 CTA 文案');
+ assert.ok(content.includes('companyName'), '必须包含 companyName 文案');
+ assert.ok(content.includes('logoUrl'), '必须包含 logoUrl 文案');
+ assert.ok(content.includes('primaryColor'), '必须包含 primaryColor 文案');
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
+ 'locale 禁止使用 emoji'
+ );
+
+ assert.equal(defaultI18n['en-US']['menu.team.brandKit'], 'Brand Kit');
+ assert.equal(defaultI18n['zh-CN']['menu.team.brandKit'], '品牌规范');
+});
diff --git a/admin/tests/team-handover.test.ts b/admin/tests/team-handover.test.ts
new file mode 100644
index 0000000..d29a9cf
--- /dev/null
+++ b/admin/tests/team-handover.test.ts
@@ -0,0 +1,101 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const handoverPagePath = path.join(
+ adminRoot,
+ 'src/pages/team/handover/index.tsx'
+);
+const handoverLocalePath = path.join(
+ adminRoot,
+ 'src/pages/team/handover/locale/index.ts'
+);
+
+function readPage(): string {
+ assert.ok(fs.existsSync(handoverPagePath), '离职交接页面必须存在');
+ return fs.readFileSync(handoverPagePath, 'utf-8');
+}
+
+test('1. team/handover 必须请求 handover/transfer,并使用 Form 与 Popconfirm', () => {
+ const content = readPage();
+
+ assert.ok(
+ content.includes('admin/team/handover/transfer'),
+ '必须请求 POST /api/admin/team/handover/transfer'
+ );
+ assert.ok(content.includes('Form'), '必须使用 Arco Form');
+ assert.ok(content.includes('Popconfirm'), '必须使用 Arco Popconfirm');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.success'), '成功必须使用 Arco Message.success');
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js 假数据冒充成功'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('2. team/handover 必须包含必填字段、Alert 说明与单一主 CTA', () => {
+ const content = readPage();
+
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 admin/src/types/omni.ts');
+ assert.ok(content.includes('HandoverRequest'), '必须使用 HandoverRequest 类型');
+ assert.ok(content.includes('Alert'), '必须使用 Arco Alert');
+ assert.ok(content.includes('cardId'), '表单必须包含 cardId');
+ assert.ok(content.includes('fromUserId'), '表单必须包含 fromUserId');
+ assert.ok(content.includes('toUserId'), '表单必须包含 toUserId');
+ assert.ok(content.includes('required: true'), 'cardId / fromUserId / toUserId 必须必填');
+ assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token');
+ assert.ok(content.includes('type="primary"'), '必须有一个主 CTA');
+ assert.ok(
+ !/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content),
+ '禁止硬编码颜色'
+ );
+ assert.ok(
+ !content.includes('#000') && !content.includes('#000000'),
+ '禁止纯黑'
+ );
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('3. team/handover locale 必须包含 menu.team.handover 与中英文案', () => {
+ assert.ok(fs.existsSync(handoverLocalePath), '离职交接语言包必须存在');
+ const content = fs.readFileSync(handoverLocalePath, 'utf-8');
+
+ assert.ok(
+ content.includes("'menu.team.handover'"),
+ '页面语言包必须包含 menu.team.handover'
+ );
+ assert.ok(content.includes("'en-US'"), '必须包含英文案');
+ assert.ok(content.includes("'zh-CN'"), '必须包含中文案');
+ assert.ok(content.includes('teamHandover.form.cardId'), '必须包含 cardId 文案');
+ assert.ok(content.includes('teamHandover.form.fromUserId'), '必须包含 fromUserId 文案');
+ assert.ok(content.includes('teamHandover.form.toUserId'), '必须包含 toUserId 文案');
+ assert.ok(content.includes('teamHandover.alert.keepSlug'), '必须包含短链保留说明');
+ assert.ok(content.includes('teamHandover.form.submit'), '必须包含交接主 CTA 文案');
+ assert.ok(content.includes('teamHandover.request.success'), '必须包含成功提示文案');
+ assert.ok(content.includes('teamHandover.request.fail'), '必须包含失败提示文案');
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
+ 'locale 禁止使用 emoji'
+ );
+
+ assert.equal(defaultI18n['en-US']['menu.team.handover'], 'Handover');
+ assert.equal(defaultI18n['zh-CN']['menu.team.handover'], '离职交接');
+});
diff --git a/admin/tests/team-members.test.ts b/admin/tests/team-members.test.ts
new file mode 100644
index 0000000..ad71f6b
--- /dev/null
+++ b/admin/tests/team-members.test.ts
@@ -0,0 +1,133 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import defaultI18n from '../src/locale/index.ts';
+
+const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
+const membersPagePath = path.join(
+ adminRoot,
+ 'src/pages/team/members/index.tsx'
+);
+const membersLocalePath = path.join(
+ adminRoot,
+ 'src/pages/team/members/locale/index.ts'
+);
+const routesPath = path.join(adminRoot, 'src/routes.ts');
+
+function readPage(): string {
+ assert.ok(fs.existsSync(membersPagePath), '团队组织架构与成员管理页面必须存在');
+ return fs.readFileSync(membersPagePath, 'utf-8');
+}
+
+test('1. routes.ts 与主词典必须注册 team/members 路由', () => {
+ assert.ok(fs.existsSync(routesPath), 'routes.ts 必须存在');
+ const routesContent = fs.readFileSync(routesPath, 'utf-8');
+
+ assert.ok(
+ routesContent.includes("'team/members'") || routesContent.includes('"team/members"'),
+ 'routes.ts 必须包含 team/members 路由'
+ );
+ assert.ok(
+ routesContent.includes("'menu.team.members'") || routesContent.includes('"menu.team.members"'),
+ 'routes.ts 必须包含 menu.team.members 国际化 key'
+ );
+
+ assert.ok(defaultI18n['en-US']['menu.team.members'], '主词典必须包含英文 menu.team.members');
+ assert.ok(defaultI18n['zh-CN']['menu.team.members'], '主词典必须包含中文 menu.team.members');
+});
+
+test('2. team/members 必须调用部门树、成员分页与名片分配 API,并使用 Arco 核心组件', () => {
+ const content = readPage();
+
+ assert.ok(
+ content.includes('admin/team/members/getDepartmentTree') ||
+ content.includes('admin/team/members/department-tree'),
+ '必须请求部门树 API'
+ );
+ assert.ok(
+ content.includes('admin/team/members/getMemberPage') ||
+ content.includes('admin/team/members/member-page'),
+ '必须请求成员分页列表 API'
+ );
+ assert.ok(
+ content.includes('admin/team/members/assignCard') ||
+ content.includes('admin/team/members/assign-card'),
+ '必须请求分配名片 API'
+ );
+ assert.ok(
+ content.includes('admin/team/members/moveMembers') ||
+ content.includes('admin/team/members/move-members'),
+ '必须请求批量调整部门 API'
+ );
+ assert.ok(
+ content.includes('admin/card/info/page'),
+ '名片分配弹窗必须请求名片资产列表'
+ );
+
+ assert.ok(content.includes('Tree'), '左侧必须使用 Arco Tree 展示部门树');
+ assert.ok(content.includes('Table'), '右侧必须使用 Arco Table 展示成员列表');
+ assert.ok(content.includes('Modal'), '名片分配必须使用 Arco Modal');
+ assert.ok(
+ content.includes("from '@arco-design/web-react'"),
+ '必须从 @arco-design/web-react 引入组件'
+ );
+ assert.ok(
+ content.includes('code === 1000') || content.includes('code ===1000'),
+ '必须按 Cool-Admin code === 1000 判断成功'
+ );
+ assert.ok(content.includes('Message.success'), '成功必须使用 Arco Message.success');
+ assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
+ assert.ok(
+ !content.includes('Mock.js') && !content.includes("from 'mockjs'"),
+ '禁止用 Mock.js'
+ );
+ assert.ok(!/\bany\b/.test(content), '禁止使用 any');
+});
+
+test('3. team/members 必须遵守瑞士国际主义设计规范与 Token 约束', () => {
+ const content = readPage();
+
+ assert.ok(content.includes("from '@/types/omni'"), '必须复用 omni 类型契约');
+ assert.ok(content.includes('DepartmentTreeNode'), '必须使用 DepartmentTreeNode 类型');
+ assert.ok(content.includes('TeamMemberItem'), '必须使用 TeamMemberItem 类型');
+
+ assert.ok(content.includes('lightColors'), '必须引用 lightColors 设计 Token');
+ assert.ok(content.includes('type="primary"'), '必须具有且仅有单一主 CTA');
+
+ // 禁止硬编码颜色
+ assert.ok(
+ !/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content),
+ '禁止硬编码 16 进制颜色代码'
+ );
+ assert.ok(
+ !content.includes('#000') && !content.includes('#000000'),
+ '禁止纯黑 #000'
+ );
+
+ // 禁止 emoji
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
+ '页面禁止使用 emoji'
+ );
+});
+
+test('4. team/members locale 必须包含中英多语言且零 emoji、零 any', () => {
+ assert.ok(fs.existsSync(membersLocalePath), '页面语言包必须存在');
+ const content = fs.readFileSync(membersLocalePath, 'utf-8');
+
+ assert.ok(content.includes("'en-US'"), '必须包含英文词典');
+ assert.ok(content.includes("'zh-CN'"), '必须包含中文词典');
+ assert.ok(content.includes('menu.team.members'), '必须包含菜单国际化 key');
+ assert.ok(content.includes('teamMembers.deptTree.title'), '必须包含部门树标题 key');
+ assert.ok(content.includes('teamMembers.memberTable.title'), '必须包含成员列表标题 key');
+ assert.ok(content.includes('teamMembers.modal.assignTitle'), '必须包含名片分配弹窗 key');
+ assert.ok(content.includes('teamMembers.modal.moveTitle'), '必须包含批量调部弹窗 key');
+
+ assert.ok(!/\bany\b/.test(content), 'locale 禁止使用 any');
+ assert.ok(
+ !/[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u.test(content),
+ 'locale 禁止使用 emoji'
+ );
+});
diff --git a/admin/tests/tokens-and-locale.test.ts b/admin/tests/tokens-and-locale.test.ts
index ac289b4..59aa00b 100644
--- a/admin/tests/tokens-and-locale.test.ts
+++ b/admin/tests/tokens-and-locale.test.ts
@@ -69,15 +69,20 @@ test('3. admin/src/style/tokens.ts - CSS 变量生成辅助函数', () => {
});
test('4. admin/src/locale/omni.ts - 多语言字典与 RTL 支持', () => {
- const locales = ['zh-CN', 'en-US', 'ar-SA'] as const;
+ const locales = ['zh-CN', 'en-US', 'ar-SA', 'es-ES', 'de-DE', 'fr-FR', 'ja-JP'] as const;
+ const requiredKeys = [
+ 'common.confirm',
+ 'common.cancel',
+ 'card.save_contact',
+ 'card.exchange',
+ 'card.ai_chat',
+ 'radar.online_visitor',
+ ] as const;
for (const loc of locales) {
assert.ok(omniLocales[loc], `应当包含语言包: ${loc}`);
- assert.ok(omniLocales[loc].messages['card.save_contact']);
- assert.ok(omniLocales[loc].messages['card.exchange']);
- assert.ok(omniLocales[loc].messages['card.ai_chat']);
- assert.ok(omniLocales[loc].messages['radar.online_visitor']);
- assert.ok(omniLocales[loc].messages['common.confirm']);
- assert.ok(omniLocales[loc].messages['common.cancel']);
+ for (const key of requiredKeys) {
+ assert.ok(omniLocales[loc].messages[key], `语言 ${loc} 缺少词条 ${key}`);
+ }
}
// 阿拉伯语 RTL 校验
@@ -86,11 +91,18 @@ test('4. admin/src/locale/omni.ts - 多语言字典与 RTL 支持', () => {
assert.equal(omniLocales['zh-CN'].dir, 'ltr');
assert.equal(isRtl('zh-CN'), false);
assert.equal(isRtl('en-US'), false);
+ assert.equal(omniLocales['es-ES'].dir, 'ltr');
+ assert.equal(omniLocales['de-DE'].dir, 'ltr');
+ assert.equal(omniLocales['fr-FR'].dir, 'ltr');
+ assert.equal(omniLocales['ja-JP'].dir, 'ltr');
// 获取文案辅助函数
assert.equal(getOmniText('zh-CN', 'card.save_contact'), '保存到通讯录');
assert.equal(getOmniText('en-US', 'card.save_contact'), 'Save Contact');
assert.equal(getOmniText('ar-SA', 'card.save_contact'), 'حفظ جهة الاتصال');
+ const jaSaveContact = getOmniText('ja-JP', 'card.save_contact');
+ assert.ok(jaSaveContact.length > 0);
+ assert.notEqual(jaSaveContact, '保存到通讯录');
// 禁止包含任何 emoji 校验
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
@@ -130,6 +142,7 @@ test('6. admin/src/locale/index.ts - 成功合并 omni 词条且支持 ar-SA', (
assert.ok(defaultI18n['en-US']['card.save_contact'] === 'Save Contact');
assert.ok(defaultI18n['ar-SA']['card.save_contact'] === 'حفظ جهة الاتصال');
assert.ok(defaultI18n['ar-SA']['radar.online_visitor'] === 'الزوار الحاليون');
+ assert.equal(defaultI18n['zh-CN']['common.app_name'], 'OmniCard');
});
diff --git a/backend/.gitignore b/backend/.gitignore
index 3022174..b497cfd 100644
--- a/backend/.gitignore
+++ b/backend/.gitignore
@@ -19,3 +19,4 @@ launch.json
data/*
pnpm-lock.yaml
public/uploads/*
+src/index.ts
diff --git a/backend/package.json b/backend/package.json
index 8cda4f2..2836f42 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,7 +1,7 @@
{
"name": "@crmmp/backend",
"version": "8.0.0",
- "description": "一个很酷的Ai快速开发框架",
+ "description": "OmniCard API",
"private": true,
"dependencies": {
"@cool-midway/core": "^8.0.7",
@@ -28,6 +28,7 @@
"md5": "^2.3.0",
"moment": "^2.30.1",
"mysql2": "^3.14.0",
+ "qrcode": "^1.5.4",
"svg-captcha": "^1.4.0",
"tslib": "^2.8.1",
"typeorm": "npm:@cool-midway/typeorm@0.3.20",
@@ -39,6 +40,7 @@
"@midwayjs/mock": "^3.20.3",
"@types/jest": "^29.5.14",
"@types/node": "22",
+ "@types/qrcode": "^1.5.6",
"@yao-pkg/pkg": "^6.3.2",
"cross-env": "^7.0.3",
"jest": "^29.7.0",
diff --git a/backend/public/index.html b/backend/public/index.html
index 10b2ba8..a5e1286 100644
--- a/backend/public/index.html
+++ b/backend/public/index.html
@@ -5,22 +5,21 @@
- COOL-ADMIN 一个很酷的后台权限管理系统
-
-
+ OmniCard
+
+
-HELLO COOL-ADMIN AI快速开发框架
+OmniCard
diff --git a/backend/src/comm/geoip/lookup.ts b/backend/src/comm/geoip/lookup.ts
new file mode 100644
index 0000000..e73f6e6
--- /dev/null
+++ b/backend/src/comm/geoip/lookup.ts
@@ -0,0 +1,145 @@
+/**
+ * 内置轻量 GeoIP 前缀表(零外部 HTTP、无 MaxMind 库文件)
+ * 供雷达公网 IP 兜底,私网/回环返回 null 以便仍归 Local
+ */
+
+export interface GeoIpLookup {
+ country: string;
+ city: string;
+}
+
+/** 精确 IP 或短前缀(按最长前缀优先匹配) */
+const GEOIP_PREFIXES: Array<{ prefix: string; country: string; city: string }> = [
+ { prefix: '8.8.8.8', country: 'US', city: 'Mountain View' },
+ { prefix: '8.8.4.4', country: 'US', city: 'Mountain View' },
+ { prefix: '8.8.8', country: 'US', city: 'Mountain View' },
+ { prefix: '8.8.4', country: 'US', city: 'Mountain View' },
+ { prefix: '1.1.1.1', country: 'AU', city: 'Sydney' },
+ { prefix: '1.0.0.1', country: 'AU', city: 'Sydney' },
+ { prefix: '1.1.1', country: 'AU', city: 'Sydney' },
+ { prefix: '114.114.114.114', country: 'CN', city: 'Nanjing' },
+ { prefix: '9.9.9.9', country: 'US', city: 'Berkeley' },
+ { prefix: '223.5.5.5', country: 'CN', city: 'Hangzhou' },
+ { prefix: '223.6.6.6', country: 'CN', city: 'Hangzhou' },
+ { prefix: '180.76.76.76', country: 'CN', city: 'Beijing' },
+ { prefix: '208.67.222.222', country: 'US', city: 'San Francisco' },
+ { prefix: '208.67.220.220', country: 'US', city: 'San Francisco' },
+];
+
+/** IPv4 首段粗粒度国家(RIR 常见分配,城市未知) */
+const FIRST_OCTET_COUNTRY: Record = {
+ 1: 'AU',
+ 8: 'US',
+ 9: 'US',
+ 14: 'CN',
+ 27: 'CN',
+ 36: 'CN',
+ 39: 'CN',
+ 42: 'CN',
+ 58: 'CN',
+ 59: 'CN',
+ 60: 'CN',
+ 61: 'CN',
+ 101: 'CN',
+ 106: 'CN',
+ 110: 'CN',
+ 111: 'CN',
+ 112: 'CN',
+ 113: 'CN',
+ 114: 'CN',
+ 115: 'CN',
+ 116: 'CN',
+ 117: 'CN',
+ 118: 'CN',
+ 119: 'CN',
+ 120: 'CN',
+ 121: 'CN',
+ 122: 'CN',
+ 123: 'CN',
+ 124: 'CN',
+ 125: 'CN',
+ 180: 'CN',
+ 183: 'CN',
+ 202: 'CN',
+ 218: 'CN',
+ 219: 'CN',
+ 220: 'CN',
+ 221: 'CN',
+ 222: 'CN',
+ 223: 'CN',
+};
+
+function normalizeIp(ip: string): string {
+ return ip.trim().replace(/^::ffff:/i, '');
+}
+
+function isPrivateOrLoopback(ip: string): boolean {
+ const clean = ip.toLowerCase();
+ if (!clean) {
+ return true;
+ }
+ if (clean === '::1' || clean === 'localhost' || clean === '0.0.0.0') {
+ return true;
+ }
+ if (clean === '127.0.0.1' || clean.startsWith('127.')) {
+ return true;
+ }
+ if (clean.startsWith('192.168.') || clean.startsWith('10.')) {
+ return true;
+ }
+ if (/^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(clean)) {
+ return true;
+ }
+ if (clean.startsWith('169.254.')) {
+ return true;
+ }
+ return false;
+}
+
+/** 文档/测试网段不推断国家,避免污染雷达 Unknown 兜底 */
+function isDocumentationIp(ip: string): boolean {
+ return (
+ ip.startsWith('192.0.2.') ||
+ ip.startsWith('198.51.100.') ||
+ ip.startsWith('203.0.113.')
+ );
+}
+
+function matchPrefix(ip: string, prefix: string): boolean {
+ return ip === prefix || ip.startsWith(`${prefix}.`);
+}
+
+/**
+ * 内置前缀表查询公网 IP 地理位置
+ * @returns 命中返回国家/城市;私网、回环、未识别返回 null
+ */
+export function lookupGeoIp(ip: string): GeoIpLookup | null {
+ if (!ip) {
+ return null;
+ }
+
+ const cleanIp = normalizeIp(ip);
+ if (isPrivateOrLoopback(cleanIp) || isDocumentationIp(cleanIp)) {
+ return null;
+ }
+
+ let hit: GeoIpLookup | null = null;
+ let hitLen = -1;
+ for (const row of GEOIP_PREFIXES) {
+ if (matchPrefix(cleanIp, row.prefix) && row.prefix.length > hitLen) {
+ hit = { country: row.country, city: row.city };
+ hitLen = row.prefix.length;
+ }
+ }
+ if (hit) {
+ return hit;
+ }
+
+ const first = Number(cleanIp.split('.')[0]);
+ const country = FIRST_OCTET_COUNTRY[first];
+ if (country) {
+ return { country, city: 'Unknown' };
+ }
+
+ return null;
+}
diff --git a/backend/src/comm/i18n/geo.ts b/backend/src/comm/i18n/geo.ts
new file mode 100644
index 0000000..3f08a11
--- /dev/null
+++ b/backend/src/comm/i18n/geo.ts
@@ -0,0 +1,155 @@
+/**
+ * Geo / 浏览器语言自动建议(零外部 GeoIP 依赖)
+ * 映射:zh* → zh-CN,ar* → ar-SA,en* → en-US,否则 en-US
+ */
+
+export type SuggestedLocale = 'zh-CN' | 'ar-SA' | 'en-US';
+
+export type GeoHeaderValue = string | string[] | undefined;
+
+export type GeoHeaderMap = Record;
+
+const DEFAULT_LOCALE: SuggestedLocale = 'en-US';
+
+const ZH_COUNTRIES = new Set(['CN', 'TW', 'HK']);
+const AR_COUNTRIES = new Set(['SA', 'AE', 'EG']);
+
+function firstHeaderValue(value: GeoHeaderValue): string | undefined {
+ if (Array.isArray(value)) {
+ for (const item of value) {
+ if (item && item.trim()) {
+ return item;
+ }
+ }
+ return undefined;
+ }
+ return value;
+}
+
+function getHeader(headers: GeoHeaderMap | null | undefined, name: string): string | undefined {
+ if (!headers) {
+ return undefined;
+ }
+ const target = name.toLowerCase();
+ for (const [key, value] of Object.entries(headers)) {
+ if (key.toLowerCase() === target) {
+ return firstHeaderValue(value);
+ }
+ }
+ return undefined;
+}
+
+function mapLangTag(raw: string): SuggestedLocale {
+ const lower = raw.trim().toLowerCase();
+ if (!lower) {
+ return DEFAULT_LOCALE;
+ }
+ if (lower.startsWith('zh')) {
+ return 'zh-CN';
+ }
+ if (lower.startsWith('ar')) {
+ return 'ar-SA';
+ }
+ if (lower.startsWith('en')) {
+ return 'en-US';
+ }
+ return DEFAULT_LOCALE;
+}
+
+function pickPreferredLang(header: string): string | undefined {
+ let bestTag = '';
+ let bestQ = -1;
+
+ for (const part of header.split(',')) {
+ const segments = part.trim().split(';');
+ const tag = (segments[0] || '').trim();
+ if (!tag || tag === '*') {
+ continue;
+ }
+
+ let q = 1;
+ for (let i = 1; i < segments.length; i++) {
+ const [key, value] = segments[i].split('=').map(item => item.trim());
+ if (key === 'q') {
+ const parsed = Number(value);
+ if (!Number.isNaN(parsed)) {
+ q = parsed;
+ }
+ }
+ }
+
+ if (q > bestQ) {
+ bestQ = q;
+ bestTag = tag;
+ }
+ }
+
+ return bestTag || undefined;
+}
+
+function mapCountry(country: string): SuggestedLocale {
+ const code = country.trim().toUpperCase();
+ if (!code) {
+ return DEFAULT_LOCALE;
+ }
+ if (ZH_COUNTRIES.has(code)) {
+ return 'zh-CN';
+ }
+ if (AR_COUNTRIES.has(code)) {
+ return 'ar-SA';
+ }
+ return DEFAULT_LOCALE;
+}
+
+/**
+ * 解析 Accept-Language,取 q 值最高的首选 locale
+ */
+export function parseAcceptLanguage(header?: string | string[] | null): SuggestedLocale {
+ const raw = Array.isArray(header) ? header.join(',') : header || '';
+ if (!raw.trim()) {
+ return DEFAULT_LOCALE;
+ }
+ const preferred = pickPreferredLang(raw);
+ if (!preferred) {
+ return DEFAULT_LOCALE;
+ }
+ return mapLangTag(preferred);
+}
+
+/**
+ * 解析 Cloudflare / 反向代理国家头:优先 cf-ipcountry,其次 x-country
+ */
+export function parseGeoLocale(headers?: GeoHeaderMap | null): SuggestedLocale {
+ const country =
+ getHeader(headers, 'cf-ipcountry') || getHeader(headers, 'x-country');
+ if (!country) {
+ return DEFAULT_LOCALE;
+ }
+ return mapCountry(country);
+}
+
+/**
+ * 优先级:query.lang > Accept-Language > geo country > en-US
+ */
+export function resolveSuggestedLocale(input: {
+ lang?: string | null;
+ headers?: GeoHeaderMap | null;
+}): SuggestedLocale {
+ const lang = input.lang?.trim();
+ if (lang) {
+ return mapLangTag(lang);
+ }
+
+ const accept = getHeader(input.headers, 'accept-language');
+ if (accept?.trim()) {
+ return parseAcceptLanguage(accept);
+ }
+
+ const country =
+ getHeader(input.headers, 'cf-ipcountry') || getHeader(input.headers, 'x-country');
+ if (country?.trim()) {
+ return parseGeoLocale(input.headers);
+ }
+
+ return DEFAULT_LOCALE;
+}
diff --git a/backend/src/comm/qrcode/qrcode.ts b/backend/src/comm/qrcode/qrcode.ts
new file mode 100644
index 0000000..1443380
--- /dev/null
+++ b/backend/src/comm/qrcode/qrcode.ts
@@ -0,0 +1,74 @@
+/**
+ * OmniCard AI (CRMmp) - 动态短链二维码生成引擎
+ * 复用成熟 qrcode 库输出 SVG / PNG,禁止手写 QR 编码算法
+ */
+
+import * as QRCode from 'qrcode';
+
+/** 微官网公开地址默认域(无环境变量时使用) */
+export const DEFAULT_CARD_PUBLIC_BASE = 'https://card.omnicard.ai';
+
+export interface CardQrcodeSvgResult {
+ svg: string;
+ url: string;
+}
+
+export interface CardQrcodePngResult {
+ png: string;
+ url: string;
+}
+
+/**
+ * 解析微官网公开基址:优先 CARD_PUBLIC_BASE_URL,否则默认域
+ */
+export function resolveCardPublicBase(
+ env: NodeJS.ProcessEnv = process.env
+): string {
+ const raw = env.CARD_PUBLIC_BASE_URL;
+ if (!raw || !raw.trim()) {
+ return DEFAULT_CARD_PUBLIC_BASE;
+ }
+ return raw.trim().replace(/\/+$/, '');
+}
+
+/**
+ * 组装微官网公开地址。slug 优先,否则使用 id。
+ */
+export function buildCardPublicUrl(
+ slugOrId: string | number,
+ env: NodeJS.ProcessEnv = process.env
+): string {
+ const key = String(slugOrId ?? '').trim();
+ if (!key) {
+ throw new Error('缺少名片标识');
+ }
+ return `${resolveCardPublicBase(env)}/${encodeURIComponent(key)}`;
+}
+
+/**
+ * 将公开短链编码为 SVG 字符串
+ */
+export async function generateQrSvg(content: string): Promise {
+ if (!content) {
+ throw new Error('缺少二维码内容');
+ }
+ return QRCode.toString(content, {
+ type: 'svg',
+ margin: 1,
+ errorCorrectionLevel: 'M',
+ });
+}
+
+/**
+ * 将公开短链编码为 PNG dataURL
+ */
+export async function generateQrPngDataUrl(content: string): Promise {
+ if (!content) {
+ throw new Error('缺少二维码内容');
+ }
+ return QRCode.toDataURL(content, {
+ type: 'image/png',
+ margin: 1,
+ errorCorrectionLevel: 'M',
+ });
+}
diff --git a/backend/src/comm/types/omni.ts b/backend/src/comm/types/omni.ts
index 3beb2e2..d33a7ad 100644
--- a/backend/src/comm/types/omni.ts
+++ b/backend/src/comm/types/omni.ts
@@ -27,6 +27,8 @@ export interface CardProfile {
userId: string | number;
/** 姓名 */
name: string;
+ /** 个性化短链标识 (Slug) */
+ slug?: string;
/** 职业头衔/职位 */
title: string;
/** 所属企业/组织名称 */
@@ -65,6 +67,10 @@ export interface CardProfile {
city?: string;
/** 国家 */
country?: string;
+ /** 短链重定向类型 (none: 无, card: 接替人名片, portal: 企业公共微官网) */
+ redirectType?: HandoverRedirectType | string | null;
+ /** 短链重定向目标 (接替人名片 ID/Slug 或 企业公共微官网 URL) */
+ redirectTarget?: string | null;
}
/**
@@ -158,6 +164,48 @@ export interface LeadEntity {
source: LeadSource;
/** 跟进备忘录 / 需求备注 */
notes?: string;
+ /** 所属销售负责人 ID (为 null 或空表示公海池) */
+ userId?: number | null;
+ /** 是否处于公海池 (true: 公海, false: 私海) */
+ isPool?: boolean;
+ /** 最后跟进时间 */
+ lastFollowUpTime?: Date | string | null;
+}
+
+/**
+ * 线索公海流转动作类型
+ */
+export type LeadPoolAction = 'recycle' | 'auto_recycle' | 'claim' | 'assign';
+
+/**
+ * 线索公海流转审计日志契约
+ */
+export interface LeadPoolAudit {
+ id?: number | string;
+ leadId: number | string;
+ action: LeadPoolAction;
+ operatorId?: number | null;
+ fromUserId?: number | null;
+ toUserId?: number | null;
+ reason?: string;
+ createTime?: Date | string;
+}
+
+/**
+ * 销售认领抢单 DTO
+ */
+export interface LeadClaimDTO {
+ leadId: number;
+ userId?: number;
+}
+
+/**
+ * 管理员派发分配 DTO
+ */
+export interface LeadAssignDTO {
+ leadId: number;
+ targetUserId: number;
+ assignerId?: number;
}
/**
@@ -307,14 +355,86 @@ export interface HandoverRequest {
toUserId: string | number;
}
+export type HandoverRedirectType = 'none' | 'card' | 'portal';
+export type HandoverLeadAction = 'none' | 'transfer' | 'pool';
+export type HandoverStatus = 'pending' | 'processing' | 'completed' | 'failed';
+
+export interface ExecuteHandoverDTO {
+ fromUserId: number | string;
+ toUserId?: number | string | null;
+ cardId?: number | string | null;
+ redirectType?: HandoverRedirectType;
+ redirectTarget?: string | null;
+ leadAction?: HandoverLeadAction;
+ operatorId?: number | string | null;
+ remark?: string | null;
+}
+
+export interface TeamHandoverLog {
+ id?: number | string;
+ tenantId?: number | string;
+ handoverNo: string;
+ fromUserId: number;
+ toUserId?: number | null;
+ cardId?: number | null;
+ redirectType: HandoverRedirectType;
+ redirectTarget?: string | null;
+ leadAction: HandoverLeadAction;
+ migratedLeadCount: number;
+ operatorId?: number | null;
+ status: HandoverStatus;
+ remark?: string | null;
+ snapshot?: string | null;
+ createTime?: Date | string;
+}
+
export type DomainSslStatus = 'pending' | 'active' | 'failed';
export interface TenantDomain {
id?: string | number;
customDomain: string;
sslStatus: DomainSslStatus;
+ cnameTarget?: string | null;
+ sslExpireTime?: string | null;
}
+export interface ValidateDomainResult {
+ valid: boolean;
+ reason?: string;
+}
+
+export interface DnsVerifyResult {
+ verified: boolean;
+ records: string[];
+ targetCname: string;
+ message?: string;
+}
+
+export interface SslCertMockInfo {
+ issuer?: string;
+ validFrom?: string | Date;
+ validTo?: string | Date;
+ authorized?: boolean;
+}
+
+export interface SslCheckResult {
+ valid: boolean;
+ status: DomainSslStatus;
+ issuer?: string;
+ validFrom?: string;
+ validTo?: string;
+ daysRemaining?: number;
+ expired?: boolean;
+ errorMessage?: string;
+}
+
+export interface BindDomainDTO {
+ domain: string;
+ tenantId?: number;
+ cnameTarget?: string;
+}
+
+
export interface BillingQuota {
planType: TenantPlanType;
seatsUsed: number;
@@ -436,12 +556,92 @@ export interface AiKnowledgeDoc {
locale?: string;
}
+/**
+ * AI 顾问预设人设类型
+ */
+export type ConciergePersonaType =
+ | 'trade_expert'
+ | 'business_rep'
+ | 'tech_advisor'
+ | 'custom';
+
+/**
+ * AI 顾问语气风格
+ */
+export type ConciergeToneStyle =
+ | 'professional'
+ | 'direct'
+ | 'enthusiastic'
+ | 'friendly';
+
+/**
+ * AI 顾问人设与意向留资策略配置契约
+ */
+export interface AiConciergeConfig {
+ id?: string | number;
+ cardId: string | number;
+ personaType: ConciergePersonaType;
+ welcomeMsg: string;
+ triggerLeadThreshold: number;
+ promptTemplate: string;
+ toneStyle: ConciergeToneStyle;
+ isEnabled: boolean;
+}
+
+/**
+ * RAG 知识库切片块契约
+ */
+export interface KnowledgeChunk {
+ id?: string | number;
+ index: number;
+ content: string;
+ length: number;
+ score?: number;
+}
+
+/**
+ * 知识库沙盒调试请求契约
+ */
+export interface KnowledgeSandboxRequest {
+ query: string;
+ content?: string;
+ cardId?: string | number;
+ id?: string | number;
+ chunkSize?: number;
+ overlap?: number;
+ topK?: number;
+ threshold?: number;
+ chunks?: KnowledgeChunk[];
+}
+
+/**
+ * 知识库沙盒调试响应契约
+ */
+export interface KnowledgeSandboxResult {
+ totalChunks: number;
+ chunks: KnowledgeChunk[];
+ recallList: KnowledgeChunk[];
+}
+
export type LocalizeTone = 'professional' | 'direct' | 'storytelling';
export interface LocalizeRequest {
text: string;
targetLocale: string;
tone?: LocalizeTone;
+ domain?: string;
+}
+
+export interface AiGlossaryItem {
+ id?: string | number;
+ sourceTerm: string;
+ targetTerm: string;
+ sourceLang?: string;
+ targetLang: string;
+ domain?: string;
+ notes?: string;
+ createTime?: Date | string;
+ updateTime?: Date | string;
}
export interface LocalizeReply {
@@ -476,6 +676,18 @@ export interface ArticleOgMeta {
image: string;
}
+/**
+ * 名片微官网 OpenGraph 社交元数据契约
+ */
+export interface CardOgMeta {
+ title: string;
+ description: string;
+ image: string;
+ url?: string;
+ type?: string;
+ siteName?: string;
+}
+
/**
* 博客文章与动态契约
*/
@@ -511,3 +723,338 @@ export interface CardCase {
updateTime?: string | Date;
}
+/**
+ * 安全审计日志数据契约
+ */
+export interface BaseAuditLog {
+ id?: number | string;
+ module: string;
+ action: string;
+ operatorId?: number | null;
+ operatorName?: string | null;
+ ip?: string | null;
+ detail?: string | null;
+ status: number;
+ tenantId?: number | null;
+ createTime?: Date | string;
+ updateTime?: Date | string;
+}
+
+/**
+ * 部门树形节点数据契约
+ */
+export interface DepartmentTreeNode {
+ id: number;
+ name: string;
+ parentId?: number | null;
+ orderNum?: number;
+ parentName?: string;
+ memberCount?: number;
+ key?: number | string;
+ title?: string;
+ children?: DepartmentTreeNode[];
+}
+
+/**
+ * 团队成员与分配名片契约
+ */
+export interface TeamMemberItem {
+ id: number;
+ name: string;
+ username: string;
+ phone?: string;
+ email?: string;
+ departmentId?: number | null;
+ departmentName?: string;
+ status: number;
+ card?: {
+ id: number;
+ name: string;
+ title?: string;
+ company?: string;
+ slug?: string;
+ status?: string;
+ } | null;
+}
+
+/**
+ * 成员名片分配请求契约
+ */
+export interface AssignCardRequest {
+ memberId: number;
+ cardId: number;
+}
+
+/**
+ * 批量调整部门请求契约
+ */
+export interface MoveMembersRequest {
+ memberIds: number[];
+ departmentId: number;
+}
+
+// ==========================================
+// 8. 国际财税发票与收据契约 (Invoice & Billing)
+// ==========================================
+
+/**
+ * 国际主流结算币种
+ */
+export type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'CNY';
+
+/**
+ * 发票状态
+ */
+export type InvoiceStatus = 'paid' | 'issued' | 'refunded' | 'void';
+
+/**
+ * 买方开票账单信息
+ */
+export interface InvoiceBillingInfo {
+ companyName: string;
+ taxId?: string;
+ address?: string;
+ email?: string;
+ phone?: string;
+}
+
+/**
+ * 发票项目明细
+ */
+export interface InvoiceItem {
+ description: string;
+ quantity: number;
+ unitPrice: number;
+ amount: number;
+}
+
+/**
+ * 创建/生成发票 DTO
+ */
+export interface CreateInvoiceDTO {
+ orderId?: string;
+ currency?: SupportedCurrency | string;
+ title: string;
+ subtotal?: number;
+ totalAmount?: number;
+ taxRate?: number;
+ isTaxInclusive?: boolean;
+ taxAmount?: number;
+ billingInfo?: InvoiceBillingInfo;
+ items?: InvoiceItem[];
+ status?: InvoiceStatus;
+ paidAt?: Date | string;
+}
+
+/**
+ * 税费折算计算结果
+ */
+export interface TaxCalculationResult {
+ subtotal: number;
+ taxRate: number;
+ taxAmount: number;
+ totalAmount: number;
+}
+
+/**
+ * 发票渲染参数
+ */
+export interface InvoiceRenderParams {
+ invoiceNo: string;
+ issueDate: string;
+ currency: SupportedCurrency;
+ currencySymbol: string;
+ subtotal: number;
+ taxRate: number;
+ taxAmount: number;
+ totalAmount: number;
+ billingInfo?: InvoiceBillingInfo;
+ items?: InvoiceItem[];
+}
+
+// ==========================================
+// 9. Stripe 国际信用卡订阅支付与 Webhook 契约 (Stripe Billing)
+// ==========================================
+
+export type StripeBillingCycle = 'monthly' | 'yearly';
+
+export interface StripeCheckoutParams {
+ tenantId: number | string;
+ plan: TenantPlanType;
+ billingCycle?: StripeBillingCycle;
+ successUrl?: string;
+ cancelUrl?: string;
+}
+
+export interface StripeCheckoutResult {
+ sessionId: string;
+ url: string;
+ tenantId: number | string;
+ plan: TenantPlanType;
+ billingCycle: StripeBillingCycle;
+ amount: number;
+ currency: string;
+}
+
+export interface StripeWebhookEventData> {
+ object: T;
+}
+
+export interface StripeWebhookEvent> {
+ id: string;
+ type: string;
+ created: number;
+ data: StripeWebhookEventData;
+}
+
+export interface StripeWebhookHandleResult {
+ success: boolean;
+ eventType: string;
+ tenantId?: number | string;
+ plan?: TenantPlanType;
+ status?: string;
+ action: 'upgraded' | 'renewed' | 'downgraded' | 'ignored';
+ ignored?: boolean;
+ message?: string;
+}
+
+// ==========================================
+// 10. 企业配额用量监控与升级差价折算契约 (Team Quota & Proration)
+// ==========================================
+
+/**
+ * 配额资源类型 (名片、坐席、NFC设备,兼容单复数)
+ */
+export type QuotaResourceType =
+ | 'cards'
+ | 'seats'
+ | 'nfcDevices'
+ | 'card'
+ | 'seat'
+ | 'nfc';
+
+/**
+ * 各套餐版本的配额上限规格
+ */
+export interface PlanLimits {
+ plan: TenantPlanType;
+ cardsLimit: number;
+ seatsLimit: number;
+ nfcDevicesLimit: number;
+}
+
+/**
+ * 配额检查与用量预警结果
+ */
+export interface QuotaCheckResult {
+ tenantId: number | string;
+ resourceType: QuotaResourceType;
+ plan: TenantPlanType;
+ used: number;
+ limit: number;
+ remaining: number;
+ percentage: number;
+ warning: boolean;
+ exceeded: boolean;
+}
+
+/**
+ * 配额检查查询参数
+ */
+export interface QuotaCheckQuery {
+ tenantId?: number | string;
+ resourceType: QuotaResourceType;
+ plan?: TenantPlanType;
+ currentUsed?: number;
+}
+
+/**
+ * 套餐升级补差价折算结果
+ */
+export interface ProrationResult {
+ currentPlan: TenantPlanType;
+ targetPlan: TenantPlanType;
+ currentPlanPrice: number;
+ targetPlanPrice: number;
+ remainingDays: number;
+ totalDays: number;
+ proratedCurrentCredit: number;
+ proratedTargetCost: number;
+ upgradeDiffAmount: number;
+}
+
+/**
+ * 套餐升级补差价计算参数
+ */
+export interface CalculateProrationDTO {
+ currentPlan: TenantPlanType;
+ targetPlan: TenantPlanType;
+ remainingDays: number;
+ totalDays: number;
+ currentPlanPrice?: number;
+ targetPlanPrice?: number;
+ currentPrice?: number;
+ targetPrice?: number;
+}
+
+// ==========================================
+// 11. 展会现场团队获客实时排行榜大屏广播契约 (Team Live Rank & Broadcast)
+// ==========================================
+
+/**
+ * 团队成员实时排行榜条目
+ */
+export interface TeamMemberRankItem {
+ /** 成员用户 ID */
+ memberId: number;
+ /** 成员姓名 */
+ memberName: string;
+ /** 头像 URL */
+ avatarUrl?: string;
+ /** 所属部门 ID */
+ departmentId?: number;
+ /** 所属部门名称 */
+ departmentName?: string;
+ /** 关联名片 ID */
+ cardId?: number;
+ /** 线索获取数 (Leads) */
+ leads: number;
+ /** 名片触碰量 (Touches) */
+ touches: number;
+ /** 加权综合得分 (每线索 10 分,每触碰 1 分) */
+ score: number;
+ /** 排名序号 (1-indexed,支持同分并列) */
+ rank: number;
+ /** 是否与他人同分并列 */
+ isTied?: boolean;
+}
+
+/**
+ * 实时排行榜广播元数据
+ */
+export interface TeamRankBroadcastMeta {
+ tenantId?: string;
+ eventId?: string;
+ topScore?: number;
+ totalLeads?: number;
+ totalTouches?: number;
+}
+
+/**
+ * 结构化 WebSocket 广播事件载荷
+ */
+export interface TeamRankBroadcastPayload {
+ /** 事件类型标识 (默认为 'team.live_rank') */
+ event: string;
+ /** 毫秒级时间戳 */
+ timestamp: number;
+ /** 排行榜成员总数 */
+ total: number;
+ /** 排行榜列表 */
+ ranks: TeamMemberRankItem[];
+ /** 排行榜列表别名 (兼容标准 WS 载荷 data 字段) */
+ data: TeamMemberRankItem[];
+ /** 聚合元数据统计 */
+ meta?: TeamRankBroadcastMeta;
+}
+
diff --git a/backend/src/config/config.default.ts b/backend/src/config/config.default.ts
index d47ebf4..b38fdbf 100644
--- a/backend/src/config/config.default.ts
+++ b/backend/src/config/config.default.ts
@@ -10,9 +10,9 @@ import { availablePort } from '../comm/port';
export default {
// 确保每个项目唯一,项目首次启动会自动生成
- keys: 'cool-admin-keys-xxxxxx',
+ keys: 'deada6f9-03b7-4ba1-8af3-faf09fe2eefc',
koa: {
- port: availablePort(8001),
+ port: availablePort(9000),
},
// 开启异步上下文管理
asyncContextManager: {
diff --git a/backend/src/entities.ts b/backend/src/entities.ts
index 5b2a4bd..1f2f1e5 100644
--- a/backend/src/entities.ts
+++ b/backend/src/entities.ts
@@ -1,2 +1,87 @@
// 自动生成的文件,请勿手动修改
-export const entities = [];
+import * as entity0 from './modules/user/entity/wx';
+import * as entity1 from './modules/user/entity/info';
+import * as entity2 from './modules/user/entity/address';
+import * as entity3 from './modules/team/entity/subscription';
+import * as entity4 from './modules/team/entity/invoice';
+import * as entity5 from './modules/team/entity/handover-log';
+import * as entity6 from './modules/team/entity/domain';
+import * as entity7 from './modules/team/entity/brand';
+import * as entity8 from './modules/task/entity/log';
+import * as entity9 from './modules/task/entity/info';
+import * as entity10 from './modules/space/entity/type';
+import * as entity11 from './modules/space/entity/info';
+import * as entity12 from './modules/recycle/entity/data';
+import * as entity13 from './modules/plugin/entity/info';
+import * as entity14 from './modules/nfc/entity/rule';
+import * as entity15 from './modules/nfc/entity/device';
+import * as entity16 from './modules/dict/entity/type';
+import * as entity17 from './modules/dict/entity/info';
+import * as entity18 from './modules/demo/entity/goods';
+import * as entity19 from './modules/crm/entity/track';
+import * as entity20 from './modules/crm/entity/lead';
+import * as entity21 from './modules/crm/entity/audit';
+import * as entity22 from './modules/card/entity/locale';
+import * as entity23 from './modules/card/entity/info';
+import * as entity24 from './modules/card/entity/cases';
+import * as entity25 from './modules/card/entity/bento';
+import * as entity26 from './modules/card/entity/article';
+import * as entity27 from './modules/base/entity/base';
+import * as entity28 from './modules/base/entity/audit';
+import * as entity29 from './modules/base/entity/sys/user_role';
+import * as entity30 from './modules/base/entity/sys/user';
+import * as entity31 from './modules/base/entity/sys/role_menu';
+import * as entity32 from './modules/base/entity/sys/role_department';
+import * as entity33 from './modules/base/entity/sys/role';
+import * as entity34 from './modules/base/entity/sys/param';
+import * as entity35 from './modules/base/entity/sys/menu';
+import * as entity36 from './modules/base/entity/sys/log';
+import * as entity37 from './modules/base/entity/sys/department';
+import * as entity38 from './modules/base/entity/sys/conf';
+import * as entity39 from './modules/ai/entity/knowledge';
+import * as entity40 from './modules/ai/entity/glossary';
+import * as entity41 from './modules/ai/entity/concierge';
+export const entities = [
+ ...Object.values(entity0),
+ ...Object.values(entity1),
+ ...Object.values(entity2),
+ ...Object.values(entity3),
+ ...Object.values(entity4),
+ ...Object.values(entity5),
+ ...Object.values(entity6),
+ ...Object.values(entity7),
+ ...Object.values(entity8),
+ ...Object.values(entity9),
+ ...Object.values(entity10),
+ ...Object.values(entity11),
+ ...Object.values(entity12),
+ ...Object.values(entity13),
+ ...Object.values(entity14),
+ ...Object.values(entity15),
+ ...Object.values(entity16),
+ ...Object.values(entity17),
+ ...Object.values(entity18),
+ ...Object.values(entity19),
+ ...Object.values(entity20),
+ ...Object.values(entity21),
+ ...Object.values(entity22),
+ ...Object.values(entity23),
+ ...Object.values(entity24),
+ ...Object.values(entity25),
+ ...Object.values(entity26),
+ ...Object.values(entity27),
+ ...Object.values(entity28),
+ ...Object.values(entity29),
+ ...Object.values(entity30),
+ ...Object.values(entity31),
+ ...Object.values(entity32),
+ ...Object.values(entity33),
+ ...Object.values(entity34),
+ ...Object.values(entity35),
+ ...Object.values(entity36),
+ ...Object.values(entity37),
+ ...Object.values(entity38),
+ ...Object.values(entity39),
+ ...Object.values(entity40),
+ ...Object.values(entity41),
+];
diff --git a/backend/src/modules/ai/config.ts b/backend/src/modules/ai/config.ts
new file mode 100644
index 0000000..d295a7c
--- /dev/null
+++ b/backend/src/modules/ai/config.ts
@@ -0,0 +1,14 @@
+import { ModuleConfig } from '@cool-midway/core';
+
+/**
+ * AI 顾问与出海润色模块配置
+ */
+export default () => {
+ return {
+ name: 'AI 智能中枢模块',
+ description: '名片内置顾问对话、意向识别与出海文案润色',
+ middlewares: [],
+ globalMiddlewares: [],
+ order: 40,
+ } as ModuleConfig;
+};
diff --git a/backend/src/modules/ai/controller/admin/concierge.ts b/backend/src/modules/ai/controller/admin/concierge.ts
new file mode 100644
index 0000000..a236828
--- /dev/null
+++ b/backend/src/modules/ai/controller/admin/concierge.ts
@@ -0,0 +1,26 @@
+import { CoolController, BaseController, CurdOption } from '@cool-midway/core';
+import { AiConciergeEntity } from '../../entity/concierge';
+import { AiConciergeService } from '../../service/concierge';
+
+/**
+ * 后台 AI 顾问人设与意向留资策略 CRUD 配置
+ */
+export const adminAiConciergeCurdOption: CurdOption = {
+ api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
+ 'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
+ >,
+ entity: AiConciergeEntity,
+ service: AiConciergeService,
+ serviceApis: ['getDefaultPersona', 'getByCardId', 'saveByCardId'],
+ pageQueryOp: {
+ fieldEq: ['a.cardId', 'a.personaType', 'a.isEnabled'],
+ addOrderBy: { updateTime: 'desc' as const },
+ },
+};
+
+/**
+ * AI 模块 - 后台名片 AI 顾问人设与留资配置
+ * 路由前缀:/admin/ai/concierge
+ */
+@CoolController(adminAiConciergeCurdOption)
+export class AdminAiConciergeController extends BaseController {}
diff --git a/backend/src/modules/ai/controller/admin/copilot.ts b/backend/src/modules/ai/controller/admin/copilot.ts
new file mode 100644
index 0000000..a1be77b
--- /dev/null
+++ b/backend/src/modules/ai/controller/admin/copilot.ts
@@ -0,0 +1,20 @@
+import { CoolController, BaseController } from '@cool-midway/core';
+import { AiCopilotService } from '../../service/copilot';
+
+/**
+ * 后台销售跟进话术配置(不开标准 CRUD)
+ */
+export const adminAiCopilotCurdOption = {
+ api: [] as Array<
+ 'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
+ >,
+ service: AiCopilotService,
+ serviceApis: [{ method: 'generate', summary: '销售跟进话术生成' }],
+};
+
+/**
+ * AI 模块-后台销售跟进话术
+ * 路由前缀:/admin/ai/copilot
+ */
+@CoolController(adminAiCopilotCurdOption)
+export class AdminAiCopilotController extends BaseController {}
diff --git a/backend/src/modules/ai/controller/admin/glossary.ts b/backend/src/modules/ai/controller/admin/glossary.ts
new file mode 100644
index 0000000..c57168a
--- /dev/null
+++ b/backend/src/modules/ai/controller/admin/glossary.ts
@@ -0,0 +1,27 @@
+import { CoolController, BaseController } from '@cool-midway/core';
+import { AiGlossaryEntity } from '../../entity/glossary';
+import { AiGlossaryService } from '../../service/glossary';
+
+/**
+ * 后台行业术语库标准 CRUD 与批量导入配置
+ */
+export const adminAiGlossaryCurdOption = {
+ api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
+ 'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
+ >,
+ serviceApis: ['batchImport'] as Array<'batchImport'>,
+ entity: AiGlossaryEntity,
+ service: AiGlossaryService,
+ pageQueryOp: {
+ keyWordLikeFields: ['a.sourceTerm', 'a.targetTerm', 'a.notes'],
+ fieldEq: ['a.sourceLang', 'a.targetLang', 'a.domain'],
+ addOrderBy: { updateTime: 'desc' as const },
+ },
+};
+
+/**
+ * AI 模块-后台行业术语库
+ * 路由前缀:/admin/ai/glossary
+ */
+@CoolController(adminAiGlossaryCurdOption)
+export class AdminAiGlossaryController extends BaseController {}
diff --git a/backend/src/modules/ai/controller/admin/knowledge.ts b/backend/src/modules/ai/controller/admin/knowledge.ts
new file mode 100644
index 0000000..399d50a
--- /dev/null
+++ b/backend/src/modules/ai/controller/admin/knowledge.ts
@@ -0,0 +1,43 @@
+import { Body, Inject, Post, Provide } from '@midwayjs/core';
+import { CoolController, BaseController } from '@cool-midway/core';
+import { AiKnowledgeEntity } from '../../entity/knowledge';
+import { AiKnowledgeService } from '../../service/knowledge';
+import { KnowledgeSandboxRequest } from '../../../../comm/types/omni';
+
+/**
+ * 后台知识库标准 CRUD 配置
+ */
+export const adminAiKnowledgeCurdOption = {
+ api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
+ 'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
+ >,
+ entity: AiKnowledgeEntity,
+ service: AiKnowledgeService,
+ serviceApis: [{ method: 'sandboxTest', summary: '知识库切片与召回沙盒测试' }],
+ pageQueryOp: {
+ keyWordLikeFields: ['a.title', 'a.content'],
+ fieldEq: ['a.cardId', 'a.locale'],
+ addOrderBy: { updateTime: 'desc' as const },
+ },
+};
+
+/**
+ * AI 模块-后台知识库
+ * 路由前缀:/admin/ai/knowledge
+ */
+@Provide()
+@CoolController(adminAiKnowledgeCurdOption)
+export class AdminAiKnowledgeController extends BaseController {
+ @Inject()
+ aiKnowledgeService: AiKnowledgeService;
+
+ /**
+ * 知识库分块切片与召回沙盒测试接口
+ * 接口: POST /admin/ai/knowledge/sandbox-test
+ */
+ @Post('/sandbox-test', { summary: '知识库分块切片与召回沙盒测试' })
+ async sandboxTest(@Body() body: KnowledgeSandboxRequest) {
+ const result = await this.aiKnowledgeService.sandboxTest(body || ({} as KnowledgeSandboxRequest));
+ return this.ok(result);
+ }
+}
diff --git a/backend/src/modules/ai/controller/admin/localize.ts b/backend/src/modules/ai/controller/admin/localize.ts
new file mode 100644
index 0000000..6de33bd
--- /dev/null
+++ b/backend/src/modules/ai/controller/admin/localize.ts
@@ -0,0 +1,20 @@
+import { CoolController, BaseController } from '@cool-midway/core';
+import { AiLocalizeService } from '../../service/localize';
+
+/**
+ * 后台出海润色配置(不开标准 CRUD)
+ */
+export const adminAiLocalizeCurdOption = {
+ api: [] as Array<
+ 'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
+ >,
+ service: AiLocalizeService,
+ serviceApis: [{ method: 'localize', summary: 'AI 出海润色' }],
+};
+
+/**
+ * AI 模块-后台出海润色
+ * 路由前缀:/admin/ai/localize
+ */
+@CoolController(adminAiLocalizeCurdOption)
+export class AdminAiLocalizeController extends BaseController {}
diff --git a/backend/src/modules/ai/controller/open/chat.ts b/backend/src/modules/ai/controller/open/chat.ts
new file mode 100644
index 0000000..2ba3771
--- /dev/null
+++ b/backend/src/modules/ai/controller/open/chat.ts
@@ -0,0 +1,20 @@
+import { CoolController, BaseController } from '@cool-midway/core';
+import { AiChatService } from '../../service/chat';
+
+/**
+ * 公开名片 AI 顾问对话配置(不开标准 CRUD)
+ */
+export const openAiChatCurdOption = {
+ api: [] as Array<
+ 'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
+ >,
+ service: AiChatService,
+ serviceApis: [{ method: 'ask', summary: '名片 AI 顾问对话' }],
+};
+
+/**
+ * AI 模块-公开顾问对话
+ * 路由前缀:/open/ai/chat
+ */
+@CoolController(openAiChatCurdOption)
+export class OpenAiChatController extends BaseController {}
diff --git a/backend/src/modules/ai/entity/concierge.ts b/backend/src/modules/ai/entity/concierge.ts
new file mode 100644
index 0000000..8209327
--- /dev/null
+++ b/backend/src/modules/ai/entity/concierge.ts
@@ -0,0 +1,68 @@
+import { BaseEntity } from '../../../comm/entity/base';
+import { Column, Entity, Index } from 'typeorm';
+import {
+ AiConciergeConfig,
+ ConciergePersonaType,
+ ConciergeToneStyle,
+} from '../../../comm/types/omni';
+
+/**
+ * 名片 AI 顾问人设与意向留资策略实体
+ * 表名: ai_concierge
+ */
+@Entity('ai_concierge')
+export class AiConciergeEntity extends BaseEntity implements AiConciergeConfig {
+ @Index()
+ @Column({
+ name: 'card_id',
+ comment: '所属名片 ID',
+ type: 'bigint',
+ })
+ cardId: number;
+
+ @Column({
+ name: 'persona_type',
+ comment: '顾问人设预设类型 (trade_expert, business_rep, tech_advisor, custom)',
+ length: 50,
+ default: 'trade_expert',
+ })
+ personaType: ConciergePersonaType;
+
+ @Column({
+ name: 'welcome_msg',
+ comment: '首条问候欢迎语与引导词',
+ type: 'text',
+ })
+ welcomeMsg: string;
+
+ @Column({
+ name: 'trigger_lead_threshold',
+ comment: '意向留资触发阈值 (0-100)',
+ type: 'int',
+ default: 70,
+ })
+ triggerLeadThreshold: number;
+
+ @Column({
+ name: 'prompt_template',
+ comment: 'AI 顾问系统指令与 Prompt 模板',
+ type: 'text',
+ })
+ promptTemplate: string;
+
+ @Column({
+ name: 'tone_style',
+ comment: '回复语气风格 (professional, direct, enthusiastic, friendly)',
+ length: 50,
+ default: 'professional',
+ })
+ toneStyle: ConciergeToneStyle;
+
+ @Column({
+ name: 'is_enabled',
+ comment: '是否启用 AI 顾问',
+ type: 'boolean',
+ default: true,
+ })
+ isEnabled: boolean;
+}
diff --git a/backend/src/modules/ai/entity/glossary.ts b/backend/src/modules/ai/entity/glossary.ts
new file mode 100644
index 0000000..3175f02
--- /dev/null
+++ b/backend/src/modules/ai/entity/glossary.ts
@@ -0,0 +1,59 @@
+import { BaseEntity } from '../../../comm/entity/base';
+import { Column, Entity, Index } from 'typeorm';
+import { AiGlossaryItem } from '../../../comm/types/omni';
+
+/**
+ * 行业术语库 (Glossary)
+ * 表名: ai_glossary
+ */
+@Entity('ai_glossary')
+export class AiGlossaryEntity extends BaseEntity implements AiGlossaryItem {
+ @Index()
+ @Column({
+ name: 'source_term',
+ comment: '源语言术语/词条',
+ length: 150,
+ })
+ sourceTerm: string;
+
+ @Column({
+ name: 'target_term',
+ comment: '目标语言标准译名',
+ length: 150,
+ })
+ targetTerm: string;
+
+ @Index()
+ @Column({
+ name: 'source_lang',
+ comment: '源语言代码 (如 en, zh 等,空表示全局适用)',
+ length: 32,
+ nullable: true,
+ })
+ sourceLang?: string;
+
+ @Index()
+ @Column({
+ name: 'target_lang',
+ comment: '目标语言代码 (如 zh-CN, ar-SA, en-US)',
+ length: 32,
+ })
+ targetLang: string;
+
+ @Index()
+ @Column({
+ name: 'domain',
+ comment: '所属行业/专业领域 (如 tech, crm, legal, medical, general)',
+ length: 64,
+ nullable: true,
+ })
+ domain?: string;
+
+ @Column({
+ name: 'notes',
+ comment: '术语定义、使用语境或说明备注',
+ length: 255,
+ nullable: true,
+ })
+ notes?: string;
+}
diff --git a/backend/src/modules/ai/entity/knowledge.ts b/backend/src/modules/ai/entity/knowledge.ts
new file mode 100644
index 0000000..ea871bd
--- /dev/null
+++ b/backend/src/modules/ai/entity/knowledge.ts
@@ -0,0 +1,41 @@
+import { BaseEntity } from '../../../comm/entity/base';
+import { Column, Entity, Index } from 'typeorm';
+import { AiKnowledgeDoc } from '../../../comm/types/omni';
+
+/**
+ * 名片 RAG 知识库文档
+ * 表名: ai_knowledge
+ */
+@Entity('ai_knowledge')
+export class AiKnowledgeEntity extends BaseEntity implements AiKnowledgeDoc {
+ @Index()
+ @Column({
+ name: 'card_id',
+ comment: '所属名片 ID',
+ type: 'bigint',
+ })
+ cardId: number;
+
+ @Column({
+ name: 'title',
+ comment: '文档标题',
+ length: 150,
+ })
+ title: string;
+
+ @Column({
+ name: 'content',
+ comment: '文档正文 / FAQ / 规格',
+ type: 'text',
+ })
+ content: string;
+
+ @Index()
+ @Column({
+ name: 'locale',
+ comment: '语言标签',
+ length: 16,
+ nullable: true,
+ })
+ locale?: string;
+}
diff --git a/backend/src/modules/ai/service/chat.ts b/backend/src/modules/ai/service/chat.ts
new file mode 100644
index 0000000..5f40181
--- /dev/null
+++ b/backend/src/modules/ai/service/chat.ts
@@ -0,0 +1,70 @@
+import { Provide } from '@midwayjs/core';
+import { BaseService, CoolCommException } from '@cool-midway/core';
+import {
+ AiChatMessage,
+ AiChatReply,
+ AiChatRequest,
+} from '../../../comm/types/omni';
+
+/** locale 缺省值 */
+const DEFAULT_LOCALE = 'en-US';
+
+/** 价格 / 合作 / 联系:触发引导留资 */
+const LEAD_INTENT_PATTERN =
+ /价格|报价|多少钱|合作|联系|price|pricing|cost|quote|cooperat|partner|contact/i;
+
+/**
+ * 名片内置 AI 顾问(本地 RuleBased,不调用外部 LLM)
+ */
+@Provide()
+export class AiChatService extends BaseService {
+ /**
+ * 顾问对话
+ * 兼容公开端 serviceApis:POST body 为单参数对象 AiChatRequest
+ */
+ async ask(req: AiChatRequest): Promise {
+ const cardId = req?.cardId;
+ if (cardId === undefined || cardId === null || String(cardId).trim() === '') {
+ throw new CoolCommException('缺少名片标识 cardId');
+ }
+
+ const messages = req.messages;
+ if (!Array.isArray(messages) || messages.length === 0) {
+ throw new CoolCommException('对话消息不能为空');
+ }
+
+ const last = messages[messages.length - 1];
+ const content = this.lastUserContent(last);
+ if (!content) {
+ throw new CoolCommException('最后一条用户消息内容不能为空');
+ }
+
+ const locale = req.locale?.trim() || DEFAULT_LOCALE;
+ const shouldCollectLead = LEAD_INTENT_PATTERN.test(content);
+ const isZh = locale.toLowerCase().startsWith('zh');
+
+ return {
+ reply: this.buildReply(shouldCollectLead, isZh),
+ locale,
+ shouldCollectLead,
+ };
+ }
+
+ private lastUserContent(last?: AiChatMessage): string {
+ if (!last || last.role !== 'user') {
+ return '';
+ }
+ return (last.content || '').trim();
+ }
+
+ private buildReply(shouldCollectLead: boolean, isZh: boolean): string {
+ if (shouldCollectLead) {
+ return isZh
+ ? '方便留下您的姓名与联系方式吗?我们会尽快回复报价与合作细节。'
+ : 'Please leave your name and contact details so we can follow up on pricing and partnership.';
+ }
+ return isZh
+ ? '我是这张名片的智能顾问,可以为您介绍业务与服务。需要的话可以留下联系方式。'
+ : 'I am the digital concierge for this card and can introduce the business. Would you like to leave your contact details?';
+ }
+}
diff --git a/backend/src/modules/ai/service/concierge.ts b/backend/src/modules/ai/service/concierge.ts
new file mode 100644
index 0000000..ba5a60c
--- /dev/null
+++ b/backend/src/modules/ai/service/concierge.ts
@@ -0,0 +1,147 @@
+import { Init, Provide } from '@midwayjs/core';
+import { BaseService, CoolCommException } from '@cool-midway/core';
+import { InjectEntityModel } from '@midwayjs/typeorm';
+import { Repository } from 'typeorm';
+import { AiConciergeEntity } from '../entity/concierge';
+import {
+ AiConciergeConfig,
+ ConciergePersonaType,
+ ConciergeToneStyle,
+} from '../../../comm/types/omni';
+
+/**
+ * 预设人设配置集合
+ */
+const PERSONA_PRESETS: Record<
+ string,
+ Omit
+> = {
+ trade_expert: {
+ personaType: 'trade_expert',
+ welcomeMsg:
+ '您好!我是您的专属出海业务顾问。请问有什么全球贸易或合作需求可以协助您?',
+ promptTemplate:
+ '你是一名深耕国际贸易与跨国商务的出海业务专家。用专业、沉稳的商务语调为访客解答产品出海、跨境结算、供应链合作等咨询。引导有明确意向的客商提供联系方式。',
+ toneStyle: 'professional',
+ triggerLeadThreshold: 70,
+ isEnabled: true,
+ },
+ business_rep: {
+ personaType: 'business_rep',
+ welcomeMsg: '欢迎光临!我是商务联络代表,很高兴为您服务。',
+ promptTemplate:
+ '你是一名高效热情的商务拓展代表。重点介绍公司业务优势与成功案例,积极促成商业联络,敏锐捕捉商机。',
+ toneStyle: 'enthusiastic',
+ triggerLeadThreshold: 60,
+ isEnabled: true,
+ },
+ tech_advisor: {
+ personaType: 'tech_advisor',
+ welcomeMsg:
+ '您好,我是技术顾问,随时解答系统对接与技术架构问题。',
+ promptTemplate:
+ '你是一名资深解决方案架构师与技术顾问。精确、客观、条理清晰地回答技术规格、API 集成与实施流程问题。',
+ toneStyle: 'direct',
+ triggerLeadThreshold: 80,
+ isEnabled: true,
+ },
+};
+
+/**
+ * 名片 AI 顾问人设与意向留资策略服务
+ */
+@Provide()
+export class AiConciergeService extends BaseService {
+ @InjectEntityModel(AiConciergeEntity)
+ aiConciergeEntity: Repository;
+
+ @Init()
+ async init() {
+ await super.init();
+ this.setEntity(this.aiConciergeEntity);
+ }
+
+ /**
+ * 获取预设人设配置模版
+ */
+ getDefaultPersona(
+ personaType: ConciergePersonaType | string = 'trade_expert'
+ ): Omit {
+ const preset = PERSONA_PRESETS[personaType] || PERSONA_PRESETS.trade_expert;
+ return { ...preset };
+ }
+
+ /**
+ * 智能校验人设与策略入参
+ */
+ validate(data: Partial) {
+ if (
+ data.cardId === undefined ||
+ data.cardId === null ||
+ String(data.cardId).trim() === ''
+ ) {
+ throw new CoolCommException('缺少名片标识 cardId');
+ }
+
+ if (
+ data.welcomeMsg !== undefined &&
+ data.welcomeMsg.trim() === ''
+ ) {
+ throw new CoolCommException('欢迎语不能为空');
+ }
+
+ if (data.triggerLeadThreshold !== undefined) {
+ const threshold = Number(data.triggerLeadThreshold);
+ if (isNaN(threshold) || threshold < 0 || threshold > 100) {
+ throw new CoolCommException('留资触发阈值必须在 0 到 100 之间');
+ }
+ }
+ }
+
+ /**
+ * 按名片 ID 查询顾问人设与留资配置 (不存在时返回默认预设)
+ */
+ async getByCardId(cardId: number | string): Promise {
+ if (cardId === undefined || cardId === null || String(cardId).trim() === '') {
+ throw new CoolCommException('缺少名片标识 cardId');
+ }
+ const numCardId = Number(cardId);
+ const found = await this.aiConciergeEntity.findOneBy({ cardId: numCardId });
+ if (found) {
+ return found;
+ }
+
+ const defaultPreset = this.getDefaultPersona('trade_expert');
+ return {
+ cardId: numCardId,
+ ...defaultPreset,
+ };
+ }
+
+ /**
+ * 按名片 ID 保存或更新配置
+ */
+ async saveByCardId(
+ cardId: number | string,
+ data: Partial