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

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

查看文件

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