feat(admin-workplace): 工作台业务概览真实对接与全端认证集成 (更新远程代码仓配置与测试全绿)
这个提交包含在:
@@ -7,6 +7,7 @@
|
|||||||
## 1. 项目基本概况 (Project Overview)
|
## 1. 项目基本概况 (Project Overview)
|
||||||
|
|
||||||
- **项目名称**:OmniCard AI (全球智能数字名片 & 移动微官网 SaaS)
|
- **项目名称**:OmniCard AI (全球智能数字名片 & 移动微官网 SaaS)
|
||||||
|
- **远程仓库**:`https://gitcode.gynhc.com/wikiboss/OmniCard.git`
|
||||||
- **业务定位**:面向全球商务出海的下一代 **AI 驱动型智能名片 + 移动微官网 + 访客雷达 CRM + NFC 实体卡管理** 一体化平台。
|
- **业务定位**:面向全球商务出海的下一代 **AI 驱动型智能名片 + 移动微官网 + 访客雷达 CRM + NFC 实体卡管理** 一体化平台。
|
||||||
- **业务核心模块**:
|
- **业务核心模块**:
|
||||||
1. 微官网搭建与展示系统(Bento Grid 便当盒美学、响应式与 RTL 多语言适配)
|
1. 微官网搭建与展示系统(Bento Grid 便当盒美学、响应式与 RTL 多语言适配)
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ import styles from './style/index.module.less';
|
|||||||
import defaultLocale from '@/locale';
|
import defaultLocale from '@/locale';
|
||||||
import useStorage from '@/utils/useStorage';
|
import useStorage from '@/utils/useStorage';
|
||||||
import { generatePermission } from '@/routes';
|
import { generatePermission } from '@/routes';
|
||||||
|
import { clearAdminTokens } from '@/utils/auth-token';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
function Navbar({ show }: { show: boolean }) {
|
function Navbar({ show }: { show: boolean }) {
|
||||||
const t = useLocale();
|
const t = useLocale();
|
||||||
@@ -48,8 +50,14 @@ function Navbar({ show }: { show: boolean }) {
|
|||||||
const { setLang, lang, theme, setTheme } = useContext(GlobalContext);
|
const { setLang, lang, theme, setTheme } = useContext(GlobalContext);
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
setUserStatus('logout');
|
axios
|
||||||
window.location.href = '/login';
|
.post('/api/admin/base/comm/logout')
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
clearAdminTokens();
|
||||||
|
setUserStatus('logout');
|
||||||
|
window.location.href = '/login';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMenuItemClick(key) {
|
function onMenuItemClick(key) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Login from './pages/login';
|
|||||||
import checkLogin from './utils/checkLogin';
|
import checkLogin from './utils/checkLogin';
|
||||||
import changeTheme from './utils/changeTheme';
|
import changeTheme from './utils/changeTheme';
|
||||||
import useStorage from './utils/useStorage';
|
import useStorage from './utils/useStorage';
|
||||||
|
import './utils/setupRequest';
|
||||||
import './mock';
|
import './mock';
|
||||||
|
|
||||||
const store = createStore(rootReducer);
|
const store = createStore(rootReducer);
|
||||||
@@ -40,10 +41,43 @@ function Index() {
|
|||||||
payload: { userLoading: true },
|
payload: { userLoading: true },
|
||||||
});
|
});
|
||||||
axios.get('/api/user/userInfo').then((res) => {
|
axios.get('/api/user/userInfo').then((res) => {
|
||||||
store.dispatch({
|
const mockUser = res.data;
|
||||||
type: 'update-userInfo',
|
axios
|
||||||
payload: { userInfo: res.data, userLoading: false },
|
.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 './user';
|
||||||
import './message-box';
|
import './message-box';
|
||||||
|
|
||||||
|
interface MockXhrLike {
|
||||||
|
send: (...args: unknown[]) => unknown;
|
||||||
|
custom?: {
|
||||||
|
xhr?: {
|
||||||
|
setRequestHeader: (name: string, value: string) => void;
|
||||||
|
};
|
||||||
|
requestHeaders?: Record<string, string>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!isSSR) {
|
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({
|
Mock.setup({
|
||||||
timeout: '500-1500',
|
timeout: '500-1500',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,39 +1,379 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Grid, Space } from '@arco-design/web-react';
|
import { useHistory } from 'react-router-dom';
|
||||||
import Overview from './overview';
|
import { useSelector } from 'react-redux';
|
||||||
import PopularContents from './popular-contents';
|
import axios from 'axios';
|
||||||
import ContentPercentage from './content-percentage';
|
import {
|
||||||
import Shortcuts from './shortcuts';
|
Button,
|
||||||
import Announcement from './announcement';
|
Card,
|
||||||
import Carousel from './carousel';
|
Empty,
|
||||||
import Docs from './docs';
|
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 styles from './style/index.module.less';
|
||||||
import './mock';
|
|
||||||
|
|
||||||
const { Row, Col } = Grid;
|
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 (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.page} style={tokenStyle}>
|
||||||
<Space size={16} direction="vertical" className={styles.left}>
|
<div className={styles.header}>
|
||||||
<Overview />
|
<div>
|
||||||
<Row gutter={gutter}>
|
<Typography.Title heading={5} className={styles.title}>
|
||||||
<Col span={12}>
|
{t['workplace.title']}
|
||||||
<PopularContents />
|
</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>
|
||||||
<Col span={12}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<ContentPercentage />
|
<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>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</Space>
|
|
||||||
<Space className={styles.right} size={16} direction="vertical">
|
<Row gutter={[16, 16]} className={styles.main}>
|
||||||
<Shortcuts />
|
<Col xs={24} lg={16}>
|
||||||
<Carousel />
|
<Card
|
||||||
<Announcement />
|
bordered={false}
|
||||||
<Docs />
|
className={styles.panel}
|
||||||
</Space>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,90 @@
|
|||||||
const i18n = {
|
const i18n = {
|
||||||
'en-US': {
|
'en-US': {
|
||||||
'workplace.welcomeBack': 'Welcome Back,',
|
'workplace.title': 'Business overview',
|
||||||
'workplace.totalOnlyData': 'Total online data',
|
'workplace.subtitle': 'Today’s NFC taps, card views, new leads, and close rate',
|
||||||
'workplace.contentInMarket': 'Content in market',
|
'workplace.welcome': 'Welcome back, {name}',
|
||||||
'workplace.comments': 'Comments',
|
'workplace.welcomeGuest': 'Welcome back',
|
||||||
'workplace.growth': 'Growth',
|
'workplace.cta.newCard': 'New card',
|
||||||
'workplace.contentData': 'Content Data',
|
'workplace.kpi.taps': 'NFC taps',
|
||||||
'workplace.1year': 'Nearly 1 Year',
|
'workplace.kpi.views': 'Card views',
|
||||||
'workplace.seeMore': 'See More',
|
'workplace.kpi.leads': 'New leads',
|
||||||
'workplace.popularContents': 'Popular Contents',
|
'workplace.kpi.conversion': 'Close rate',
|
||||||
'workplace.text': 'Text',
|
'workplace.kpi.vsYesterday': 'vs yesterday {delta}',
|
||||||
'workplace.image': 'Image',
|
'workplace.radar.title': 'Live visitor radar',
|
||||||
'workplace.video': 'Video',
|
'workplace.radar.empty': 'No one has opened a card today.',
|
||||||
'workplace.column.rank': 'Rank',
|
'workplace.radar.emptyAction': 'Open global radar',
|
||||||
'workplace.column.title': 'Title',
|
'workplace.radar.stay': '{seconds}s stay',
|
||||||
'workplace.column.pv': 'PV',
|
'workplace.radar.viewing': 'Viewing {blocks}',
|
||||||
'workplace.column.increase': 'Daily Increase',
|
'workplace.shortcut.title': 'Shortcuts',
|
||||||
'workplace.contentPercentage': 'Percentage of content categories',
|
'workplace.shortcut.issueCard': 'Issue NFC cards',
|
||||||
'workplace.shortcuts': 'Shortcuts',
|
'workplace.shortcut.issueCardHint': 'Generate short links and print files',
|
||||||
'workplace.manage': 'Manage',
|
'workplace.shortcut.exportLeads': 'Export leads',
|
||||||
'workplace.contentMgmt': 'Management',
|
'workplace.shortcut.exportLeadsHint': 'Download today’s CRM list as CSV',
|
||||||
'workplace.contentStatistic': 'Statistic',
|
'workplace.channel.nfc': 'NFC',
|
||||||
'workplace.advancedMgmt': 'Advance',
|
'workplace.channel.qr': 'QR',
|
||||||
'workplace.onlinePromotion': 'Promotion',
|
'workplace.channel.link': 'Link',
|
||||||
'workplace.marketing': 'Marketing',
|
'workplace.export.success': 'Lead list exported',
|
||||||
'workplace.recent': 'Recent',
|
'workplace.export.empty': 'No leads to export yet',
|
||||||
'workplace.announcement': 'Announcement',
|
'workplace.export.fail': 'Unable to export leads',
|
||||||
'workplace.activity': 'Activity',
|
'workplace.request.fail': 'Unable to load the workspace',
|
||||||
'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',
|
|
||||||
},
|
},
|
||||||
'zh-CN': {
|
'zh-CN': {
|
||||||
'workplace.welcomeBack': '欢迎回来,',
|
'workplace.title': '商务概览',
|
||||||
'workplace.totalOnlyData': '线上总数据',
|
'workplace.subtitle': '今日触碰、浏览、新线索与成交转化',
|
||||||
'workplace.contentInMarket': '投放中的内容',
|
'workplace.welcome': '欢迎回来,{name}',
|
||||||
'workplace.comments': '日新增评论',
|
'workplace.welcomeGuest': '欢迎回来',
|
||||||
'workplace.growth': '较昨日新增',
|
'workplace.cta.newCard': '新建名片',
|
||||||
'workplace.contentData': '内容数据',
|
'workplace.kpi.taps': '触碰量',
|
||||||
'workplace.1year': '近1年',
|
'workplace.kpi.views': '浏览量',
|
||||||
'workplace.seeMore': '查看更多',
|
'workplace.kpi.leads': '新线索',
|
||||||
'workplace.popularContents': '线上热门内容',
|
'workplace.kpi.conversion': '成交转化',
|
||||||
'workplace.text': '文本',
|
'workplace.kpi.vsYesterday': '较昨日 {delta}',
|
||||||
'workplace.image': '图文',
|
'workplace.radar.title': '今日实时雷达',
|
||||||
'workplace.video': '视频',
|
'workplace.radar.empty': '今天还没有访客打开名片。',
|
||||||
'workplace.column.rank': '排名',
|
'workplace.radar.emptyAction': '查看全球雷达',
|
||||||
'workplace.column.title': '内容标题',
|
'workplace.radar.stay': '停留 {seconds} 秒',
|
||||||
'workplace.column.pv': '点击量',
|
'workplace.radar.viewing': '正在查看 {blocks}',
|
||||||
'workplace.column.increase': '日涨幅',
|
'workplace.shortcut.title': '快捷入口',
|
||||||
'workplace.contentPercentage': '内容类别占比',
|
'workplace.shortcut.issueCard': '批量发卡',
|
||||||
'workplace.shortcuts': '快捷入口',
|
'workplace.shortcut.issueCardHint': '生成短链与打印物料',
|
||||||
'workplace.manage': '管理',
|
'workplace.shortcut.exportLeads': '导出线索',
|
||||||
'workplace.contentMgmt': '内容管理',
|
'workplace.shortcut.exportLeadsHint': '下载潜客列表为 CSV',
|
||||||
'workplace.contentStatistic': '内容数据',
|
'workplace.channel.nfc': 'NFC',
|
||||||
'workplace.advancedMgmt': '高级管理',
|
'workplace.channel.qr': '二维码',
|
||||||
'workplace.onlinePromotion': '线上推广',
|
'workplace.channel.link': '链接',
|
||||||
'workplace.marketing': '内容投放',
|
'workplace.export.success': '线索已导出',
|
||||||
'workplace.recent': '最近访问',
|
'workplace.export.empty': '暂无可导出的线索',
|
||||||
'workplace.announcement': '公告',
|
'workplace.export.fail': '线索导出失败',
|
||||||
'workplace.activity': '活动',
|
'workplace.request.fail': '工作台加载失败',
|
||||||
'workplace.info': '消息',
|
},
|
||||||
'workplace.notice': '通知',
|
'ar-SA': {
|
||||||
'workplace.docs': '文档中心',
|
'workplace.title': 'نظرة العمل',
|
||||||
'workplace.pecs': '个',
|
'workplace.subtitle': 'لمسات NFC ومشاهدات البطاقة والعملاء الجدد ومعدل الإغلاق اليوم',
|
||||||
'workplace.designLab': '风格配置平台',
|
'workplace.welcome': 'مرحباً بعودتك، {name}',
|
||||||
'workplace.materialMarket': '物料市场',
|
'workplace.welcomeGuest': 'مرحباً بعودتك',
|
||||||
'workplace.react': 'React 组件库',
|
'workplace.cta.newCard': 'بطاقة جديدة',
|
||||||
'workplace.vue': 'Vue 组件库',
|
'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 {
|
.page {
|
||||||
background-color: var(--color-bg-2);
|
min-height: 100%;
|
||||||
padding: 20px;
|
max-width: 1200px;
|
||||||
|
padding: var(--omni-space-24);
|
||||||
|
background: var(--omni-color-surface);
|
||||||
|
font-family: var(--omni-font-family);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.header {
|
||||||
display: flex;
|
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%;
|
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 {
|
.shortcut:last-child {
|
||||||
width: calc(100% - 296px);
|
border-bottom: 0;
|
||||||
margin-right: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.right {
|
.shortcut:hover {
|
||||||
width: 280px;
|
filter: brightness(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.shortcut:focus-visible {
|
||||||
background-color: var(--color-bg-2);
|
outline: 2px solid var(--omni-color-primary);
|
||||||
border-radius: 4px;
|
outline-offset: 2px;
|
||||||
overflow: auto;
|
}
|
||||||
|
|
||||||
|
.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,
|
Space,
|
||||||
} from '@arco-design/web-react';
|
} from '@arco-design/web-react';
|
||||||
import { FormInstance } from '@arco-design/web-react/es/Form';
|
import { FormInstance } from '@arco-design/web-react/es/Form';
|
||||||
import { IconLock, IconUser } from '@arco-design/web-react/icon';
|
import { IconLock, IconUser, IconSafe } from '@arco-design/web-react/icon';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import useStorage from '@/utils/useStorage';
|
import useStorage from '@/utils/useStorage';
|
||||||
import useLocale from '@/utils/useLocale';
|
import useLocale from '@/utils/useLocale';
|
||||||
|
import { setAdminTokens } from '@/utils/auth-token';
|
||||||
|
import { lightColors } from '@/style/tokens';
|
||||||
import locale from './locale';
|
import locale from './locale';
|
||||||
import styles from './style/index.module.less';
|
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() {
|
export default function LoginForm() {
|
||||||
const formRef = useRef<FormInstance>();
|
const formRef = useRef<FormInstance>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [captchaId, setCaptchaId] = useState('');
|
||||||
|
const [captchaImg, setCaptchaImg] = useState('');
|
||||||
const [loginParams, setLoginParams, removeLoginParams] =
|
const [loginParams, setLoginParams, removeLoginParams] =
|
||||||
useStorage('loginParams');
|
useStorage('loginParams');
|
||||||
|
|
||||||
@@ -26,31 +65,76 @@ export default function LoginForm() {
|
|||||||
|
|
||||||
const [rememberPassword, setRememberPassword] = useState(!!loginParams);
|
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) {
|
if (rememberPassword) {
|
||||||
setLoginParams(JSON.stringify(params));
|
setLoginParams(
|
||||||
|
JSON.stringify({
|
||||||
|
userName: params.userName,
|
||||||
|
password: params.password,
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
removeLoginParams();
|
removeLoginParams();
|
||||||
}
|
}
|
||||||
// 记录登录状态
|
|
||||||
localStorage.setItem('userStatus', 'login');
|
localStorage.setItem('userStatus', 'login');
|
||||||
// 跳转首页
|
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
function login(params) {
|
function login(params: LoginFormValues) {
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
axios
|
axios
|
||||||
.post('/api/user/login', params)
|
.post<CoolResponse<CoolLoginData>>(LOGIN_API, {
|
||||||
|
username: params.userName,
|
||||||
|
password: params.password,
|
||||||
|
captchaId,
|
||||||
|
verifyCode: params.verifyCode,
|
||||||
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const { status, msg } = res.data;
|
if (res.data.code === COOL_OK && res.data.data?.token) {
|
||||||
if (status === 'ok') {
|
setAdminTokens(res.data.data.token, res.data.data.refreshToken);
|
||||||
afterLoginSuccess(params);
|
afterLoginSuccess(params);
|
||||||
} else {
|
return;
|
||||||
setErrorMessage(msg || t['login.form.login.errMsg']);
|
|
||||||
}
|
}
|
||||||
|
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(() => {
|
.finally(() => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -58,20 +142,31 @@ export default function LoginForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSubmitClick() {
|
function onSubmitClick() {
|
||||||
formRef.current.validate().then((values) => {
|
formRef.current?.validate().then((values: LoginFormValues) => {
|
||||||
login(values);
|
login(values);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 读取 localStorage,设置初始值
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const rememberPassword = !!loginParams;
|
refreshCaptcha();
|
||||||
setRememberPassword(rememberPassword);
|
}, [refreshCaptcha]);
|
||||||
if (formRef.current && rememberPassword) {
|
|
||||||
const parseParams = JSON.parse(loginParams);
|
useEffect(() => {
|
||||||
formRef.current.setFieldsValue(parseParams);
|
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 (
|
return (
|
||||||
<div className={styles['login-form-wrapper']}>
|
<div className={styles['login-form-wrapper']}>
|
||||||
@@ -84,7 +179,7 @@ export default function LoginForm() {
|
|||||||
className={styles['login-form']}
|
className={styles['login-form']}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
ref={formRef}
|
ref={formRef}
|
||||||
initialValues={{ userName: 'admin', password: 'admin' }}
|
initialValues={{ userName: 'admin', password: '123456' }}
|
||||||
>
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
field="userName"
|
field="userName"
|
||||||
@@ -106,6 +201,39 @@ export default function LoginForm() {
|
|||||||
onPressEnter={onSubmitClick}
|
onPressEnter={onSubmitClick}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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">
|
<Space size={16} direction="vertical">
|
||||||
<div className={styles['login-form-password-actions']}>
|
<div className={styles['login-form-password-actions']}>
|
||||||
<Checkbox checked={rememberPassword} onChange={setRememberPassword}>
|
<Checkbox checked={rememberPassword} onChange={setRememberPassword}>
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ const i18n = {
|
|||||||
'login.form.password.errMsg': 'Password cannot be empty',
|
'login.form.password.errMsg': 'Password cannot be empty',
|
||||||
'login.form.login.errMsg': 'Login error, please refresh and try again',
|
'login.form.login.errMsg': 'Login error, please refresh and try again',
|
||||||
'login.form.userName.placeholder': 'Username: admin',
|
'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.rememberPassword': 'Remember password',
|
||||||
'login.form.forgetPassword': 'Forgot password',
|
'login.form.forgetPassword': 'Forgot password',
|
||||||
'login.form.login': 'Sign in',
|
'login.form.login': 'Sign in',
|
||||||
@@ -25,7 +29,11 @@ const i18n = {
|
|||||||
'login.form.password.errMsg': '密码不能为空',
|
'login.form.password.errMsg': '密码不能为空',
|
||||||
'login.form.login.errMsg': '登录出错,请刷新重试',
|
'login.form.login.errMsg': '登录出错,请刷新重试',
|
||||||
'login.form.userName.placeholder': '用户名:admin',
|
'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.rememberPassword': '记住密码',
|
||||||
'login.form.forgetPassword': '忘记密码',
|
'login.form.forgetPassword': '忘记密码',
|
||||||
'login.form.login': '登录',
|
'login.form.login': '登录',
|
||||||
|
|||||||
@@ -117,4 +117,42 @@
|
|||||||
&-register-btn {
|
&-register-btn {
|
||||||
color: var(--color-text-3) !important;
|
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;
|
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)
|
// 3. NFC 实体卡设备台账契约 (NFC Fleet)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
23
admin/src/utils/auth-token.ts
普通文件
23
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');
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getAdminToken } from './auth-token';
|
||||||
|
|
||||||
export default function checkLogin() {
|
export default function checkLogin() {
|
||||||
return localStorage.getItem('userStatus') === 'login';
|
return localStorage.getItem('userStatus') === 'login' && !!getAdminToken();
|
||||||
}
|
}
|
||||||
|
|||||||
25
admin/src/utils/setupRequest.ts
普通文件
25
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);
|
||||||
|
}
|
||||||
|
);
|
||||||
55
admin/tests/admin-auth.test.ts
普通文件
55
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'));
|
||||||
|
});
|
||||||
@@ -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;
|
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)
|
// 3. NFC 实体卡设备台账契约 (NFC Fleet)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { CrmRadarService } from '../../service/radar';
|
|||||||
{ method: 'getRecentTracks', summary: '最近足迹' },
|
{ method: 'getRecentTracks', summary: '最近足迹' },
|
||||||
{ method: 'distribution', summary: '国家分布' },
|
{ method: 'distribution', summary: '国家分布' },
|
||||||
{ method: 'analytics', summary: '大屏综合指标' },
|
{ method: 'analytics', summary: '大屏综合指标' },
|
||||||
|
{ method: 'getWorkplaceOverview', summary: '商务概览工作台' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AdminCrmRadarController extends BaseController {
|
export class AdminCrmRadarController extends BaseController {
|
||||||
@@ -72,5 +73,25 @@ export class AdminCrmRadarController extends BaseController {
|
|||||||
});
|
});
|
||||||
return this.ok(data);
|
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 { InjectEntityModel } from '@midwayjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { CrmTrackEntity } from '../entity/track';
|
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';
|
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
|
* 访客足迹上报 DTO
|
||||||
*/
|
*/
|
||||||
@@ -81,6 +209,9 @@ export class CrmRadarService extends BaseService {
|
|||||||
@InjectEntityModel(CrmTrackEntity)
|
@InjectEntityModel(CrmTrackEntity)
|
||||||
crmTrackEntity: Repository<CrmTrackEntity>;
|
crmTrackEntity: Repository<CrmTrackEntity>;
|
||||||
|
|
||||||
|
@InjectEntityModel(CrmLeadEntity)
|
||||||
|
crmLeadEntity: Repository<CrmLeadEntity>;
|
||||||
|
|
||||||
@Inject()
|
@Inject()
|
||||||
ctx: any;
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户