feat(card): 实现后台博客文章与动态发布中心及OpenGraph自动提炼 (Point 02)

这个提交包含在:
wikiboss
2026-09-20 22:40:43 +08:00
父节点 679ea8441e
当前提交 991c0bb6b5
共有 11 个文件被更改,包括 1635 次插入 和 2 次删除

查看文件

@@ -5,6 +5,27 @@ const i18n = {
...omniLocales['en-US'].messages,
'menu.welcome': 'Welcome',
'menu.dashboard': 'Dashboard',
'menu.card': 'Digital Cards',
'menu.card.list': 'Card Library',
'menu.card.builder': 'Bento Builder',
'menu.card.cases': 'Case Studies',
'menu.card.articles': 'Articles & Posts',
'menu.crm': 'Radar CRM',
'menu.crm.leads': 'Leads',
'menu.crm.pipeline': 'Pipeline',
'menu.crm.radarLog': 'Visitor Radar',
'menu.crm.leadProfile': 'Lead Profile',
'menu.nfc': 'NFC Fleet',
'menu.nfc.devices': 'Device Pool',
'menu.nfc.batch': 'Batch Cards',
'menu.ai': 'Omni AI',
'menu.ai.knowledge': 'Knowledge Base',
'menu.team': 'Team',
'menu.team.brandKit': 'Brand Kit',
'menu.team.handover': 'Handover',
'menu.settingsBiz': 'Workspace',
'menu.settings.tenant': 'Domain & SSL',
'menu.settings.billing': 'Billing',
'menu.list': 'List',
'menu.result': 'Result',
'menu.exception': 'Exception',
@@ -64,6 +85,27 @@ const i18n = {
'zh-CN': {
...omniLocales['zh-CN'].messages,
'menu.dashboard': '仪表盘',
'menu.card': '数字名片',
'menu.card.list': '名片资产库',
'menu.card.builder': 'Bento 搭建器',
'menu.card.cases': '出海案例作品集',
'menu.card.articles': '文章与动态',
'menu.crm': '访客雷达',
'menu.crm.leads': '潜客线索',
'menu.crm.pipeline': '商机看板',
'menu.crm.radarLog': '访客足迹',
'menu.crm.leadProfile': '线索画像',
'menu.nfc': 'NFC 舰队',
'menu.nfc.devices': '设备池',
'menu.nfc.batch': '批量制卡',
'menu.ai': 'AI 中枢',
'menu.ai.knowledge': '知识库',
'menu.team': '企业团队',
'menu.team.brandKit': '品牌规范',
'menu.team.handover': '离职交接',
'menu.settingsBiz': '企业设置',
'menu.settings.tenant': '域名与证书',
'menu.settings.billing': '订阅账单',
'menu.list': '列表页',
'menu.result': '结果页',
'menu.exception': '异常页',

查看文件

@@ -0,0 +1,657 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
Table,
Drawer,
Form,
Input,
Switch,
Button,
Popconfirm,
Card,
Tag,
Message,
Typography,
Space,
Grid,
} from '@arco-design/web-react';
import type { PaginationProps, TableColumnProps } from '@arco-design/web-react';
import axios from 'axios';
import useLocale from '@/utils/useLocale';
import { lightColors } from '@/style/tokens';
import type { CardArticle, ArticleOgMeta } from '@/types/omni';
import locale from './locale';
type TablePaginationConfig = PaginationProps;
const DEFAULT_PAGE = 1;
const DEFAULT_SIZE = 10;
const PAGE_API = '/api/admin/card/article/page';
const ADD_API = '/api/admin/card/article/add';
const UPDATE_API = '/api/admin/card/article/update';
const DELETE_API = '/api/admin/card/article/delete';
interface ArticleFilterForm {
keyWord?: string;
cardId?: string;
isPublished?: boolean;
}
interface ArticleEditForm {
id?: number | string;
cardId?: number | string;
title?: string;
summary?: string;
content?: string;
coverImage?: string;
isPublished?: boolean;
}
interface CoolPagination {
page?: number;
size?: number;
total?: number;
}
interface CoolPageData {
list?: CardArticle[];
pagination?: CoolPagination;
}
interface CoolResponse<T = unknown> {
code: number;
message?: string;
data?: T;
}
function resolveErrorMessage(error: unknown, fallback: string): string {
if (axios.isAxiosError(error)) {
const payload = error.response?.data as { message?: string } | undefined;
if (payload && typeof payload.message === 'string' && payload.message) {
return payload.message;
}
if (error.message) {
return error.message;
}
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
}
function extractPlainText(content?: string): string {
if (!content) {
return '';
}
return content
.replace(/<[^>]+>/g, '')
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[(.*?)\]\(.*?\)/g, '$1')
.replace(/[#*`_~>-]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
function extractFirstImage(content?: string): string {
if (!content) {
return '';
}
const htmlImgMatch = content.match(/<img[^>]+src=["']([^"']+)["']/i);
if (htmlImgMatch && htmlImgMatch[1]) {
return htmlImgMatch[1].trim();
}
const mdImgMatch = content.match(/!\[.*?\]\((https?:\/\/[^\s)]+|\/[^\s)]+)\)/i);
if (mdImgMatch && mdImgMatch[1]) {
return mdImgMatch[1].trim();
}
return '';
}
function deriveOgMeta(formData: ArticleEditForm): ArticleOgMeta {
const title = (formData.title || '').trim();
let description = (formData.summary || '').trim();
if (!description && formData.content) {
description = extractPlainText(formData.content).slice(0, 160);
}
let image = (formData.coverImage || '').trim();
if (!image && formData.content) {
image = extractFirstImage(formData.content);
}
return { title, description, image };
}
export default function CardArticlesPage() {
const t = useLocale(locale);
const [filterForm] = Form.useForm<ArticleFilterForm>();
const [drawerForm] = Form.useForm<ArticleEditForm>();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [articles, setArticles] = useState<CardArticle[]>([]);
const [pagination, setPagination] = useState<TablePaginationConfig>({
current: DEFAULT_PAGE,
pageSize: DEFAULT_SIZE,
total: 0,
showTotal: true,
});
const [drawerVisible, setDrawerVisible] = useState(false);
const [currentArticleId, setCurrentArticleId] = useState<number | string | null>(null);
// 表单联动 OpenGraph 实时预览状态
const [formValues, setFormValues] = useState<ArticleEditForm>({
cardId: 1,
title: '',
summary: '',
content: '',
coverImage: '',
isPublished: false,
});
const ogPreview = useMemo(() => deriveOgMeta(formValues), [formValues]);
const loadData = async (page = DEFAULT_PAGE, size = DEFAULT_SIZE) => {
setLoading(true);
try {
const filters = filterForm.getFieldsValue();
const payload: Record<string, unknown> = {
page,
size,
};
if (filters.keyWord) {
payload.keyWord = filters.keyWord.trim();
}
if (filters.cardId) {
payload.cardId = filters.cardId.trim();
}
if (typeof filters.isPublished === 'boolean') {
payload.isPublished = filters.isPublished;
}
const res = await axios.post<CoolResponse<CoolPageData>>(PAGE_API, payload);
if (res.data && res.data.code === 1000) {
const pageData = res.data.data;
const list = Array.isArray(pageData?.list) ? pageData.list : [];
setArticles(list);
setPagination((prev) => ({
...prev,
current: page,
pageSize: size,
total: pageData?.pagination?.total ?? list.length,
}));
} else {
Message.error(res.data?.message || t['cardArticles.message.requestFail']);
}
} catch (err) {
Message.error(resolveErrorMessage(err, t['cardArticles.message.requestFail']));
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleOpenCreate = () => {
setCurrentArticleId(null);
const initData: ArticleEditForm = {
cardId: 1,
title: '',
summary: '',
content: '',
coverImage: '',
isPublished: true,
};
drawerForm.setFieldsValue(initData);
setFormValues(initData);
setDrawerVisible(true);
};
const handleOpenEdit = (record: CardArticle) => {
setCurrentArticleId(record.id ?? null);
const editData: ArticleEditForm = {
id: record.id,
cardId: record.cardId,
title: record.title,
summary: record.summary,
content: record.content,
coverImage: record.coverImage,
isPublished: Boolean(record.isPublished),
};
drawerForm.setFieldsValue(editData);
setFormValues(editData);
setDrawerVisible(true);
};
const handleTogglePublish = async (record: CardArticle, checked: boolean) => {
try {
const res = await axios.post<CoolResponse>(UPDATE_API, {
id: record.id,
isPublished: checked,
});
if (res.data && res.data.code === 1000) {
Message.success(t['cardArticles.message.statusSuccess']);
setArticles((prev) =>
prev.map((item) => (item.id === record.id ? { ...item, isPublished: checked } : item))
);
} else {
Message.error(res.data?.message || t['cardArticles.message.requestFail']);
}
} catch (err) {
Message.error(resolveErrorMessage(err, t['cardArticles.message.requestFail']));
}
};
const handleDelete = async (record: CardArticle) => {
try {
const res = await axios.post<CoolResponse>(DELETE_API, {
ids: [record.id],
});
if (res.data && res.data.code === 1000) {
Message.success(t['cardArticles.message.deleteSuccess']);
loadData(pagination.current, pagination.pageSize);
} else {
Message.error(res.data?.message || t['cardArticles.message.requestFail']);
}
} catch (err) {
Message.error(resolveErrorMessage(err, t['cardArticles.message.requestFail']));
}
};
const handleSaveArticle = async () => {
try {
const values = await drawerForm.validate();
setSaving(true);
const isEdit = Boolean(currentArticleId);
const api = isEdit ? UPDATE_API : ADD_API;
const payload = {
...values,
id: currentArticleId ?? undefined,
ogMeta: deriveOgMeta(values),
};
const res = await axios.post<CoolResponse>(api, payload);
if (res.data && res.data.code === 1000) {
Message.success(t['cardArticles.message.saveSuccess']);
setDrawerVisible(false);
loadData(pagination.current, pagination.pageSize);
} else {
Message.error(res.data?.message || t['cardArticles.message.requestFail']);
}
} catch (err) {
Message.error(resolveErrorMessage(err, t['cardArticles.message.requestFail']));
} finally {
setSaving(false);
}
};
const columns: TableColumnProps<CardArticle>[] = [
{
title: t['cardArticles.field.id'],
dataIndex: 'id',
width: 80,
},
{
title: t['cardArticles.field.cardId'],
dataIndex: 'cardId',
width: 100,
render: (val: unknown) => (val !== undefined && val !== null ? String(val) : '-'),
},
{
title: t['cardArticles.field.title'],
dataIndex: 'title',
render: (title: string, record: CardArticle) => (
<div>
<Typography.Text style={{ fontWeight: 500, color: lightColors.text }}>
{title}
</Typography.Text>
{record.summary && (
<div
style={{
fontSize: '12px',
color: lightColors.text2,
marginTop: '4px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '380px',
}}
>
{record.summary}
</div>
)}
</div>
),
},
{
title: t['cardArticles.field.status'],
dataIndex: 'isPublished',
width: 130,
render: (isPublished: boolean, record: CardArticle) => (
<Space size={8}>
<Switch
checked={Boolean(isPublished)}
size="small"
onChange={(checked) => handleTogglePublish(record, checked)}
/>
<Tag color={isPublished ? lightColors.success : lightColors.text2}>
{isPublished ? t['cardArticles.status.published'] : t['cardArticles.status.draft']}
</Tag>
</Space>
),
},
{
title: t['cardArticles.field.updateTime'],
dataIndex: 'updateTime',
width: 180,
render: (val: unknown) => (typeof val === 'string' ? val : '-'),
},
{
title: t['cardArticles.field.operations'],
width: 140,
render: (_: unknown, record: CardArticle) => (
<Space size={12}>
<Button
type="text"
size="small"
style={{ color: lightColors.primary, padding: 0 }}
onClick={() => handleOpenEdit(record)}
>
{t['cardArticles.action.edit']}
</Button>
<Popconfirm
title={t['cardArticles.action.deleteConfirm']}
onOk={() => handleDelete(record)}
>
<Button
type="text"
size="small"
style={{ color: lightColors.error, padding: 0 }}
>
{t['cardArticles.action.delete']}
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: '24px', backgroundColor: lightColors.surface, minHeight: '100%' }}>
{/* 头部导航与操作栏 */}
<Card
bordered={false}
style={{
backgroundColor: lightColors.bg,
border: `1px solid ${lightColors.border}`,
borderRadius: '16px',
marginBottom: '16px',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}
>
<div>
<Typography.Title heading={5} style={{ margin: 0, color: lightColors.text }}>
{t['cardArticles.title']}
</Typography.Title>
<Typography.Text style={{ color: lightColors.text2, fontSize: '13px' }}>
{t['cardArticles.description']}
</Typography.Text>
</div>
{/* 遵循瑞士国际主义风格:整屏唯一主 CTA 按钮 */}
<Button type="primary" onClick={handleOpenCreate}>
{t['cardArticles.action.add']}
</Button>
</div>
{/* 筛选表单 */}
<Form
form={filterForm}
layout="inline"
onValuesChange={() => loadData(1, pagination.pageSize)}
>
<Form.Item field="keyWord">
<Input.Search
placeholder={t['cardArticles.filter.search']}
allowClear
style={{ width: 260 }}
onSearch={() => loadData(1, pagination.pageSize)}
/>
</Form.Item>
<Form.Item field="cardId">
<Input
placeholder={t['cardArticles.filter.cardId']}
allowClear
style={{ width: 160 }}
/>
</Form.Item>
<Form.Item>
<Button
type="secondary"
onClick={() => {
filterForm.resetFields();
loadData(1, pagination.pageSize);
}}
>
{t['cardArticles.action.refresh']}
</Button>
</Form.Item>
</Form>
</Card>
{/* 数据表格 */}
<Card
bordered={false}
style={{
backgroundColor: lightColors.bg,
border: `1px solid ${lightColors.border}`,
borderRadius: '16px',
}}
>
<Table<CardArticle>
rowKey="id"
loading={loading}
columns={columns}
data={articles}
pagination={{
...pagination,
onChange: (current, pageSize) => loadData(current, pageSize),
}}
noDataElement={
<div style={{ textAlign: 'center', padding: '48px 0', color: lightColors.text2 }}>
{t['cardArticles.empty.text']}
</div>
}
/>
</Card>
{/* 文章编辑与 OpenGraph 社交预览抽屉 */}
<Drawer
title={
currentArticleId
? t['cardArticles.drawer.editTitle']
: t['cardArticles.drawer.createTitle']
}
visible={drawerVisible}
width={780}
onCancel={() => setDrawerVisible(false)}
footer={
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
<Button type="secondary" onClick={() => setDrawerVisible(false)}>
{t['cardArticles.action.saveDraft']}
</Button>
<Button
type="outline"
loading={saving}
onClick={handleSaveArticle}
>
{t['cardArticles.action.publish']}
</Button>
</div>
}
>
<Grid.Row gutter={24}>
{/* 左侧表单编辑区 */}
<Grid.Col span={14}>
<Form
form={drawerForm}
layout="vertical"
onValuesChange={(_, allValues) => setFormValues(allValues)}
>
<Form.Item
label={t['cardArticles.field.cardId']}
field="cardId"
rules={[{ required: true }]}
>
<Input placeholder="101" />
</Form.Item>
<Form.Item
label={t['cardArticles.field.title']}
field="title"
rules={[{ required: true }]}
>
<Input placeholder="输入出海深度文章标题..." maxLength={120} showWordLimit />
</Form.Item>
<Form.Item label={t['cardArticles.field.summary']} field="summary">
<Input.TextArea
placeholder="文章摘要(留空将自动从正文剥离 HTML 提取前 160 字符)..."
rows={3}
maxLength={300}
showWordLimit
/>
</Form.Item>
<Form.Item label={t['cardArticles.field.coverImage']} field="coverImage">
<Input placeholder="https://cdn.omnicard.ai/articles/cover.jpg" />
</Form.Item>
<Form.Item
label={t['cardArticles.field.content']}
field="content"
rules={[{ required: true }]}
>
<Input.TextArea
placeholder="正文内容(支持 HTML 或 Markdown 格式)..."
rows={8}
/>
</Form.Item>
<Form.Item
label={t['cardArticles.field.status']}
field="isPublished"
triggerPropName="checked"
>
<Switch />
</Form.Item>
</Form>
</Grid.Col>
{/* 右侧 OpenGraph 社交图文卡实时预览区 */}
<Grid.Col span={10}>
<div
style={{
backgroundColor: lightColors.surface,
border: `1px solid ${lightColors.border}`,
borderRadius: '16px',
padding: '16px',
}}
>
<Typography.Title heading={6} style={{ margin: 0, color: lightColors.text }}>
{t['cardArticles.ogPreview.title']}
</Typography.Title>
<Typography.Text
style={{
display: 'block',
fontSize: '12px',
color: lightColors.text2,
marginTop: '4px',
marginBottom: '16px',
}}
>
{t['cardArticles.ogPreview.desc']}
</Typography.Text>
{/* 模拟出海社交平台卡片 (LinkedIn / WhatsApp / X Card) */}
<div
style={{
backgroundColor: lightColors.bg,
border: `1px solid ${lightColors.border}`,
borderRadius: '12px',
overflow: 'hidden',
boxShadow: '0 1px 2px rgba(16,24,40,0.04)',
}}
>
{/* 封面图预览 */}
<div
style={{
height: '140px',
backgroundColor: lightColors.primarySoft,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
{ogPreview.image ? (
<img
src={ogPreview.image}
alt="OpenGraph Cover"
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<Typography.Text style={{ color: lightColors.primary, fontSize: '12px' }}>
OG Image
</Typography.Text>
)}
</div>
{/* 社交文本预览 */}
<div style={{ padding: '12px' }}>
<div
style={{
fontSize: '11px',
textTransform: 'uppercase',
color: lightColors.text2,
letterSpacing: '0.5px',
}}
>
{t['cardArticles.ogPreview.domain']}
</div>
<div
style={{
fontSize: '14px',
fontWeight: 600,
color: lightColors.text,
marginTop: '4px',
lineHeight: 1.3,
}}
>
{ogPreview.title || t['cardArticles.ogPreview.emptyTitle']}
</div>
<div
style={{
fontSize: '12px',
color: lightColors.text2,
marginTop: '6px',
lineHeight: 1.4,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{ogPreview.description || t['cardArticles.ogPreview.emptyDesc']}
</div>
</div>
</div>
</div>
</Grid.Col>
</Grid.Row>
</Drawer>
</div>
);
}

查看文件

@@ -0,0 +1,82 @@
const i18n = {
'en-US': {
'menu.card.articles': 'Articles & Posts',
'cardArticles.title': 'Articles & Updates Hub',
'cardArticles.description': 'Publish blog articles and dynamic updates to your digital card portal with automated OpenGraph SEO extraction.',
'cardArticles.action.add': 'Create Article',
'cardArticles.action.edit': 'Edit',
'cardArticles.action.delete': 'Delete',
'cardArticles.action.deleteConfirm': 'Are you sure you want to delete this article?',
'cardArticles.action.refresh': 'Refresh',
'cardArticles.action.saveDraft': 'Save Draft',
'cardArticles.action.publish': 'Publish Now',
'cardArticles.status.published': 'Published',
'cardArticles.status.draft': 'Draft',
'cardArticles.field.id': 'ID',
'cardArticles.field.cardId': 'Card ID',
'cardArticles.field.title': 'Title',
'cardArticles.field.summary': 'Summary',
'cardArticles.field.content': 'Content',
'cardArticles.field.coverImage': 'Cover Image URL',
'cardArticles.field.status': 'Status',
'cardArticles.field.updateTime': 'Updated At',
'cardArticles.field.operations': 'Operations',
'cardArticles.filter.search': 'Search title or summary',
'cardArticles.filter.cardId': 'Filter by Card ID',
'cardArticles.filter.status': 'Publish Status',
'cardArticles.filter.all': 'All Statuses',
'cardArticles.drawer.createTitle': 'New Article / Post',
'cardArticles.drawer.editTitle': 'Edit Article / Post',
'cardArticles.ogPreview.title': 'OpenGraph Social Card Preview',
'cardArticles.ogPreview.desc': 'This is how your post appears when shared across LinkedIn, WhatsApp, X, and WeChat.',
'cardArticles.ogPreview.domain': 'omnicard.ai',
'cardArticles.ogPreview.emptyTitle': 'Post Title Preview',
'cardArticles.ogPreview.emptyDesc': 'Summary or first 160 characters will automatically appear here as description...',
'cardArticles.message.saveSuccess': 'Article saved successfully',
'cardArticles.message.deleteSuccess': 'Article deleted successfully',
'cardArticles.message.statusSuccess': 'Status updated successfully',
'cardArticles.message.requestFail': 'Operation failed, please try again',
'cardArticles.empty.text': 'No articles found. Click the button above to publish your first post.',
},
'zh-CN': {
'menu.card.articles': '文章与动态',
'cardArticles.title': '文章与动态发布中心',
'cardArticles.description': '为名片微官网发布深度博客与动态资讯,具备 OpenGraph 社交卡片自动提炼与出海 SEO 优化。',
'cardArticles.action.add': '发布新文章',
'cardArticles.action.edit': '编辑',
'cardArticles.action.delete': '删除',
'cardArticles.action.deleteConfirm': '确定要删除此文章吗?此操作无法撤销。',
'cardArticles.action.refresh': '刷新',
'cardArticles.action.saveDraft': '保存草稿',
'cardArticles.action.publish': '立即发布',
'cardArticles.status.published': '已发布',
'cardArticles.status.draft': '草稿',
'cardArticles.field.id': '编号',
'cardArticles.field.cardId': '名片编号',
'cardArticles.field.title': '文章标题',
'cardArticles.field.summary': '文章摘要',
'cardArticles.field.content': '正文内容',
'cardArticles.field.coverImage': '封面图片链接',
'cardArticles.field.status': '发布状态',
'cardArticles.field.updateTime': '更新时间',
'cardArticles.field.operations': '操作',
'cardArticles.filter.search': '搜索标题或摘要',
'cardArticles.filter.cardId': '按名片编号筛选',
'cardArticles.filter.status': '发布状态',
'cardArticles.filter.all': '全部状态',
'cardArticles.drawer.createTitle': '新建文章与动态',
'cardArticles.drawer.editTitle': '编辑文章与动态',
'cardArticles.ogPreview.title': 'OpenGraph 社交卡片实时预览',
'cardArticles.ogPreview.desc': '在 LinkedIn、WhatsApp、X (Twitter) 及微信转发时呈现的社交图文卡片效果。',
'cardArticles.ogPreview.domain': 'omnicard.ai',
'cardArticles.ogPreview.emptyTitle': '文章标题预览',
'cardArticles.ogPreview.emptyDesc': '摘要或正文前 160 字符将自动提炼为社交分享描述...',
'cardArticles.message.saveSuccess': '文章保存成功',
'cardArticles.message.deleteSuccess': '文章已成功删除',
'cardArticles.message.statusSuccess': '发布状态更新成功',
'cardArticles.message.requestFail': '请求失败,请稍后重试',
'cardArticles.empty.text': '暂无文章或动态,点击上方主按钮即可开始创建。',
},
};
export default i18n;

查看文件

@@ -29,6 +29,57 @@ export const routes: IRoute[] = [
},
],
},
{
name: 'menu.card',
key: 'card',
children: [
{ name: 'menu.card.list', key: 'card/list' },
{ name: 'menu.card.builder', key: 'card/builder' },
{ name: 'menu.card.cases', key: 'card/cases' },
{ name: 'menu.card.articles', key: 'card/articles' },
],
},
{
name: 'menu.crm',
key: 'crm',
children: [
{ name: 'menu.crm.leads', key: 'crm/leads' },
{ name: 'menu.crm.pipeline', key: 'crm/pipeline' },
{ name: 'menu.crm.radarLog', key: 'crm/radar-log' },
{ name: 'menu.crm.leadProfile', key: 'crm/lead-profile', ignore: true },
],
},
{
name: 'menu.nfc',
key: 'nfc',
children: [
{ name: 'menu.nfc.devices', key: 'nfc/devices' },
{ name: 'menu.nfc.batch', key: 'nfc/batch' },
],
},
{
name: 'menu.ai',
key: 'ai',
children: [
{ name: 'menu.ai.knowledge', key: 'ai/knowledge' },
],
},
{
name: 'menu.team',
key: 'team',
children: [
{ name: 'menu.team.brandKit', key: 'team/brand-kit' },
{ name: 'menu.team.handover', key: 'team/handover' },
],
},
{
name: 'menu.settingsBiz',
key: 'settings-biz',
children: [
{ name: 'menu.settings.tenant', key: 'settings/tenant' },
{ name: 'menu.settings.billing', key: 'settings/billing' },
],
},
{
name: 'menu.visualization',
key: 'visualization',

查看文件

@@ -2,7 +2,7 @@
* OmniCard AI (CRMmp) - 全平台跨端核心数据契约与 TypeScript 类型体系
*
* 遵循出海 SaaS 规范、Bento Grid 便当盒美学与 RFC 6350 国际标准。
* 供 后端服务 (Cool-Admin) 与 管理后台 (Arco Pro React) 共享镜像复用。
* 供 OmniCard 后端与管理端共享镜像复用。
* 严格类型安全,杜绝 any。
*/
@@ -43,6 +43,12 @@ export interface CardProfile {
whatsapp?: string;
/** LinkedIn 个人/企业主页链接 */
linkedin?: string;
/** Telegram 账号或链接 */
telegram?: string;
/** X (Twitter) 账号或链接 */
twitter?: string;
/** 微信号 */
wechat?: string;
/** 所在地时区 (如 'UTC+8', 'America/New_York') */
timezone?: string;
/** 姓名双语发音音频 URL (RFC 6350 语音扩展) */
@@ -226,6 +232,34 @@ export interface NFCRecord {
lastScannedAt?: string | Date | null;
}
/**
* NFC 场景路由规则(按时段 / 国家把触卡导向目标名片)
*/
export interface NFCSceneRule {
/** 规则主键 */
id: string | number;
/** 所属企业租户 ID */
tenantId: string | number;
/** 绑定设备;空则对租户下全部设备生效 */
deviceId?: string | number | null;
/** 规则名称 */
name: string;
/** 命中后的场景模式 */
sceneMode: NFCSceneMode;
/** 命中后导向的名片 ID */
targetCardId: string | number;
/** 是否启用 */
enabled: boolean;
/** 生效开始时刻 HH:mm */
startTime?: string | null;
/** 生效结束时刻 HH:mm */
endTime?: string | null;
/** 生效星期 0-6(周日=0) */
weekdays?: number[] | null;
/** 地理国家码 ISO 3166-1 alpha-2 */
countryCode?: string | null;
}
// ==========================================
// 4. 企业租户与品牌 VI 契约 (Tenant & BrandKit)
// ==========================================
@@ -261,6 +295,28 @@ export interface TenantProfile {
brandKit: BrandKit;
}
export interface HandoverRequest {
cardId: string | number;
fromUserId: string | number;
toUserId: string | number;
}
export type DomainSslStatus = 'pending' | 'active' | 'failed';
export interface TenantDomain {
id?: string | number;
customDomain: string;
sslStatus: DomainSslStatus;
}
export interface BillingQuota {
planType: TenantPlanType;
seatsUsed: number;
seatsLimit: number;
cardsUsed: number;
cardsLimit: number;
}
// ==========================================
// 5. 公开微官网聚合门户契约 (Card Portal Public Data)
// ==========================================
@@ -319,6 +375,12 @@ export interface CardPortalDetail {
whatsapp?: string;
/** LinkedIn 个人/企业主页链接 */
linkedin?: string;
/** Telegram 账号或链接 */
telegram?: string;
/** X (Twitter) 账号或链接 */
twitter?: string;
/** 微信号 */
wechat?: string;
/** 所在地时区 */
timezone?: string;
/** 姓名双语发音音频 URL (RFC 6350 语音扩展) */
@@ -335,3 +397,56 @@ export interface CardPortalDetail {
cachedAt?: number;
}
export interface AiKnowledgeDoc {
id?: string | number;
cardId: string | number;
title: string;
content: string;
locale?: string;
}
/**
* OpenGraph 社交元数据契约
*/
export interface ArticleOgMeta {
title: string;
description: string;
image: string;
}
/**
* 博客文章与动态契约
*/
export interface CardArticle {
id?: string | number;
cardId: string | number;
title: string;
summary?: string;
content: string;
coverImage?: string;
isPublished: boolean;
ogMeta?: ArticleOgMeta;
createTime?: Date | string;
updateTime?: Date | string;
}
/**
* 出海案例作品契约
*/
export interface CardCase {
id?: string | number;
tenantId?: string | number;
cardId: string | number;
title: string;
client?: string;
duration?: string;
metrics?: string;
coverImage?: string;
pdfUrl?: string;
description?: string;
sortOrder?: number;
createTime?: string | Date;
updateTime?: string | Date;
}

查看文件

@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import defaultI18n from '../src/locale/index.ts';
import { routes } from '../src/routes.ts';
const adminRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const articlesPagePath = path.join(adminRoot, 'src/pages/card/articles/index.tsx');
const articlesLocalePath = path.join(adminRoot, 'src/pages/card/articles/locale/index.ts');
test('1. card/articles 路由必须在 routes.ts 注册,且多语言包含 menu.card.articles', () => {
const cardGroup = routes.find((r) => r.key === 'card');
assert.ok(cardGroup, 'routes.ts 必须存在 card 顶级路由');
const articlesRoute = cardGroup.children?.find((c) => c.key === 'card/articles');
assert.ok(articlesRoute, 'routes.ts 中 card 分组下必须存在 card/articles 路由');
assert.equal(articlesRoute?.name, 'menu.card.articles');
assert.ok(
(defaultI18n['zh-CN'] as Record<string, string>)['menu.card.articles'],
'zh-CN 必须包含 menu.card.articles'
);
assert.ok(
(defaultI18n['en-US'] as Record<string, string>)['menu.card.articles'],
'en-US 必须包含 menu.card.articles'
);
});
test('2. card/articles 页面必须请求 article API,并严格使用 Arco Table/Drawer/Form/Input/Switch', () => {
assert.ok(fs.existsSync(articlesPagePath), '文章管理页面文件必须存在');
const content = fs.readFileSync(articlesPagePath, 'utf-8');
// API 路径断言
assert.ok(
content.includes('admin/card/article/page'),
'必须请求 POST /api/admin/card/article/page'
);
assert.ok(
content.includes('admin/card/article/add'),
'必须请求 POST /api/admin/card/article/add'
);
assert.ok(
content.includes('admin/card/article/update'),
'必须请求 POST /api/admin/card/article/update'
);
assert.ok(
content.includes('admin/card/article/delete'),
'必须请求 POST /api/admin/card/article/delete'
);
// Arco 组件断言
assert.ok(content.includes('Table'), '必须使用 Arco Table');
assert.ok(content.includes('Drawer'), '必须使用 Arco Drawer');
assert.ok(content.includes('Form'), '必须使用 Arco Form');
assert.ok(content.includes('Input'), '必须使用 Arco Input');
assert.ok(content.includes('Switch'), '必须使用 Arco Switch');
assert.ok(content.includes('Popconfirm'), '删除确认必须使用 Arco Popconfirm');
assert.ok(
content.includes("from '@arco-design/web-react'"),
'必须从 @arco-design/web-react 引入组件'
);
// 代码规范断言
assert.ok(content.includes('CardArticle'), '必须使用 CardArticle 类型');
assert.ok(
content.includes('code === 1000') || content.includes('code ===1000'),
'必须按 Cool-Admin code === 1000 判断成功'
);
assert.ok(content.includes('Message.error'), '失败必须使用 Arco Message.error');
assert.ok(!content.includes('Mock.js') && !content.includes("from 'mockjs'"), '禁止使用 Mock.js 假数据');
assert.ok(!/\bany\b/.test(content), '禁止使用 any');
assert.ok(!content.includes('<Pagination'), '禁止手写分页器,必须使用 Table 自带分页');
});
test('3. card/articles 必须遵循瑞士国际主义风格、严格 Token、单一主 CTA 与无 emoji', () => {
const content = fs.readFileSync(articlesPagePath, 'utf-8');
assert.ok(content.includes('lightColors'), '颜色必须引用 lightColors token');
assert.ok(!/#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})\b/.test(content), '禁止硬编码颜色 (#hex)');
// 单一主 CTA
const primaryButtonMatches = content.match(/type=["']primary["']/g) || [];
assert.equal(
primaryButtonMatches.length,
1,
'遵循瑞士国际主义风格:全屏仅允许 1 个 type="primary" 主 CTA'
);
// 禁止 emoji 正则测试
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
assert.ok(!emojiRegex.test(content), '禁止使用 emoji');
// 支持富文本/Markdown 草稿/发布编辑与 OpenGraph 社交预览卡
assert.ok(
content.includes('ogPreview') || content.includes('OpenGraph') || content.includes('ogMeta'),
'必须支持 OpenGraph 社交卡片预览联动'
);
assert.ok(content.includes('isPublished'), '必须支持发布状态草稿/上架控制');
});
test('4. card/articles 语言包必须包含中英文案与 OpenGraph 预览词条', () => {
assert.ok(fs.existsSync(articlesLocalePath), '语言包文件必须存在');
const content = fs.readFileSync(articlesLocalePath, 'utf-8');
assert.ok(content.includes("'en-US'"), '必须包含 en-US');
assert.ok(content.includes("'zh-CN'"), '必须包含 zh-CN');
assert.ok(content.includes('cardArticles.title'), '必须包含页面标题文案');
assert.ok(content.includes('cardArticles.action.add'), '必须包含新增文章主 CTA 文案');
assert.ok(content.includes('cardArticles.ogPreview.title'), '必须包含 OG 社交预览卡文案');
assert.ok(!/\bany\b/.test(content), '语言包禁止使用 any');
});