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