From 991c0bb6b5900e13d51dd86dc5da4248d53fda53 Mon Sep 17 00:00:00 2001 From: wikiboss Date: Sun, 20 Sep 2026 22:40:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(card):=20=E5=AE=9E=E7=8E=B0=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E5=8D=9A=E5=AE=A2=E6=96=87=E7=AB=A0=E4=B8=8E=E5=8A=A8?= =?UTF-8?q?=E6=80=81=E5=8F=91=E5=B8=83=E4=B8=AD=E5=BF=83=E5=8F=8AOpenGraph?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=8F=90=E7=82=BC=20(Point=2002)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/locale/index.ts | 42 ++ admin/src/pages/card/articles/index.tsx | 657 ++++++++++++++++++ admin/src/pages/card/articles/locale/index.ts | 82 +++ admin/src/routes.ts | 51 ++ admin/src/types/omni.ts | 117 +++- admin/tests/card-articles.test.ts | 112 +++ backend/src/comm/types/omni.ts | 173 ++++- .../modules/card/controller/admin/article.ts | 26 + backend/src/modules/card/entity/article.ts | 66 ++ backend/src/modules/card/service/article.ts | 98 +++ backend/test/modules/card/article.test.ts | 213 ++++++ 11 files changed, 1635 insertions(+), 2 deletions(-) create mode 100644 admin/src/pages/card/articles/index.tsx create mode 100644 admin/src/pages/card/articles/locale/index.ts create mode 100644 admin/tests/card-articles.test.ts create mode 100644 backend/src/modules/card/controller/admin/article.ts create mode 100644 backend/src/modules/card/entity/article.ts create mode 100644 backend/src/modules/card/service/article.ts create mode 100644 backend/test/modules/card/article.test.ts diff --git a/admin/src/locale/index.ts b/admin/src/locale/index.ts index a61d4f8..456c8ce 100644 --- a/admin/src/locale/index.ts +++ b/admin/src/locale/index.ts @@ -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': '异常页', diff --git a/admin/src/pages/card/articles/index.tsx b/admin/src/pages/card/articles/index.tsx new file mode 100644 index 0000000..33d206f --- /dev/null +++ b/admin/src/pages/card/articles/index.tsx @@ -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 { + 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(/]+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(); + const [drawerForm] = Form.useForm(); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [articles, setArticles] = useState([]); + const [pagination, setPagination] = useState({ + current: DEFAULT_PAGE, + pageSize: DEFAULT_SIZE, + total: 0, + showTotal: true, + }); + + const [drawerVisible, setDrawerVisible] = useState(false); + const [currentArticleId, setCurrentArticleId] = useState(null); + + // 表单联动 OpenGraph 实时预览状态 + const [formValues, setFormValues] = useState({ + 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 = { + 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>(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(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(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(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[] = [ + { + 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) => ( +
+ + {title} + + {record.summary && ( +
+ {record.summary} +
+ )} +
+ ), + }, + { + title: t['cardArticles.field.status'], + dataIndex: 'isPublished', + width: 130, + render: (isPublished: boolean, record: CardArticle) => ( + + handleTogglePublish(record, checked)} + /> + + {isPublished ? t['cardArticles.status.published'] : t['cardArticles.status.draft']} + + + ), + }, + { + 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) => ( + + + handleDelete(record)} + > + + + + ), + }, + ]; + + return ( +
+ {/* 头部导航与操作栏 */} + +
+
+ + {t['cardArticles.title']} + + + {t['cardArticles.description']} + +
+ {/* 遵循瑞士国际主义风格:整屏唯一主 CTA 按钮 */} + +
+ + {/* 筛选表单 */} +
loadData(1, pagination.pageSize)} + > + + loadData(1, pagination.pageSize)} + /> + + + + + + + +
+
+ + {/* 数据表格 */} + + + rowKey="id" + loading={loading} + columns={columns} + data={articles} + pagination={{ + ...pagination, + onChange: (current, pageSize) => loadData(current, pageSize), + }} + noDataElement={ +
+ {t['cardArticles.empty.text']} +
+ } + /> +
+ + {/* 文章编辑与 OpenGraph 社交预览抽屉 */} + setDrawerVisible(false)} + footer={ +
+ + +
+ } + > + + {/* 左侧表单编辑区 */} + +
setFormValues(allValues)} + > + + + + + + + + + + + + + + + + + + +
+
+ + {/* 右侧 OpenGraph 社交图文卡实时预览区 */} + +
+ + {t['cardArticles.ogPreview.title']} + + + {t['cardArticles.ogPreview.desc']} + + + {/* 模拟出海社交平台卡片 (LinkedIn / WhatsApp / X Card) */} +
+ {/* 封面图预览 */} +
+ {ogPreview.image ? ( + OpenGraph Cover + ) : ( + + OG Image + + )} +
+ + {/* 社交文本预览 */} +
+
+ {t['cardArticles.ogPreview.domain']} +
+
+ {ogPreview.title || t['cardArticles.ogPreview.emptyTitle']} +
+
+ {ogPreview.description || t['cardArticles.ogPreview.emptyDesc']} +
+
+
+
+
+
+
+
+ ); +} diff --git a/admin/src/pages/card/articles/locale/index.ts b/admin/src/pages/card/articles/locale/index.ts new file mode 100644 index 0000000..5b5501e --- /dev/null +++ b/admin/src/pages/card/articles/locale/index.ts @@ -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; diff --git a/admin/src/routes.ts b/admin/src/routes.ts index 9f97b36..fbef1ad 100644 --- a/admin/src/routes.ts +++ b/admin/src/routes.ts @@ -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', diff --git a/admin/src/types/omni.ts b/admin/src/types/omni.ts index 9c7d77e..7854ad4 100644 --- a/admin/src/types/omni.ts +++ b/admin/src/types/omni.ts @@ -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; +} + + diff --git a/admin/tests/card-articles.test.ts b/admin/tests/card-articles.test.ts new file mode 100644 index 0000000..7ef47ed --- /dev/null +++ b/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)['menu.card.articles'], + 'zh-CN 必须包含 menu.card.articles' + ); + assert.ok( + (defaultI18n['en-US'] as Record)['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(' { + 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'); +}); diff --git a/backend/src/comm/types/omni.ts b/backend/src/comm/types/omni.ts index f4b5c6d..3beb2e2 100644 --- a/backend/src/comm/types/omni.ts +++ b/backend/src/comm/types/omni.ts @@ -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; +} + diff --git a/backend/src/modules/card/controller/admin/article.ts b/backend/src/modules/card/controller/admin/article.ts new file mode 100644 index 0000000..7ff40e7 --- /dev/null +++ b/backend/src/modules/card/controller/admin/article.ts @@ -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 {} diff --git a/backend/src/modules/card/entity/article.ts b/backend/src/modules/card/entity/article.ts new file mode 100644 index 0000000..49acf68 --- /dev/null +++ b/backend/src/modules/card/entity/article.ts @@ -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; +} diff --git a/backend/src/modules/card/service/article.ts b/backend/src/modules/card/service/article.ts new file mode 100644 index 0000000..855828c --- /dev/null +++ b/backend/src/modules/card/service/article.ts @@ -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; + + @Init() + async init() { + await super.init(); + this.setEntity(this.cardArticleEntity); + } + + /** + * 自动提炼 OpenGraph 社交元数据 + */ + generateOgMeta(article: Partial): 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 + const htmlImgMatch = article.content.match(/]+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 { + 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 { + 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, type: 'add' | 'update'): Promise { + if (type === 'add' || type === 'update') { + if (!data.ogMeta || !data.ogMeta.title || !data.ogMeta.description) { + data.ogMeta = this.generateOgMeta(data); + } + } + } +} diff --git a/backend/test/modules/card/article.test.ts b/backend/test/modules/card/article.test.ts new file mode 100644 index 0000000..fceb149 --- /dev/null +++ b/backend/test/modules/card/article.test.ts @@ -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: '

正文内容...

', + 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 = ` +
+

一级标题

+

在全球数字化浪潮席卷而来的今天,智能名片已经不仅是身份凭证,更是出海企业的第一道数字门面与线索转化漏斗。通过多语言自适应与 NFC 瞬时触达,销售代表可以在海外展会现场以极低摩擦完成商机留资与精准雷达跟进,大幅提升商机转化效率与客户体验。

+
+ `; + 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('

'); + expect(og.description).not.toContain(''); + expect(og.description).toContain('在全球数字化浪潮席卷而来的今天'); + }); + + it('缺失 coverImage 时,应自动从 content 中提取首张图片 URL', () => { + const contentWithImages = ` +

+

前言段落

+ Hero +

次要段落

+ +
+ `; + 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 = { + title: '动态资讯', + content: '

精彩资讯动态内容发布

', + }; + + await service.modifyBefore(data, 'add'); + expect(data.ogMeta).toBeDefined(); + expect(data.ogMeta?.title).toBe('动态资讯'); + expect(data.ogMeta?.description).toBe('精彩资讯动态内容发布'); + expect(data.ogMeta?.image).toBe(''); + }); + }); +});