From e5c79082d33592f87aa3ebd048a4956a7514766d Mon Sep 17 00:00:00 2001 From: wikiboss Date: Mon, 21 Sep 2026 09:22:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin-workplace):=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8F=B0=E4=B8=9A=E5=8A=A1=E6=A6=82=E8=A7=88=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E5=AF=B9=E6=8E=A5=E4=B8=8E=E5=85=A8=E7=AB=AF=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E9=9B=86=E6=88=90=20(=E6=9B=B4=E6=96=B0=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BB=93=E9=85=8D=E7=BD=AE=E4=B8=8E=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E5=85=A8=E7=BB=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 1 + admin/src/components/NavBar/index.tsx | 12 +- admin/src/main.tsx | 42 +- admin/src/mock/index.ts | 26 ++ admin/src/pages/dashboard/workplace/index.tsx | 394 ++++++++++++++++-- .../pages/dashboard/workplace/locale/index.ts | 153 +++---- .../workplace/style/index.module.less | 143 ++++++- admin/src/pages/login/form.tsx | 174 +++++++- admin/src/pages/login/locale/index.ts | 12 +- admin/src/pages/login/style/index.module.less | 38 ++ admin/src/types/omni.ts | 42 ++ admin/src/utils/auth-token.ts | 23 + admin/src/utils/checkLogin.tsx | 4 +- admin/src/utils/setupRequest.ts | 25 ++ admin/tests/admin-auth.test.ts | 55 +++ admin/tests/dashboard-workplace.test.ts | 83 ++++ backend/src/comm/types/omni.ts | 42 ++ .../src/modules/crm/controller/admin/radar.ts | 21 + backend/src/modules/crm/service/radar.ts | 150 ++++++- backend/test/modules/crm/workplace.test.ts | 155 +++++++ 20 files changed, 1452 insertions(+), 143 deletions(-) create mode 100644 admin/src/utils/auth-token.ts create mode 100644 admin/src/utils/setupRequest.ts create mode 100644 admin/tests/admin-auth.test.ts create mode 100644 admin/tests/dashboard-workplace.test.ts create mode 100644 backend/test/modules/crm/workplace.test.ts diff --git a/AGENTS.md b/AGENTS.md index 56cdf90..d51502f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,6 +7,7 @@ ## 1. 项目基本概况 (Project Overview) - **项目名称**:OmniCard AI (全球智能数字名片 & 移动微官网 SaaS) +- **远程仓库**:`https://gitcode.gynhc.com/wikiboss/OmniCard.git` - **业务定位**:面向全球商务出海的下一代 **AI 驱动型智能名片 + 移动微官网 + 访客雷达 CRM + NFC 实体卡管理** 一体化平台。 - **业务核心模块**: 1. 微官网搭建与展示系统(Bento Grid 便当盒美学、响应式与 RTL 多语言适配) diff --git a/admin/src/components/NavBar/index.tsx b/admin/src/components/NavBar/index.tsx index 9971e24..3735124 100644 --- a/admin/src/components/NavBar/index.tsx +++ b/admin/src/components/NavBar/index.tsx @@ -36,6 +36,8 @@ import styles from './style/index.module.less'; import defaultLocale from '@/locale'; import useStorage from '@/utils/useStorage'; import { generatePermission } from '@/routes'; +import { clearAdminTokens } from '@/utils/auth-token'; +import axios from 'axios'; function Navbar({ show }: { show: boolean }) { const t = useLocale(); @@ -48,8 +50,14 @@ function Navbar({ show }: { show: boolean }) { const { setLang, lang, theme, setTheme } = useContext(GlobalContext); function logout() { - setUserStatus('logout'); - window.location.href = '/login'; + axios + .post('/api/admin/base/comm/logout') + .catch(() => undefined) + .finally(() => { + clearAdminTokens(); + setUserStatus('logout'); + window.location.href = '/login'; + }); } function onMenuItemClick(key) { diff --git a/admin/src/main.tsx b/admin/src/main.tsx index 1d2bbd0..c09e19f 100644 --- a/admin/src/main.tsx +++ b/admin/src/main.tsx @@ -15,6 +15,7 @@ import Login from './pages/login'; import checkLogin from './utils/checkLogin'; import changeTheme from './utils/changeTheme'; import useStorage from './utils/useStorage'; +import './utils/setupRequest'; import './mock'; const store = createStore(rootReducer); @@ -40,10 +41,43 @@ function Index() { payload: { userLoading: true }, }); axios.get('/api/user/userInfo').then((res) => { - store.dispatch({ - type: 'update-userInfo', - payload: { userInfo: res.data, userLoading: false }, - }); + const mockUser = res.data; + axios + .get<{ + code: number; + data?: { + name?: string; + nickName?: string; + username?: string; + email?: string; + headImg?: string; + }; + }>('/api/admin/base/comm/person') + .then((personRes) => { + const person = personRes.data?.data; + store.dispatch({ + type: 'update-userInfo', + payload: { + userLoading: false, + userInfo: { + ...mockUser, + name: + person?.name || + person?.nickName || + person?.username || + mockUser.name, + email: person?.email || mockUser.email, + avatar: person?.headImg || mockUser.avatar, + }, + }, + }); + }) + .catch(() => { + store.dispatch({ + type: 'update-userInfo', + payload: { userInfo: mockUser, userLoading: false }, + }); + }); }); } diff --git a/admin/src/mock/index.ts b/admin/src/mock/index.ts index d385b74..14c4e3a 100644 --- a/admin/src/mock/index.ts +++ b/admin/src/mock/index.ts @@ -4,7 +4,33 @@ import { isSSR } from '@/utils/is'; import './user'; import './message-box'; +interface MockXhrLike { + send: (...args: unknown[]) => unknown; + custom?: { + xhr?: { + setRequestHeader: (name: string, value: string) => void; + }; + requestHeaders?: Record; + }; +} + if (!isSSR) { + const xhrProto = Mock.XHR.prototype as MockXhrLike; + const nativeSend = xhrProto.send; + xhrProto.send = function patchedSend( + this: MockXhrLike, + ...args: unknown[] + ) { + const headers = this.custom?.requestHeaders; + const nativeXhr = this.custom?.xhr; + if (headers && nativeXhr) { + Object.keys(headers).forEach((key) => { + nativeXhr.setRequestHeader(key, headers[key]); + }); + } + return nativeSend.apply(this, args); + }; + Mock.setup({ timeout: '500-1500', }); diff --git a/admin/src/pages/dashboard/workplace/index.tsx b/admin/src/pages/dashboard/workplace/index.tsx index e06f23e..6c0b66f 100644 --- a/admin/src/pages/dashboard/workplace/index.tsx +++ b/admin/src/pages/dashboard/workplace/index.tsx @@ -1,39 +1,379 @@ -import React from 'react'; -import { Grid, Space } from '@arco-design/web-react'; -import Overview from './overview'; -import PopularContents from './popular-contents'; -import ContentPercentage from './content-percentage'; -import Shortcuts from './shortcuts'; -import Announcement from './announcement'; -import Carousel from './carousel'; -import Docs from './docs'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import axios from 'axios'; +import { + Button, + Card, + Empty, + Grid, + Message, + Spin, + Statistic, + Tag, + Timeline, + Typography, +} from '@arco-design/web-react'; +import { IconPlus } from '@arco-design/web-react/icon'; +import useLocale from '@/utils/useLocale'; +import { getCssVariables, lightColors } from '@/style/tokens'; +import type { GlobalState } from '@/store'; +import type { + LeadEntity, + TouchType, + WorkplaceKpi, + WorkplaceOverviewResult, + WorkplaceRadarItem, +} from '@/types/omni'; +import locale from './locale'; import styles from './style/index.module.less'; -import './mock'; const { Row, Col } = Grid; +const OVERVIEW_API = '/api/admin/crm/radar/workplace'; +const LEAD_PAGE_API = '/api/admin/crm/lead/page'; +const EMPTY_KPI: WorkplaceKpi = { + tapCount: 0, + viewCount: 0, + newLeadCount: 0, + conversionRate: 0, + tapDelta: 0, + viewDelta: 0, + newLeadDelta: 0, +}; -const gutter = 16; +interface CoolResponse { + code: number; + message?: string; + data?: T; +} + +interface CoolPageData { + list?: LeadEntity[]; +} + +function isTouchType(value: unknown): value is TouchType { + return value === 'nfc' || value === 'qr' || value === 'link'; +} + +function formatDelta(value: number): string { + if (value > 0) { + return `+${value}`; + } + return String(value); +} + +function formatStaySeconds(ms: number): string { + return String(Math.max(0, Math.round(ms / 1000))); +} + +function escapeCsvCell(value: string): string { + if (value.includes(',') || value.includes('"') || value.includes('\n')) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} + +function downloadCsv(fileName: string, rows: string[][]): void { + const body = rows.map((row) => row.map(escapeCsvCell).join(',')).join('\r\n'); + const blob = new Blob([`\uFEFF${body}\r\n`], { + type: 'text/csv;charset=utf-8;', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.click(); + URL.revokeObjectURL(url); +} + +function resolveErrorMessage(error: unknown, fallback: string): string { + if (axios.isAxiosError(error)) { + const payload = error.response?.data; + if (payload && typeof payload === 'object' && 'message' in payload) { + const message = Reflect.get(payload, 'message'); + if (typeof message === 'string' && message) { + return message; + } + } + if (error.message) { + return error.message; + } + } + if (error instanceof Error && error.message) { + return error.message; + } + return fallback; +} + +function Workplace(): React.ReactElement { + const t = useLocale(locale); + const history = useHistory(); + const userInfo = useSelector((state: GlobalState) => state.userInfo); + const [loading, setLoading] = useState(true); + const [exporting, setExporting] = useState(false); + const [overview, setOverview] = useState({ + kpis: EMPTY_KPI, + radarFeed: [], + }); + + const tokenStyle = useMemo( + () => getCssVariables('light') as React.CSSProperties, + [] + ); + + const welcome = useMemo(() => { + const name = userInfo?.name?.trim(); + if (!name) { + return t['workplace.welcomeGuest']; + } + return t['workplace.welcome'].replace('{name}', name); + }, [t, userInfo?.name]); + + const fetchOverview = useCallback(async () => { + setLoading(true); + try { + const { data: payload } = await axios.post>( + OVERVIEW_API, + { feedLimit: 12 } + ); + if (payload.data?.kpis) { + setOverview({ + kpis: payload.data.kpis, + radarFeed: payload.data.radarFeed || [], + }); + } + } catch (error: unknown) { + Message.error(resolveErrorMessage(error, t['workplace.request.fail'])); + } finally { + setLoading(false); + } + }, [t]); + + useEffect(() => { + fetchOverview(); + }, [fetchOverview]); + + const handleCreateCard = () => { + history.push('/card/list'); + }; + + const handleIssueCards = () => { + history.push('/nfc/batch'); + }; + + const handleOpenRadar = () => { + history.push('/dashboard/radar-map'); + }; + + const handleExportLeads = async () => { + setExporting(true); + try { + const { data: payload } = await axios.post>( + LEAD_PAGE_API, + { page: 1, size: 500 } + ); + const list = payload.data?.list || []; + if (!list.length) { + Message.info(t['workplace.export.empty']); + return; + } + const rows = [ + ['name', 'company', 'email', 'phone', 'status', 'source'], + ...list.map((item) => [ + item.name || '', + item.company || '', + item.email || '', + item.phone || '', + item.status || '', + item.source || '', + ]), + ]; + downloadCsv(`omnicard-leads-${new Date().toISOString().slice(0, 10)}.csv`, rows); + Message.success(t['workplace.export.success']); + } catch (error: unknown) { + Message.error(resolveErrorMessage(error, t['workplace.export.fail'])); + } finally { + setExporting(false); + } + }; + + const getChannelLabel = (type: TouchType): string => { + if (type === 'nfc') { + return t['workplace.channel.nfc']; + } + if (type === 'qr') { + return t['workplace.channel.qr']; + } + return t['workplace.channel.link']; + }; + + const getChannelColor = (type: TouchType): string => { + if (type === 'nfc') { + return lightColors.primary; + } + if (type === 'qr') { + return lightColors.ai; + } + return lightColors.text2; + }; + + const renderDelta = (value: number): React.ReactElement => { + const tone = + value > 0 ? styles.deltaUp : value < 0 ? styles.deltaDown : styles.delta; + return ( +
+ {t['workplace.kpi.vsYesterday'].replace('{delta}', formatDelta(value))} +
+ ); + }; + + const kpis = overview.kpis; -function Workplace() { return ( -
- - - - - +
+
+
+ + {t['workplace.title']} + + + {t['workplace.subtitle']} + + {welcome} +
+ +
+ + + + + + + {renderDelta(kpis.tapDelta)} + - - + + + + {renderDelta(kpis.viewDelta)} + + + + + + {renderDelta(kpis.newLeadDelta)} + + + + + + - - - - - - - + + + + + {overview.radarFeed.length === 0 ? ( +
+ + {t['workplace.radar.empty']} +

+ } + /> + +
+ ) : ( + + {overview.radarFeed.map((item: WorkplaceRadarItem) => { + const touchType = isTouchType(item.touchType) + ? item.touchType + : 'link'; + const locationLabel = [item.city, item.country] + .filter(Boolean) + .join(', '); + const blocks = (item.viewedBlocks || []).join(' / '); + return ( + +
+ {locationLabel || '—'} + + {getChannelLabel(touchType)} + + + {t['workplace.radar.stay'].replace( + '{seconds}', + formatStaySeconds(item.stayDurationMs) + )} + +
+ {blocks ? ( +
+ {t['workplace.radar.viewing'].replace('{blocks}', blocks)} +
+ ) : null} +
+ ); + })} +
+ )} +
+ + + + + + + +
+
); } diff --git a/admin/src/pages/dashboard/workplace/locale/index.ts b/admin/src/pages/dashboard/workplace/locale/index.ts index be8bf4a..ac805f8 100644 --- a/admin/src/pages/dashboard/workplace/locale/index.ts +++ b/admin/src/pages/dashboard/workplace/locale/index.ts @@ -1,77 +1,90 @@ const i18n = { 'en-US': { - 'workplace.welcomeBack': 'Welcome Back,', - 'workplace.totalOnlyData': 'Total online data', - 'workplace.contentInMarket': 'Content in market', - 'workplace.comments': 'Comments', - 'workplace.growth': 'Growth', - 'workplace.contentData': 'Content Data', - 'workplace.1year': 'Nearly 1 Year', - 'workplace.seeMore': 'See More', - 'workplace.popularContents': 'Popular Contents', - 'workplace.text': 'Text', - 'workplace.image': 'Image', - 'workplace.video': 'Video', - 'workplace.column.rank': 'Rank', - 'workplace.column.title': 'Title', - 'workplace.column.pv': 'PV', - 'workplace.column.increase': 'Daily Increase', - 'workplace.contentPercentage': 'Percentage of content categories', - 'workplace.shortcuts': 'Shortcuts', - 'workplace.manage': 'Manage', - 'workplace.contentMgmt': 'Management', - 'workplace.contentStatistic': 'Statistic', - 'workplace.advancedMgmt': 'Advance', - 'workplace.onlinePromotion': 'Promotion', - 'workplace.marketing': 'Marketing', - 'workplace.recent': 'Recent', - 'workplace.announcement': 'Announcement', - 'workplace.activity': 'Activity', - 'workplace.info': 'Info', - 'workplace.notice': 'Notice', - 'workplace.docs': 'Document', - 'workplace.pecs': 'pecs', - 'workplace.designLab': 'DesignLab', - 'workplace.materialMarket': 'MaterialMarket', - 'workplace.react': 'React Quick Start', - 'workplace.vue': 'Vue Quick Start', + 'workplace.title': 'Business overview', + 'workplace.subtitle': 'Today’s NFC taps, card views, new leads, and close rate', + 'workplace.welcome': 'Welcome back, {name}', + 'workplace.welcomeGuest': 'Welcome back', + 'workplace.cta.newCard': 'New card', + 'workplace.kpi.taps': 'NFC taps', + 'workplace.kpi.views': 'Card views', + 'workplace.kpi.leads': 'New leads', + 'workplace.kpi.conversion': 'Close rate', + 'workplace.kpi.vsYesterday': 'vs yesterday {delta}', + 'workplace.radar.title': 'Live visitor radar', + 'workplace.radar.empty': 'No one has opened a card today.', + 'workplace.radar.emptyAction': 'Open global radar', + 'workplace.radar.stay': '{seconds}s stay', + 'workplace.radar.viewing': 'Viewing {blocks}', + 'workplace.shortcut.title': 'Shortcuts', + 'workplace.shortcut.issueCard': 'Issue NFC cards', + 'workplace.shortcut.issueCardHint': 'Generate short links and print files', + 'workplace.shortcut.exportLeads': 'Export leads', + 'workplace.shortcut.exportLeadsHint': 'Download today’s CRM list as CSV', + 'workplace.channel.nfc': 'NFC', + 'workplace.channel.qr': 'QR', + 'workplace.channel.link': 'Link', + 'workplace.export.success': 'Lead list exported', + 'workplace.export.empty': 'No leads to export yet', + 'workplace.export.fail': 'Unable to export leads', + 'workplace.request.fail': 'Unable to load the workspace', }, 'zh-CN': { - 'workplace.welcomeBack': '欢迎回来,', - 'workplace.totalOnlyData': '线上总数据', - 'workplace.contentInMarket': '投放中的内容', - 'workplace.comments': '日新增评论', - 'workplace.growth': '较昨日新增', - 'workplace.contentData': '内容数据', - 'workplace.1year': '近1年', - 'workplace.seeMore': '查看更多', - 'workplace.popularContents': '线上热门内容', - 'workplace.text': '文本', - 'workplace.image': '图文', - 'workplace.video': '视频', - 'workplace.column.rank': '排名', - 'workplace.column.title': '内容标题', - 'workplace.column.pv': '点击量', - 'workplace.column.increase': '日涨幅', - 'workplace.contentPercentage': '内容类别占比', - 'workplace.shortcuts': '快捷入口', - 'workplace.manage': '管理', - 'workplace.contentMgmt': '内容管理', - 'workplace.contentStatistic': '内容数据', - 'workplace.advancedMgmt': '高级管理', - 'workplace.onlinePromotion': '线上推广', - 'workplace.marketing': '内容投放', - 'workplace.recent': '最近访问', - 'workplace.announcement': '公告', - 'workplace.activity': '活动', - 'workplace.info': '消息', - 'workplace.notice': '通知', - 'workplace.docs': '文档中心', - 'workplace.pecs': '个', - 'workplace.designLab': '风格配置平台', - 'workplace.materialMarket': '物料市场', - 'workplace.react': 'React 组件库', - 'workplace.vue': 'Vue 组件库', + 'workplace.title': '商务概览', + 'workplace.subtitle': '今日触碰、浏览、新线索与成交转化', + 'workplace.welcome': '欢迎回来,{name}', + 'workplace.welcomeGuest': '欢迎回来', + 'workplace.cta.newCard': '新建名片', + 'workplace.kpi.taps': '触碰量', + 'workplace.kpi.views': '浏览量', + 'workplace.kpi.leads': '新线索', + 'workplace.kpi.conversion': '成交转化', + 'workplace.kpi.vsYesterday': '较昨日 {delta}', + 'workplace.radar.title': '今日实时雷达', + 'workplace.radar.empty': '今天还没有访客打开名片。', + 'workplace.radar.emptyAction': '查看全球雷达', + 'workplace.radar.stay': '停留 {seconds} 秒', + 'workplace.radar.viewing': '正在查看 {blocks}', + 'workplace.shortcut.title': '快捷入口', + 'workplace.shortcut.issueCard': '批量发卡', + 'workplace.shortcut.issueCardHint': '生成短链与打印物料', + 'workplace.shortcut.exportLeads': '导出线索', + 'workplace.shortcut.exportLeadsHint': '下载潜客列表为 CSV', + 'workplace.channel.nfc': 'NFC', + 'workplace.channel.qr': '二维码', + 'workplace.channel.link': '链接', + 'workplace.export.success': '线索已导出', + 'workplace.export.empty': '暂无可导出的线索', + 'workplace.export.fail': '线索导出失败', + 'workplace.request.fail': '工作台加载失败', + }, + 'ar-SA': { + 'workplace.title': 'نظرة العمل', + 'workplace.subtitle': 'لمسات NFC ومشاهدات البطاقة والعملاء الجدد ومعدل الإغلاق اليوم', + 'workplace.welcome': 'مرحباً بعودتك، {name}', + 'workplace.welcomeGuest': 'مرحباً بعودتك', + 'workplace.cta.newCard': 'بطاقة جديدة', + 'workplace.kpi.taps': 'لمسات NFC', + 'workplace.kpi.views': 'مشاهدات البطاقة', + 'workplace.kpi.leads': 'عملاء جدد', + 'workplace.kpi.conversion': 'معدل الإغلاق', + 'workplace.kpi.vsYesterday': 'مقارنة بالأمس {delta}', + 'workplace.radar.title': 'رادار الزوار المباشر', + 'workplace.radar.empty': 'لم يفتح أحد بطاقة اليوم.', + 'workplace.radar.emptyAction': 'فتح الرادار العالمي', + 'workplace.radar.stay': 'مكث {seconds} ث', + 'workplace.radar.viewing': 'يشاهد {blocks}', + 'workplace.shortcut.title': 'اختصارات', + 'workplace.shortcut.issueCard': 'إصدار بطاقات NFC', + 'workplace.shortcut.issueCardHint': 'إنشاء الروابط وملفات الطباعة', + 'workplace.shortcut.exportLeads': 'تصدير العملاء', + 'workplace.shortcut.exportLeadsHint': 'تنزيل قائمة CRM بتنسيق CSV', + 'workplace.channel.nfc': 'NFC', + 'workplace.channel.qr': 'رمز QR', + 'workplace.channel.link': 'رابط', + 'workplace.export.success': 'تم تصدير قائمة العملاء', + 'workplace.export.empty': 'لا توجد عملاء للتصدير', + 'workplace.export.fail': 'تعذر تصدير العملاء', + 'workplace.request.fail': 'تعذر تحميل مساحة العمل', }, }; diff --git a/admin/src/pages/dashboard/workplace/style/index.module.less b/admin/src/pages/dashboard/workplace/style/index.module.less index a57ac6f..228efd2 100644 --- a/admin/src/pages/dashboard/workplace/style/index.module.less +++ b/admin/src/pages/dashboard/workplace/style/index.module.less @@ -1,24 +1,141 @@ -.banner { - background-color: var(--color-bg-2); - padding: 20px; +.page { + min-height: 100%; + max-width: 1200px; + padding: var(--omni-space-24); + background: var(--omni-color-surface); + font-family: var(--omni-font-family); } -.wrapper { +.header { display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--omni-space-16); + flex-wrap: wrap; + margin-bottom: var(--omni-space-24); +} + +.title { + margin: 0; + color: var(--omni-color-text); +} + +.subtitle { + display: block; + margin-top: var(--omni-space-4); + color: var(--omni-color-text-2); + font-size: 14px; + line-height: 1.6; +} + +.welcome { + display: block; + margin-top: var(--omni-space-8); + color: var(--omni-color-text); + font-size: 16px; + line-height: 1.6; +} + +.primaryCta { + height: 40px; + border-radius: var(--omni-radius-button); + background-color: var(--omni-color-primary); +} + +:global(.arco-card).kpiCard, +:global(.arco-card).panel { + border: 1px solid var(--omni-color-border); + border-radius: var(--omni-radius-card); + background: var(--omni-color-bg); + box-shadow: var(--omni-shadow); +} + +.main { + margin-top: var(--omni-space-24); +} + +.delta { + margin-top: var(--omni-space-8); + font-size: 12px; + line-height: 1.6; + color: var(--omni-color-text-2); +} + +.deltaUp { + color: var(--omni-color-success); +} + +.deltaDown { + color: var(--omni-color-error); +} + +.feedMeta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--omni-space-8); +} + +.feedExtra { + margin-top: var(--omni-space-4); + color: var(--omni-color-text-2); + font-size: 12px; + line-height: 1.6; +} + +.empty { + padding: var(--omni-space-24) 0; + text-align: center; +} + +.emptyText { + margin: 0 0 var(--omni-space-12); + color: var(--omni-color-text-2); + font-size: 14px; + line-height: 1.6; +} + +.shortcut { + display: flex; + flex-direction: column; + align-items: flex-start; width: 100%; + min-height: 44px; + padding: var(--omni-space-12) 0; + border: 0; + border-bottom: 1px solid var(--omni-color-border); + background: transparent; + text-align: start; + cursor: pointer; } -.left { - width: calc(100% - 296px); - margin-right: 16px; +.shortcut:last-child { + border-bottom: 0; } -.right { - width: 280px; +.shortcut:hover { + filter: brightness(0.98); } -.panel { - background-color: var(--color-bg-2); - border-radius: 4px; - overflow: auto; +.shortcut:focus-visible { + outline: 2px solid var(--omni-color-primary); + outline-offset: 2px; +} + +.shortcutLabel { + color: var(--omni-color-text); + font-size: 14px; + line-height: 1.6; +} + +.shortcutHint { + color: var(--omni-color-text-2); + font-size: 12px; + line-height: 1.6; +} + +@media (prefers-reduced-motion: reduce) { + .shortcut:hover { + filter: none; + } } diff --git a/admin/src/pages/login/form.tsx b/admin/src/pages/login/form.tsx index be3e0e1..bc7afb7 100644 --- a/admin/src/pages/login/form.tsx +++ b/admin/src/pages/login/form.tsx @@ -7,18 +7,57 @@ import { Space, } from '@arco-design/web-react'; import { FormInstance } from '@arco-design/web-react/es/Form'; -import { IconLock, IconUser } from '@arco-design/web-react/icon'; -import React, { useEffect, useRef, useState } from 'react'; +import { IconLock, IconUser, IconSafe } from '@arco-design/web-react/icon'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import axios from 'axios'; import useStorage from '@/utils/useStorage'; import useLocale from '@/utils/useLocale'; +import { setAdminTokens } from '@/utils/auth-token'; +import { lightColors } from '@/style/tokens'; import locale from './locale'; import styles from './style/index.module.less'; +const LOGIN_API = '/api/admin/base/open/login'; +const CAPTCHA_API = '/api/admin/base/open/captcha'; +const COOL_OK = 1000; + +interface LoginFormValues { + userName: string; + password: string; + verifyCode: string; +} + +interface RememberedLogin { + userName?: string; + password?: string; +} + +interface CoolResponse { + code: number; + message?: string; + data?: T; +} + +interface CoolLoginData { + token: string; + refreshToken?: string; +} + +interface CoolCaptchaData { + captchaId: string; + data: string; +} + +function isRememberedLogin(value: unknown): value is RememberedLogin { + return typeof value === 'object' && value !== null; +} + export default function LoginForm() { - const formRef = useRef(); + const formRef = useRef(null); const [errorMessage, setErrorMessage] = useState(''); const [loading, setLoading] = useState(false); + const [captchaId, setCaptchaId] = useState(''); + const [captchaImg, setCaptchaImg] = useState(''); const [loginParams, setLoginParams, removeLoginParams] = useStorage('loginParams'); @@ -26,31 +65,76 @@ export default function LoginForm() { const [rememberPassword, setRememberPassword] = useState(!!loginParams); - function afterLoginSuccess(params) { - // 记住密码 + const refreshCaptcha = useCallback(async () => { + try { + const { data } = await axios.get>( + CAPTCHA_API, + { params: { width: 150, height: 44, color: lightColors.text } } + ); + if (data.code === COOL_OK && data.data?.captchaId && data.data.data) { + setCaptchaId(data.data.captchaId); + setCaptchaImg(data.data.data); + return; + } + setErrorMessage(data.message || t['login.form.captcha.loadFail']); + } catch (error: unknown) { + const message = + axios.isAxiosError(error) && + error.response?.data && + typeof error.response.data === 'object' && + 'message' in error.response.data && + typeof Reflect.get(error.response.data, 'message') === 'string' + ? String(Reflect.get(error.response.data, 'message')) + : t['login.form.captcha.loadFail']; + setErrorMessage(message); + } + }, [t]); + + function afterLoginSuccess(params: LoginFormValues) { if (rememberPassword) { - setLoginParams(JSON.stringify(params)); + setLoginParams( + JSON.stringify({ + userName: params.userName, + password: params.password, + }) + ); } else { removeLoginParams(); } - // 记录登录状态 localStorage.setItem('userStatus', 'login'); - // 跳转首页 window.location.href = '/'; } - function login(params) { + function login(params: LoginFormValues) { setErrorMessage(''); setLoading(true); axios - .post('/api/user/login', params) + .post>(LOGIN_API, { + username: params.userName, + password: params.password, + captchaId, + verifyCode: params.verifyCode, + }) .then((res) => { - const { status, msg } = res.data; - if (status === 'ok') { + if (res.data.code === COOL_OK && res.data.data?.token) { + setAdminTokens(res.data.data.token, res.data.data.refreshToken); afterLoginSuccess(params); - } else { - setErrorMessage(msg || t['login.form.login.errMsg']); + return; } + setErrorMessage(res.data.message || t['login.form.login.errMsg']); + refreshCaptcha(); + }) + .catch((error: unknown) => { + const message = + axios.isAxiosError(error) && + error.response?.data && + typeof error.response.data === 'object' && + 'message' in error.response.data && + typeof Reflect.get(error.response.data, 'message') === 'string' + ? String(Reflect.get(error.response.data, 'message')) + : t['login.form.login.errMsg']; + setErrorMessage(message); + refreshCaptcha(); }) .finally(() => { setLoading(false); @@ -58,20 +142,31 @@ export default function LoginForm() { } function onSubmitClick() { - formRef.current.validate().then((values) => { + formRef.current?.validate().then((values: LoginFormValues) => { login(values); }); } - // 读取 localStorage,设置初始值 useEffect(() => { - const rememberPassword = !!loginParams; - setRememberPassword(rememberPassword); - if (formRef.current && rememberPassword) { - const parseParams = JSON.parse(loginParams); - formRef.current.setFieldsValue(parseParams); + refreshCaptcha(); + }, [refreshCaptcha]); + + useEffect(() => { + setRememberPassword(!!loginParams); + if (formRef.current && loginParams) { + try { + const parsed: unknown = JSON.parse(loginParams); + if (isRememberedLogin(parsed)) { + formRef.current.setFieldsValue({ + userName: parsed.userName, + password: parsed.password, + }); + } + } catch { + removeLoginParams(); + } } - }, [loginParams]); + }, [loginParams, removeLoginParams]); return (
@@ -84,7 +179,7 @@ export default function LoginForm() { className={styles['login-form']} layout="vertical" ref={formRef} - initialValues={{ userName: 'admin', password: 'admin' }} + initialValues={{ userName: 'admin', password: '123456' }} > +
+ + } + placeholder={t['login.form.captcha.placeholder']} + onPressEnter={onSubmitClick} + /> + + +
diff --git a/admin/src/pages/login/locale/index.ts b/admin/src/pages/login/locale/index.ts index 418e034..d3de87b 100644 --- a/admin/src/pages/login/locale/index.ts +++ b/admin/src/pages/login/locale/index.ts @@ -5,7 +5,11 @@ const i18n = { 'login.form.password.errMsg': 'Password cannot be empty', 'login.form.login.errMsg': 'Login error, please refresh and try again', 'login.form.userName.placeholder': 'Username: admin', - 'login.form.password.placeholder': 'Password: admin', + 'login.form.password.placeholder': 'Password: 123456', + 'login.form.captcha.placeholder': 'Captcha', + 'login.form.captcha.errMsg': 'Captcha cannot be empty', + 'login.form.captcha.alt': 'Refresh captcha', + 'login.form.captcha.loadFail': 'Unable to load captcha', 'login.form.rememberPassword': 'Remember password', 'login.form.forgetPassword': 'Forgot password', 'login.form.login': 'Sign in', @@ -25,7 +29,11 @@ const i18n = { 'login.form.password.errMsg': '密码不能为空', 'login.form.login.errMsg': '登录出错,请刷新重试', 'login.form.userName.placeholder': '用户名:admin', - 'login.form.password.placeholder': '密码:admin', + 'login.form.password.placeholder': '密码:123456', + 'login.form.captcha.placeholder': '验证码', + 'login.form.captcha.errMsg': '验证码不能为空', + 'login.form.captcha.alt': '点击刷新验证码', + 'login.form.captcha.loadFail': '验证码加载失败', 'login.form.rememberPassword': '记住密码', 'login.form.forgetPassword': '忘记密码', 'login.form.login': '登录', diff --git a/admin/src/pages/login/style/index.module.less b/admin/src/pages/login/style/index.module.less index 6176224..db228f7 100644 --- a/admin/src/pages/login/style/index.module.less +++ b/admin/src/pages/login/style/index.module.less @@ -117,4 +117,42 @@ &-register-btn { color: var(--color-text-3) !important; } + + &-captcha { + display: flex; + align-items: flex-start; + gap: 12px; + } + + &-captcha-item { + flex: 1; + margin-bottom: 20px; + } + + &-captcha-refresh { + width: 120px; + height: 44px; + padding: 0; + border: 1px solid var(--color-border-2); + border-radius: 12px; + background: var(--color-fill-1); + cursor: pointer; + overflow: hidden; + } + + &-captcha-image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + &-captcha-placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + font-size: 12px; + color: var(--color-text-3); + } } diff --git a/admin/src/types/omni.ts b/admin/src/types/omni.ts index 1e50be6..d56c275 100644 --- a/admin/src/types/omni.ts +++ b/admin/src/types/omni.ts @@ -195,6 +195,48 @@ export interface RadarTrack { createdAt: string | Date; } +/** + * 商务概览工作台 KPI + */ +export interface WorkplaceKpi { + /** 今日 NFC 触碰量 */ + tapCount: number; + /** 今日名片浏览量 */ + viewCount: number; + /** 今日新线索 */ + newLeadCount: number; + /** 成交转化率 (赢单 / 未归档线索, 0-100) */ + conversionRate: number; + /** 触碰量较昨日差值 */ + tapDelta: number; + /** 浏览量较昨日差值 */ + viewDelta: number; + /** 新线索较昨日差值 */ + newLeadDelta: number; +} + +/** + * 工作台雷达动态项 + */ +export interface WorkplaceRadarItem { + id: number | string; + cardId: number | string; + country: string; + city: string; + touchType: TouchType; + stayDurationMs: number; + viewedBlocks: string[]; + createTime: Date | string; +} + +/** + * 商务概览工作台聚合结果 + */ +export interface WorkplaceOverviewResult { + kpis: WorkplaceKpi; + radarFeed: WorkplaceRadarItem[]; +} + // ========================================== // 3. NFC 实体卡设备台账契约 (NFC Fleet) // ========================================== diff --git a/admin/src/utils/auth-token.ts b/admin/src/utils/auth-token.ts new file mode 100644 index 0000000..0b0f740 --- /dev/null +++ b/admin/src/utils/auth-token.ts @@ -0,0 +1,23 @@ +const TOKEN_KEY = 'adminToken'; +const REFRESH_TOKEN_KEY = 'adminRefreshToken'; + +export function getAdminToken(): string { + return localStorage.getItem(TOKEN_KEY) || ''; +} + +export function getAdminRefreshToken(): string { + return localStorage.getItem(REFRESH_TOKEN_KEY) || ''; +} + +export function setAdminTokens(token: string, refreshToken?: string): void { + localStorage.setItem(TOKEN_KEY, token); + if (refreshToken) { + localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken); + } +} + +export function clearAdminTokens(): void { + localStorage.removeItem(TOKEN_KEY); + localStorage.removeItem(REFRESH_TOKEN_KEY); + localStorage.setItem('userStatus', 'logout'); +} diff --git a/admin/src/utils/checkLogin.tsx b/admin/src/utils/checkLogin.tsx index df76c68..26598a4 100644 --- a/admin/src/utils/checkLogin.tsx +++ b/admin/src/utils/checkLogin.tsx @@ -1,3 +1,5 @@ +import { getAdminToken } from './auth-token'; + export default function checkLogin() { - return localStorage.getItem('userStatus') === 'login'; + return localStorage.getItem('userStatus') === 'login' && !!getAdminToken(); } diff --git a/admin/src/utils/setupRequest.ts b/admin/src/utils/setupRequest.ts new file mode 100644 index 0000000..54bd7ee --- /dev/null +++ b/admin/src/utils/setupRequest.ts @@ -0,0 +1,25 @@ +import axios from 'axios'; +import { clearAdminTokens, getAdminToken } from './auth-token'; + +axios.interceptors.request.use((config) => { + const token = getAdminToken(); + if (token) { + config.headers = config.headers || {}; + config.headers.Authorization = token; + } + return config; +}); + +axios.interceptors.response.use( + (response) => response, + (error: unknown) => { + const status = + axios.isAxiosError(error) && error.response ? error.response.status : 0; + const path = window.location.pathname.replace(/\//g, ''); + if (status === 401 && path !== 'login') { + clearAdminTokens(); + window.location.pathname = '/login'; + } + return Promise.reject(error); + } +); diff --git a/admin/tests/admin-auth.test.ts b/admin/tests/admin-auth.test.ts new file mode 100644 index 0000000..dc39e9a --- /dev/null +++ b/admin/tests/admin-auth.test.ts @@ -0,0 +1,55 @@ +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'; + +const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +function read(rel: string): string { + return fs.readFileSync(path.join(adminRoot, rel), 'utf8'); +} + +test('1. 登录必须对接 Cool-Admin 开放接口并携带验证码', () => { + const form = read('src/pages/login/form.tsx'); + assert.ok( + form.includes('/api/admin/base/open/login'), + '登录必须请求 /api/admin/base/open/login' + ); + assert.ok( + form.includes('/api/admin/base/open/captcha'), + '登录必须拉取 /api/admin/base/open/captcha' + ); + assert.ok(form.includes('captchaId'), '登录必须提交 captchaId'); + assert.ok(form.includes('verifyCode'), '登录必须提交 verifyCode'); + assert.ok(!form.includes('/api/user/login'), '禁止再走 Arco Mock 登录'); + assert.ok(!/\bany\b/.test(form), '严禁 any'); +}); + +test('2. axios 必须自动附带 Cool-Admin Authorization', () => { + const request = read('src/utils/setupRequest.ts'); + assert.ok(request.includes('Authorization'), '必须写入 Authorization'); + assert.ok(request.includes('getAdminToken'), '必须读取已保存的 token'); + + const main = read('src/main.tsx'); + assert.ok( + main.includes("./utils/setupRequest"), + '入口必须启用请求拦截器' + ); +}); + +test('3. 登录态必须同时校验 userStatus 与 JWT', () => { + const checkLogin = read('src/utils/checkLogin.tsx'); + assert.ok(checkLogin.includes('getAdminToken'), '未持有 token 不得视为已登录'); + + const tokenUtil = read('src/utils/auth-token.ts'); + assert.ok(tokenUtil.includes('adminToken'), '必须持久化 adminToken'); + assert.ok(tokenUtil.includes('adminRefreshToken'), '必须持久化 refreshToken'); +}); + +test('4. 登录文案必须提示真实超管密码并提供验证码词条', () => { + const locale = read('src/pages/login/locale/index.ts'); + assert.ok(locale.includes('123456'), '占位提示必须是 Cool-Admin 默认密码 123456'); + assert.ok(locale.includes('login.form.captcha.placeholder')); + assert.ok(locale.includes('login.form.captcha.errMsg')); +}); diff --git a/admin/tests/dashboard-workplace.test.ts b/admin/tests/dashboard-workplace.test.ts new file mode 100644 index 0000000..59a3db8 --- /dev/null +++ b/admin/tests/dashboard-workplace.test.ts @@ -0,0 +1,83 @@ +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'; + +const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const pagePath = path.join(adminRoot, 'src/pages/dashboard/workplace/index.tsx'); +const localePath = path.join(adminRoot, 'src/pages/dashboard/workplace/locale/index.ts'); +const routesPath = path.join(adminRoot, 'src/routes.ts'); + +const EMOJI_RE = + /[\u{1F300}-\u{1F5FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u; + +test('1. routes.ts 必须在 dashboard 下注册 workplace 且未被 ignore', () => { + const routesContent = fs.readFileSync(routesPath, 'utf-8'); + assert.ok( + routesContent.includes("key: 'dashboard/workplace'"), + '必须注册 dashboard/workplace' + ); + assert.ok( + !/key:\s*'dashboard\/workplace'[\s\S]{0,80}ignore:\s*true/.test(routesContent), + '工作台必须在侧边栏展示' + ); +}); + +test('2. 工作台必须对接商务概览 API,并使用 PRD 指定的 Arco 组件', () => { + assert.ok(fs.existsSync(pagePath), '必须存在 workplace/index.tsx'); + const content = fs.readFileSync(pagePath, 'utf-8'); + + assert.ok( + content.includes('/api/admin/crm/radar/workplace'), + '必须调用 /api/admin/crm/radar/workplace' + ); + assert.ok(content.includes('@arco-design/web-react'), '必须使用 Arco Design'); + assert.ok(content.includes('Statistic'), '必须使用 Statistic 展示 KPI'); + assert.ok(content.includes('Timeline'), '必须使用 Timeline 展示雷达动态'); + assert.ok(content.includes('Card'), '必须使用 Card'); + assert.ok(content.includes('Grid') || content.includes('Row'), '必须使用 Grid'); + assert.ok(content.includes('Button'), '必须使用 Button'); + assert.ok(!content.includes("from './mock'"), '禁止继续引用 Arco Pro Demo mock'); + assert.ok(!content.includes('PopularContents'), '禁止继续引用内容运营 Demo 组件'); +}); + +test('3. 快捷入口必须覆盖新建名片、发卡、线索导出,且仅 1 个主 CTA', () => { + const content = fs.readFileSync(pagePath, 'utf-8'); + assert.ok(content.includes('/card/list'), '新建名片必须跳转名片资产库'); + assert.ok(content.includes('/nfc/batch'), '发卡必须跳转批量制卡'); + assert.ok( + content.includes('/api/admin/crm/lead/page'), + '线索导出必须请求线索分页接口' + ); + + const primaryButtons = content.match(/type=["']primary["']/g) || []; + assert.equal(primaryButtons.length, 1, '一屏只允许 1 个主 CTA'); +}); + +test('4. 必须引用设计 Token,且零 any、零 emoji', () => { + const content = fs.readFileSync(pagePath, 'utf-8'); + assert.ok( + content.includes('@/style/tokens') || content.includes('tokens'), + '必须引用设计 Token' + ); + assert.ok(!/\bany\b/.test(content), '严禁 any'); + assert.ok(!EMOJI_RE.test(content), '严禁 emoji'); +}); + +test('5. 工作台 i18n 必须覆盖 KPI、雷达动态与快捷入口,且含中英阿', () => { + assert.ok(fs.existsSync(localePath), '必须存在 workplace locale'); + const localeContent = fs.readFileSync(localePath, 'utf-8'); + assert.ok(localeContent.includes("'en-US'")); + assert.ok(localeContent.includes("'zh-CN'")); + assert.ok(localeContent.includes("'ar-SA'")); + assert.ok(localeContent.includes('workplace.kpi.taps')); + assert.ok(localeContent.includes('workplace.kpi.views')); + assert.ok(localeContent.includes('workplace.kpi.leads')); + assert.ok(localeContent.includes('workplace.kpi.conversion')); + assert.ok(localeContent.includes('workplace.radar.title')); + assert.ok(localeContent.includes('workplace.cta.newCard')); + assert.ok(localeContent.includes('workplace.shortcut.issueCard')); + assert.ok(localeContent.includes('workplace.shortcut.exportLeads')); + assert.ok(!EMOJI_RE.test(localeContent), '语言包严禁 emoji'); +}); diff --git a/backend/src/comm/types/omni.ts b/backend/src/comm/types/omni.ts index 20b8447..d7a5944 100644 --- a/backend/src/comm/types/omni.ts +++ b/backend/src/comm/types/omni.ts @@ -243,6 +243,48 @@ export interface RadarTrack { createdAt: string | Date; } +/** + * 商务概览工作台 KPI + */ +export interface WorkplaceKpi { + /** 今日 NFC 触碰量 */ + tapCount: number; + /** 今日名片浏览量 */ + viewCount: number; + /** 今日新线索 */ + newLeadCount: number; + /** 成交转化率 (赢单 / 未归档线索, 0-100) */ + conversionRate: number; + /** 触碰量较昨日差值 */ + tapDelta: number; + /** 浏览量较昨日差值 */ + viewDelta: number; + /** 新线索较昨日差值 */ + newLeadDelta: number; +} + +/** + * 工作台雷达动态项 + */ +export interface WorkplaceRadarItem { + id: number | string; + cardId: number | string; + country: string; + city: string; + touchType: TouchType; + stayDurationMs: number; + viewedBlocks: string[]; + createTime: Date | string; +} + +/** + * 商务概览工作台聚合结果 + */ +export interface WorkplaceOverviewResult { + kpis: WorkplaceKpi; + radarFeed: WorkplaceRadarItem[]; +} + // ========================================== // 3. NFC 实体卡设备台账契约 (NFC Fleet) // ========================================== diff --git a/backend/src/modules/crm/controller/admin/radar.ts b/backend/src/modules/crm/controller/admin/radar.ts index e034d61..c3b0611 100644 --- a/backend/src/modules/crm/controller/admin/radar.ts +++ b/backend/src/modules/crm/controller/admin/radar.ts @@ -22,6 +22,7 @@ import { CrmRadarService } from '../../service/radar'; { method: 'getRecentTracks', summary: '最近足迹' }, { method: 'distribution', summary: '国家分布' }, { method: 'analytics', summary: '大屏综合指标' }, + { method: 'getWorkplaceOverview', summary: '商务概览工作台' }, ], }) export class AdminCrmRadarController extends BaseController { @@ -72,5 +73,25 @@ export class AdminCrmRadarController extends BaseController { }); return this.ok(data); } + + /** + * 商务概览工作台 + * 路由: GET/POST /admin/crm/radar/workplace + */ + @Get('/workplace', { summary: '商务概览工作台' }) + @Post('/workplace', { summary: '商务概览工作台' }) + async workplace( + @Body() body?: { feedLimit?: number }, + @Query('feedLimit') queryFeedLimit?: number + ) { + const feedLimit = body?.feedLimit ?? queryFeedLimit; + const data = await this.crmRadarService.getWorkplaceOverview({ + feedLimit: + feedLimit !== undefined && feedLimit !== null && !isNaN(Number(feedLimit)) + ? Number(feedLimit) + : undefined, + }); + return this.ok(data); + } } diff --git a/backend/src/modules/crm/service/radar.ts b/backend/src/modules/crm/service/radar.ts index eba76ee..6b9b52d 100644 --- a/backend/src/modules/crm/service/radar.ts +++ b/backend/src/modules/crm/service/radar.ts @@ -3,9 +3,137 @@ import { BaseService, CoolCommException } from '@cool-midway/core'; import { InjectEntityModel } from '@midwayjs/typeorm'; import { Repository } from 'typeorm'; import { CrmTrackEntity } from '../entity/track'; -import { TouchType } from '../../../comm/types/omni'; +import { CrmLeadEntity } from '../entity/lead'; +import { + LeadStatus, + TouchType, + WorkplaceOverviewResult, + WorkplaceRadarItem, +} from '../../../comm/types/omni'; import { lookupGeoIp } from '../../../comm/geoip/lookup'; +interface WorkplaceTrackInput { + id?: number | string; + cardId?: number | string; + country?: string; + city?: string; + touchType?: TouchType; + stayDurationMs?: number; + viewedBlocks?: string[]; + createTime?: Date | string; + createdAt?: Date | string; +} + +interface WorkplaceLeadInput { + status?: LeadStatus; + createTime?: Date | string; + createdAt?: Date | string; +} + +function toTimestamp(value?: Date | string): number { + if (!value) { + return 0; + } + const time = new Date(value).getTime(); + return Number.isFinite(time) ? time : 0; +} + +function startOfLocalDay(now: Date): Date { + return new Date(now.getFullYear(), now.getMonth(), now.getDate()); +} + +function resolveEventTime(item: { createTime?: Date | string; createdAt?: Date | string }): number { + return toTimestamp(item.createTime ?? item.createdAt); +} + +/** + * 纯函数:按今日窗口聚合工作台 KPI 与雷达动态 + */ +export function buildWorkplaceOverview( + tracks: WorkplaceTrackInput[], + leads: WorkplaceLeadInput[], + now: Date, + feedLimit = 12 +): WorkplaceOverviewResult { + const todayStart = startOfLocalDay(now).getTime(); + const yesterdayStart = todayStart - 24 * 60 * 60 * 1000; + const parsedLimit = Number(feedLimit); + const take = Number.isFinite(parsedLimit) + ? Math.min(50, Math.max(1, Math.round(parsedLimit))) + : 12; + + let todayTaps = 0; + let todayViews = 0; + let yesterdayTaps = 0; + let yesterdayViews = 0; + + for (const item of tracks) { + const time = resolveEventTime(item); + const isNfc = item.touchType === 'nfc'; + if (time >= todayStart) { + todayViews += 1; + if (isNfc) { + todayTaps += 1; + } + } else if (time >= yesterdayStart && time < todayStart) { + yesterdayViews += 1; + if (isNfc) { + yesterdayTaps += 1; + } + } + } + + let todayLeads = 0; + let yesterdayLeads = 0; + let activeLeads = 0; + let wonLeads = 0; + + for (const item of leads) { + const time = resolveEventTime(item); + if (time >= todayStart) { + todayLeads += 1; + } else if (time >= yesterdayStart && time < todayStart) { + yesterdayLeads += 1; + } + if (item.status !== 'archived') { + activeLeads += 1; + if (item.status === 'won') { + wonLeads += 1; + } + } + } + + const radarFeed: WorkplaceRadarItem[] = [...tracks] + .sort((a, b) => resolveEventTime(b) - resolveEventTime(a)) + .slice(0, take) + .map((item) => ({ + id: item.id ?? 0, + cardId: item.cardId ?? 0, + country: item.country || 'Unknown', + city: item.city || 'Unknown', + touchType: item.touchType && ['nfc', 'qr', 'link'].includes(item.touchType) + ? item.touchType + : 'link', + stayDurationMs: Math.max(0, Math.round(item.stayDurationMs || 0)), + viewedBlocks: Array.isArray(item.viewedBlocks) ? item.viewedBlocks.map(String) : [], + createTime: item.createTime ?? item.createdAt ?? now, + })); + + return { + kpis: { + tapCount: todayTaps, + viewCount: todayViews, + newLeadCount: todayLeads, + conversionRate: + activeLeads > 0 ? Number(((wonLeads / activeLeads) * 100).toFixed(1)) : 0, + tapDelta: todayTaps - yesterdayTaps, + viewDelta: todayViews - yesterdayViews, + newLeadDelta: todayLeads - yesterdayLeads, + }, + radarFeed, + }; +} + /** * 访客足迹上报 DTO */ @@ -81,6 +209,9 @@ export class CrmRadarService extends BaseService { @InjectEntityModel(CrmTrackEntity) crmTrackEntity: Repository; + @InjectEntityModel(CrmLeadEntity) + crmLeadEntity: Repository; + @Inject() ctx: any; @@ -403,4 +534,21 @@ export class CrmRadarService extends BaseService { }, }; } + + /** + * 商务概览工作台:今日 KPI + 最近雷达动态 + */ + async getWorkplaceOverview(params?: { + feedLimit?: number; + }): Promise { + const feedLimit = params?.feedLimit; + const [tracks, leads] = await Promise.all([ + this.crmTrackEntity.find({ + order: { id: 'DESC' }, + take: 500, + }), + this.crmLeadEntity.find(), + ]); + return buildWorkplaceOverview(tracks, leads, new Date(), feedLimit); + } } diff --git a/backend/test/modules/crm/workplace.test.ts b/backend/test/modules/crm/workplace.test.ts new file mode 100644 index 0000000..0d46781 --- /dev/null +++ b/backend/test/modules/crm/workplace.test.ts @@ -0,0 +1,155 @@ +import { AdminCrmRadarController } from '../../../src/modules/crm/controller/admin/radar'; +import { + buildWorkplaceOverview, + CrmRadarService, +} from '../../../src/modules/crm/service/radar'; +import { CrmTrackEntity } from '../../../src/modules/crm/entity/track'; +import { CrmLeadEntity } from '../../../src/modules/crm/entity/lead'; + +function track(partial: Partial & { id: number; createTime: Date }): CrmTrackEntity { + const item = new CrmTrackEntity(); + item.id = partial.id; + item.cardId = partial.cardId ?? 1; + item.country = partial.country ?? 'AE'; + item.city = partial.city ?? 'Dubai'; + item.touchType = partial.touchType ?? 'link'; + item.stayDurationMs = partial.stayDurationMs ?? 30000; + item.viewedBlocks = partial.viewedBlocks ?? ['hero']; + item.createTime = partial.createTime; + return item; +} + +function lead( + partial: Partial & { id: number; createTime: Date } +): CrmLeadEntity { + const item = new CrmLeadEntity(); + item.id = partial.id; + item.status = partial.status ?? 'new'; + item.createTime = partial.createTime; + return item; +} + +describe('商务概览工作台聚合 (getWorkplaceOverview)', () => { + const now = new Date('2026-09-21T15:00:00'); + const today = new Date('2026-09-21T09:30:00'); + const yesterday = new Date('2026-09-20T11:00:00'); + const older = new Date('2026-09-18T08:00:00'); + + it('应按今日窗口统计触碰量、浏览量、新线索,并计算成交转化', () => { + const result = buildWorkplaceOverview( + [ + track({ id: 1, touchType: 'nfc', createTime: today, country: 'AE', city: 'Dubai' }), + track({ id: 2, touchType: 'qr', createTime: today, country: 'SG', city: 'Singapore' }), + track({ id: 3, touchType: 'nfc', createTime: yesterday }), + track({ id: 4, touchType: 'link', createTime: older }), + ], + [ + lead({ id: 1, status: 'new', createTime: today }), + lead({ id: 2, status: 'won', createTime: older }), + lead({ id: 3, status: 'archived', createTime: older }), + lead({ id: 4, status: 'contacted', createTime: yesterday }), + ], + now, + 10 + ); + + expect(result.kpis.tapCount).toBe(1); + expect(result.kpis.viewCount).toBe(2); + expect(result.kpis.newLeadCount).toBe(1); + expect(result.kpis.conversionRate).toBe(33.3); + expect(result.kpis.tapDelta).toBe(0); + expect(result.kpis.viewDelta).toBe(1); + expect(result.kpis.newLeadDelta).toBe(0); + }); + + it('空数据时应返回零值 KPI 与空雷达动态', () => { + const result = buildWorkplaceOverview([], [], now, 12); + expect(result.kpis.tapCount).toBe(0); + expect(result.kpis.viewCount).toBe(0); + expect(result.kpis.newLeadCount).toBe(0); + expect(result.kpis.conversionRate).toBe(0); + expect(result.radarFeed).toEqual([]); + }); + + it('雷达动态应按时间倒序截断,且不要求 cardId', () => { + const result = buildWorkplaceOverview( + [ + track({ id: 1, createTime: yesterday, country: 'US', city: 'Austin' }), + track({ id: 3, createTime: today, country: 'DE', city: 'Berlin', viewedBlocks: ['cases'] }), + track({ id: 2, createTime: today, country: 'JP', city: 'Tokyo' }), + ], + [], + now, + 2 + ); + + expect(result.radarFeed).toHaveLength(2); + expect(result.radarFeed[0].id).toBe(3); + expect(result.radarFeed[0].city).toBe('Berlin'); + expect(result.radarFeed[1].id).toBe(2); + }); + + it('归档线索不计入成交转化分母', () => { + const result = buildWorkplaceOverview( + [], + [ + lead({ id: 1, status: 'won', createTime: older }), + lead({ id: 2, status: 'archived', createTime: older }), + lead({ id: 3, status: 'archived', createTime: older }), + ], + now + ); + expect(result.kpis.conversionRate).toBe(100); + }); + + it('服务应聚合足迹与线索仓库并返回工作台结果', async () => { + const service = new CrmRadarService(); + const tracks = [ + track({ id: 9, touchType: 'nfc', createTime: today, viewedBlocks: ['hero', 'cases'] }), + ]; + const leads = [lead({ id: 7, status: 'new', createTime: today })]; + + service.crmTrackEntity = { + find: jest.fn().mockResolvedValue(tracks), + } as never; + service.crmLeadEntity = { + find: jest.fn().mockResolvedValue(leads), + } as never; + + const result = await service.getWorkplaceOverview({ feedLimit: 8 }); + expect(result.kpis.tapCount).toBe(1); + expect(result.kpis.newLeadCount).toBe(1); + expect(result.radarFeed[0].viewedBlocks).toEqual(['hero', 'cases']); + }); + + it('管理端控制器必须暴露 /workplace 聚合接口', async () => { + const controller = new AdminCrmRadarController(); + const overview = { + kpis: { + tapCount: 1, + viewCount: 2, + newLeadCount: 1, + conversionRate: 10, + tapDelta: 0, + viewDelta: 1, + newLeadDelta: 0, + }, + radarFeed: [], + }; + controller.crmRadarService = { + getWorkplaceOverview: jest.fn().mockResolvedValue(overview), + } as never; + (controller as unknown as { ok: (data: unknown) => unknown }).ok = (data) => ({ + code: 1000, + data, + }); + + const res = (await controller.workplace({}, 12)) as unknown as { + code: number; + data: typeof overview; + }; + expect(res.code).toBe(1000); + expect(res.data.kpis.viewCount).toBe(2); + expect(controller.crmRadarService.getWorkplaceOverview).toHaveBeenCalled(); + }); +});