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

查看文件

@@ -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 语音扩展) */
@@ -232,6 +238,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)
// ==========================================
@@ -267,6 +301,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)
// ==========================================
@@ -325,6 +381,12 @@ export interface CardPortalDetail {
whatsapp?: string;
/** LinkedIn 个人/企业主页链接 */
linkedin?: string;
/** Telegram 账号或链接 */
telegram?: string;
/** X (Twitter) 账号或链接 */
twitter?: string;
/** 微信号 */
wechat?: string;
/** 所在地时区 */
timezone?: string;
/** 姓名双语发音音频 URL (RFC 6350 语音扩展) */
@@ -339,4 +401,113 @@ export interface CardPortalDetail {
brandKit?: BrandKit;
/** 缓存时间戳 (毫秒),供客户端首屏秒开与协商缓存 */
cachedAt?: number;
/** Geo / 浏览器语言自动建议(不进入名片内容缓存) */
suggestedLocale?: string;
}
// ==========================================
// 5. AI 顾问对话契约 (AI Concierge)
// ==========================================
export type AiChatRole = 'user' | 'assistant' | 'system';
export interface AiChatMessage {
role: AiChatRole;
content: string;
}
export interface AiChatRequest {
cardId: string | number;
locale?: string;
messages: AiChatMessage[];
}
export interface AiChatReply {
reply: string;
locale: string;
shouldCollectLead: boolean;
}
export interface AiKnowledgeDoc {
id?: string | number;
cardId: string | number;
title: string;
content: string;
locale?: string;
}
export type LocalizeTone = 'professional' | 'direct' | 'storytelling';
export interface LocalizeRequest {
text: string;
targetLocale: string;
tone?: LocalizeTone;
}
export interface LocalizeReply {
text: string;
locale: string;
tone: LocalizeTone;
}
export type CopilotChannel = 'email' | 'whatsapp';
export interface CopilotRequest {
leadId?: string | number;
cardId?: string | number;
name?: string;
locale?: string;
channel?: CopilotChannel;
}
export interface CopilotReply {
script: string;
diagnosis: string;
locale: string;
channel: CopilotChannel;
}
/**
* 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,26 @@
import { CoolController, BaseController } from '@cool-midway/core';
import { CardArticleEntity } from '../../entity/article';
import { CardArticleService } from '../../service/article';
/**
* 后台文章标准 CRUD 配置
*/
export const adminCardArticleCurdOption = {
api: ['add', 'delete', 'update', 'info', 'list', 'page'] as Array<
'add' | 'delete' | 'update' | 'info' | 'list' | 'page'
>,
entity: CardArticleEntity,
service: CardArticleService,
pageQueryOp: {
keyWordLikeFields: ['a.title', 'a.summary'],
fieldEq: ['a.cardId', 'a.isPublished'],
addOrderBy: { updateTime: 'desc' as const },
},
};
/**
* 名片模块-后台文章与动态发布中心
* 路由前缀:/admin/card/article
*/
@CoolController(adminCardArticleCurdOption)
export class AdminCardArticleController extends BaseController {}

查看文件

@@ -0,0 +1,66 @@
import { BaseEntity, transformerJson } from '../../../comm/entity/base';
import { Column, Entity, Index } from 'typeorm';
import type { ArticleOgMeta, CardArticle } from '../../../comm/types/omni';
/**
* 名片博客文章与动态实体
*/
@Entity('card_article')
export class CardArticleEntity extends BaseEntity implements CardArticle {
@Index()
@Column({
name: 'card_id',
comment: '所属名片 ID',
type: 'bigint',
})
cardId: number;
@Column({
name: 'title',
comment: '文章标题',
length: 255,
})
title: string;
@Column({
name: 'summary',
comment: '文章摘要/简介',
type: 'text',
nullable: true,
})
summary?: string;
@Column({
name: 'content',
comment: '文章正文 (富文本或 Markdown)',
type: 'longtext',
nullable: true,
})
content: string;
@Column({
name: 'cover_image',
comment: '封面图片 URL',
length: 500,
nullable: true,
})
coverImage?: string;
@Index()
@Column({
name: 'is_published',
comment: '发布状态 (0: 未发布/草稿, 1: 已发布)',
type: 'boolean',
default: false,
})
isPublished: boolean;
@Column({
name: 'og_meta',
comment: 'OpenGraph 社交元数据 (title, description, image)',
type: 'json',
nullable: true,
transformer: transformerJson,
})
ogMeta?: ArticleOgMeta;
}

查看文件

@@ -0,0 +1,98 @@
import { Init, Provide } from '@midwayjs/core';
import { BaseService } from '@cool-midway/core';
import { InjectEntityModel } from '@midwayjs/typeorm';
import { Repository } from 'typeorm';
import { CardArticleEntity } from '../entity/article';
import type { ArticleOgMeta, CardArticle } from '../../../comm/types/omni';
/**
* 博客文章与动态服务
*/
@Provide()
export class CardArticleService extends BaseService {
@InjectEntityModel(CardArticleEntity)
cardArticleEntity: Repository<CardArticleEntity>;
@Init()
async init() {
await super.init();
this.setEntity(this.cardArticleEntity);
}
/**
* 自动提炼 OpenGraph 社交元数据
*/
generateOgMeta(article: Partial<CardArticle>): ArticleOgMeta {
const title = (article.title || '').trim();
// 1. 摘要处理:若未提供 summary,剥离正文 HTML 与 Markdown 语法,截取前 160 字符
let description = (article.summary || '').trim();
if (!description && article.content) {
const plainText = article.content
.replace(/<[^>]+>/g, '') // 剥离 HTML 标签
.replace(/!\[.*?\]\(.*?\)/g, '') // 移除 Markdown 图片语法
.replace(/\[(.*?)\]\(.*?\)/g, '$1') // 提取 Markdown 链接文本
.replace(/[#*`_~>-]/g, '') // 移除 Markdown 常用标记
.replace(/\s+/g, ' ') // 折叠多余空白
.trim();
description = plainText.slice(0, 160);
}
// 2. 封面图处理:若未提供 coverImage,尝试从正文中提取首张图片
let image = (article.coverImage || '').trim();
if (!image && article.content) {
// 匹配 HTML <img ... src="..." />
const htmlImgMatch = article.content.match(/<img[^>]+src=["']([^"']+)["']/i);
if (htmlImgMatch && htmlImgMatch[1]) {
image = htmlImgMatch[1].trim();
} else {
// 匹配 Markdown ![alt](url)
const mdImgMatch = article.content.match(/!\[.*?\]\((https?:\/\/[^\s)]+|\/[^\s)]+)\)/i);
if (mdImgMatch && mdImgMatch[1]) {
image = mdImgMatch[1].trim();
}
}
}
return {
title,
description,
image,
};
}
/**
* 发布上架文章
*/
async publish(id: number | string): Promise<CardArticleEntity> {
const article = await this.cardArticleEntity.findOneBy({ id: Number(id) } as any);
if (!article) {
throw new Error(`Article with id ${id} not found`);
}
article.isPublished = true;
return await this.cardArticleEntity.save(article);
}
/**
* 下架文章
*/
async unpublish(id: number | string): Promise<CardArticleEntity> {
const article = await this.cardArticleEntity.findOneBy({ id: Number(id) } as any);
if (!article) {
throw new Error(`Article with id ${id} not found`);
}
article.isPublished = false;
return await this.cardArticleEntity.save(article);
}
/**
* 保存前生命周期:自动提炼并补全 ogMeta
*/
async modifyBefore(data: Partial<CardArticleEntity>, type: 'add' | 'update'): Promise<void> {
if (type === 'add' || type === 'update') {
if (!data.ogMeta || !data.ogMeta.title || !data.ogMeta.description) {
data.ogMeta = this.generateOgMeta(data);
}
}
}
}

查看文件

@@ -0,0 +1,213 @@
import * as fs from 'fs';
import * as path from 'path';
import { BaseController } from '@cool-midway/core';
import { CardArticleEntity } from '../../../src/modules/card/entity/article';
import { CardArticleService } from '../../../src/modules/card/service/article';
import {
AdminCardArticleController,
adminCardArticleCurdOption,
} from '../../../src/modules/card/controller/admin/article';
describe('后台博客文章与动态发布中心 (Card Article CoolController & Service)', () => {
const controllerFile = path.join(
__dirname,
'../../../src/modules/card/controller/admin/article.ts'
);
const serviceFile = path.join(
__dirname,
'../../../src/modules/card/service/article.ts'
);
const entityFile = path.join(
__dirname,
'../../../src/modules/card/entity/article.ts'
);
describe('1. 路由与 CoolController CRUD 契约', () => {
it('文件路径应符合 /admin/card/article 自动路由与架构约定', () => {
expect(fs.existsSync(controllerFile)).toBe(true);
expect(fs.existsSync(serviceFile)).toBe(true);
expect(fs.existsSync(entityFile)).toBe(true);
expect(controllerFile.replace(/\\/g, '/')).toMatch(
/modules\/card\/controller\/admin\/article\.ts$/
);
});
it('控制器应继承 BaseController 并正确配置 entity 与 service', () => {
expect(AdminCardArticleController.prototype).toBeInstanceOf(BaseController);
expect(adminCardArticleCurdOption.entity).toBe(CardArticleEntity);
expect(adminCardArticleCurdOption.service).toBe(CardArticleService);
});
it('装饰器 api 必须完整包含 add/delete/update/info/list/page', () => {
expect(adminCardArticleCurdOption.api).toEqual([
'add',
'delete',
'update',
'info',
'list',
'page',
]);
});
it('分页查询 pageQueryOp 应支持标题搜索与发布状态筛选', () => {
const pageQueryOp = adminCardArticleCurdOption.pageQueryOp;
expect(pageQueryOp.keyWordLikeFields).toEqual(
expect.arrayContaining(['a.title', 'a.summary'])
);
expect(pageQueryOp.fieldEq).toEqual(
expect.arrayContaining(['a.cardId', 'a.isPublished'])
);
expect(pageQueryOp.addOrderBy).toEqual({ updateTime: 'desc' });
});
});
describe('2. OpenGraph 社交元数据自动提炼 (generateOgMeta)', () => {
let service: CardArticleService;
beforeEach(() => {
service = new CardArticleService();
});
it('完整文章入参时,应正确映射 title、summary 与 coverImage', () => {
const article = {
cardId: 101,
title: '出海战略新视界:2026 跨境数字名片白皮书',
summary: '本文深度剖析全球化商务场景下 NFC 与微官网结合的数字化获客新路径。',
content: '<p>正文内容...</p>',
coverImage: 'https://cdn.omnicard.ai/articles/cover-1.jpg',
isPublished: true,
};
const og = service.generateOgMeta(article);
expect(og.title).toBe(article.title);
expect(og.description).toBe(article.summary);
expect(og.image).toBe(article.coverImage);
});
it('缺失 summary 时,应自动从 content 提取剥离 HTML 标签的前 160 字符', () => {
const longHtml = `
<article>
<h1>一级标题</h1>
<p>在全球数字化浪潮席卷而来的今天,<strong>智能名片</strong>已经不仅是身份凭证,更是出海企业的第一道数字门面与线索转化漏斗。通过多语言自适应与 NFC 瞬时触达,销售代表可以在海外展会现场以极低摩擦完成商机留资与精准雷达跟进,大幅提升商机转化效率与客户体验。</p>
</article>
`;
const article = {
cardId: 101,
title: '出海营销指南',
content: longHtml,
isPublished: true,
};
const og = service.generateOgMeta(article);
expect(og.title).toBe('出海营销指南');
expect(og.description.length).toBeLessThanOrEqual(160);
expect(og.description).not.toContain('<p>');
expect(og.description).not.toContain('<strong>');
expect(og.description).toContain('在全球数字化浪潮席卷而来的今天');
});
it('缺失 coverImage 时,应自动从 content 中提取首张图片 URL', () => {
const contentWithImages = `
<div>
<p>前言段落</p>
<img src="https://cdn.omnicard.ai/upload/hero-img.png" alt="Hero" />
<p>次要段落</p>
<img src="https://cdn.omnicard.ai/upload/second.png" />
</div>
`;
const article = {
cardId: 102,
title: '图文资讯',
content: contentWithImages,
isPublished: false,
};
const og = service.generateOgMeta(article);
expect(og.image).toBe('https://cdn.omnicard.ai/upload/hero-img.png');
});
it('支持提取 Markdown 格式的图片语法 ![alt](url)', () => {
const mdContent = `
# 标题
这里是段落
![架构图](https://cdn.omnicard.ai/upload/architecture.webp)
`;
const article = {
cardId: 103,
title: 'Markdown 文章',
content: mdContent,
isPublished: true,
};
const og = service.generateOgMeta(article);
expect(og.image).toBe('https://cdn.omnicard.ai/upload/architecture.webp');
});
it('面对空对象或未定义字段时应安全回退,不抛出异常', () => {
const og = service.generateOgMeta({});
expect(og.title).toBe('');
expect(og.description).toBe('');
expect(og.image).toBe('');
});
});
describe('3. 文章发布、下架与保存前生命周期 (publish / unpublish / modifyBefore)', () => {
let service: CardArticleService;
let mockRepo: {
findOneBy: jest.Mock;
save: jest.Mock;
};
beforeEach(() => {
service = new CardArticleService();
mockRepo = {
findOneBy: jest.fn(),
save: jest.fn().mockImplementation(async entity => entity),
};
service.cardArticleEntity = mockRepo as any;
});
it('publish 应将 isPublished 置为 true 并保存', async () => {
const existing = {
id: 1,
title: '草稿文章',
isPublished: false,
} as CardArticleEntity;
mockRepo.findOneBy.mockResolvedValue(existing);
const result = await service.publish(1);
expect(mockRepo.findOneBy).toHaveBeenCalledWith({ id: 1 });
expect(existing.isPublished).toBe(true);
expect(mockRepo.save).toHaveBeenCalledWith(existing);
expect(result.isPublished).toBe(true);
});
it('unpublish 应将 isPublished 置为 false 并保存', async () => {
const existing = {
id: 2,
title: '已发布文章',
isPublished: true,
} as CardArticleEntity;
mockRepo.findOneBy.mockResolvedValue(existing);
const result = await service.unpublish(2);
expect(mockRepo.findOneBy).toHaveBeenCalledWith({ id: 2 });
expect(existing.isPublished).toBe(false);
expect(mockRepo.save).toHaveBeenCalledWith(existing);
expect(result.isPublished).toBe(false);
});
it('modifyBefore 应在保存时自动生成或补全 ogMeta', async () => {
const data: Partial<CardArticleEntity> = {
title: '动态资讯',
content: '<p>精彩资讯动态内容发布</p>',
};
await service.modifyBefore(data, 'add');
expect(data.ogMeta).toBeDefined();
expect(data.ogMeta?.title).toBe('动态资讯');
expect(data.ogMeta?.description).toBe('精彩资讯动态内容发布');
expect(data.ogMeta?.image).toBe('');
});
});
});