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