feat(card): 实现后台博客文章与动态发布中心及OpenGraph自动提炼 (Point 02)
这个提交包含在:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
112
admin/tests/card-articles.test.ts
普通文件
112
admin/tests/card-articles.test.ts
普通文件
@@ -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');
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户