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

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

查看文件

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