feat(admin-workplace): 工作台业务概览真实对接与全端认证集成 (更新远程代码仓配置与测试全绿)
这个提交包含在:
@@ -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)
|
||||
// ==========================================
|
||||
|
||||
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() {
|
||||
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');
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户