feat(admin-workplace): 工作台业务概览真实对接与全端认证集成 (更新远程代码仓配置与测试全绿)

这个提交包含在:
wikiboss
2026-09-21 09:22:14 +08:00
父节点 8c003b70cd
当前提交 e5c79082d3
共有 20 个文件被更改,包括 1452 次插入 和 143 次删除

查看文件

@@ -7,6 +7,7 @@
## 1. 项目基本概况 (Project Overview)
- **项目名称**:OmniCard AI (全球智能数字名片 & 移动微官网 SaaS)
- **远程仓库**:`https://gitcode.gynhc.com/wikiboss/OmniCard.git`
- **业务定位**:面向全球商务出海的下一代 **AI 驱动型智能名片 + 移动微官网 + 访客雷达 CRM + NFC 实体卡管理** 一体化平台。
- **业务核心模块**:
1. 微官网搭建与展示系统(Bento Grid 便当盒美学、响应式与 RTL 多语言适配)

查看文件

@@ -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) {

查看文件

@@ -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 },
});
});
});
}

查看文件

@@ -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<string, string>;
};
}
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',
});

查看文件

@@ -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<T> {
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<WorkplaceOverviewResult>({
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<CoolResponse<WorkplaceOverviewResult>>(
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<CoolResponse<CoolPageData>>(
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 (
<div className={`${styles.delta} ${tone}`}>
{t['workplace.kpi.vsYesterday'].replace('{delta}', formatDelta(value))}
</div>
);
};
const kpis = overview.kpis;
function Workplace() {
return (
<div className={styles.wrapper}>
<Space size={16} direction="vertical" className={styles.left}>
<Overview />
<Row gutter={gutter}>
<Col span={12}>
<PopularContents />
<div className={styles.page} style={tokenStyle}>
<div className={styles.header}>
<div>
<Typography.Title heading={5} className={styles.title}>
{t['workplace.title']}
</Typography.Title>
<Typography.Text className={styles.subtitle}>
{t['workplace.subtitle']}
</Typography.Text>
<Typography.Text className={styles.welcome}>{welcome}</Typography.Text>
</div>
<Button
type="primary"
icon={<IconPlus />}
className={styles.primaryCta}
onClick={handleCreateCard}
>
{t['workplace.cta.newCard']}
</Button>
</div>
<Spin loading={loading} style={{ display: 'block' }}>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12} lg={6}>
<Card bordered={false} className={styles.kpiCard}>
<Statistic title={t['workplace.kpi.taps']} value={kpis.tapCount} />
{renderDelta(kpis.tapDelta)}
</Card>
</Col>
<Col span={12}>
<ContentPercentage />
<Col xs={24} sm={12} lg={6}>
<Card bordered={false} className={styles.kpiCard}>
<Statistic title={t['workplace.kpi.views']} value={kpis.viewCount} />
{renderDelta(kpis.viewDelta)}
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card bordered={false} className={styles.kpiCard}>
<Statistic title={t['workplace.kpi.leads']} value={kpis.newLeadCount} />
{renderDelta(kpis.newLeadDelta)}
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card bordered={false} className={styles.kpiCard}>
<Statistic
title={t['workplace.kpi.conversion']}
value={kpis.conversionRate}
suffix="%"
precision={1}
/>
</Card>
</Col>
</Row>
</Space>
<Space className={styles.right} size={16} direction="vertical">
<Shortcuts />
<Carousel />
<Announcement />
<Docs />
</Space>
<Row gutter={[16, 16]} className={styles.main}>
<Col xs={24} lg={16}>
<Card
bordered={false}
className={styles.panel}
title={t['workplace.radar.title']}
>
{overview.radarFeed.length === 0 ? (
<div className={styles.empty}>
<Empty
description={
<p className={styles.emptyText}>
{t['workplace.radar.empty']}
</p>
}
/>
<Button type="text" onClick={handleOpenRadar}>
{t['workplace.radar.emptyAction']}
</Button>
</div>
) : (
<Timeline>
{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 (
<Timeline.Item key={String(item.id)}>
<div className={styles.feedMeta}>
<Typography.Text>{locationLabel || '—'}</Typography.Text>
<Tag color={getChannelColor(touchType)} size="small">
{getChannelLabel(touchType)}
</Tag>
<Typography.Text type="secondary">
{t['workplace.radar.stay'].replace(
'{seconds}',
formatStaySeconds(item.stayDurationMs)
)}
</Typography.Text>
</div>
{blocks ? (
<div className={styles.feedExtra}>
{t['workplace.radar.viewing'].replace('{blocks}', blocks)}
</div>
) : null}
</Timeline.Item>
);
})}
</Timeline>
)}
</Card>
</Col>
<Col xs={24} lg={8}>
<Card
bordered={false}
className={styles.panel}
title={t['workplace.shortcut.title']}
>
<button
type="button"
className={styles.shortcut}
onClick={handleIssueCards}
>
<span className={styles.shortcutLabel}>
{t['workplace.shortcut.issueCard']}
</span>
<span className={styles.shortcutHint}>
{t['workplace.shortcut.issueCardHint']}
</span>
</button>
<button
type="button"
className={styles.shortcut}
onClick={handleExportLeads}
disabled={exporting}
>
<span className={styles.shortcutLabel}>
{t['workplace.shortcut.exportLeads']}
</span>
<span className={styles.shortcutHint}>
{t['workplace.shortcut.exportLeadsHint']}
</span>
</button>
</Card>
</Col>
</Row>
</Spin>
</div>
);
}

查看文件

@@ -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': 'تعذر تحميل مساحة العمل',
},
};

查看文件

@@ -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;
}
}

查看文件

@@ -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<T> {
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<FormInstance>();
const formRef = useRef<FormInstance>(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<CoolResponse<CoolCaptchaData>>(
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<CoolResponse<CoolLoginData>>(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 (
<div className={styles['login-form-wrapper']}>
@@ -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' }}
>
<Form.Item
field="userName"
@@ -106,6 +201,39 @@ export default function LoginForm() {
onPressEnter={onSubmitClick}
/>
</Form.Item>
<div className={styles['login-form-captcha']}>
<Form.Item
field="verifyCode"
rules={[
{ required: true, message: t['login.form.captcha.errMsg'] },
]}
className={styles['login-form-captcha-item']}
>
<Input
prefix={<IconSafe />}
placeholder={t['login.form.captcha.placeholder']}
onPressEnter={onSubmitClick}
/>
</Form.Item>
<button
type="button"
className={styles['login-form-captcha-refresh']}
onClick={refreshCaptcha}
aria-label={t['login.form.captcha.alt']}
>
{captchaImg ? (
<img
src={captchaImg}
alt={t['login.form.captcha.alt']}
className={styles['login-form-captcha-image']}
/>
) : (
<span className={styles['login-form-captcha-placeholder']}>
{t['login.form.captcha.placeholder']}
</span>
)}
</button>
</div>
<Space size={16} direction="vertical">
<div className={styles['login-form-password-actions']}>
<Checkbox checked={rememberPassword} onChange={setRememberPassword}>

查看文件

@@ -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': '登录',

查看文件

@@ -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);
}
}

查看文件

@@ -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)
// ==========================================

查看文件

@@ -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');
}

查看文件

@@ -1,3 +1,5 @@
import { getAdminToken } from './auth-token';
export default function checkLogin() {
return localStorage.getItem('userStatus') === 'login';
return localStorage.getItem('userStatus') === 'login' && !!getAdminToken();
}

查看文件

@@ -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);
}
);

查看文件

@@ -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'));
});

查看文件

@@ -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');
});

查看文件

@@ -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)
// ==========================================

查看文件

@@ -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);
}
}

查看文件

@@ -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<CrmTrackEntity>;
@InjectEntityModel(CrmLeadEntity)
crmLeadEntity: Repository<CrmLeadEntity>;
@Inject()
ctx: any;
@@ -403,4 +534,21 @@ export class CrmRadarService extends BaseService {
},
};
}
/**
* 商务概览工作台:今日 KPI + 最近雷达动态
*/
async getWorkplaceOverview(params?: {
feedLimit?: number;
}): Promise<WorkplaceOverviewResult> {
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);
}
}

查看文件

@@ -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<CrmTrackEntity> & { 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<CrmLeadEntity> & { 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();
});
});