Compare commits
No commits in common. "1a008ea860e9d62022810897a65bf61dfffa3aa7" and "7409d2329b72f3076e6b5af21b308129ad43ff41" have entirely different histories.
1a008ea860
...
7409d2329b
1
Jenkinsfile
vendored
1
Jenkinsfile
vendored
@ -33,7 +33,6 @@ def requiredRuntimeEnvKeys() {
|
|||||||
'DB_PASSWORD',
|
'DB_PASSWORD',
|
||||||
'DB_DATABASE',
|
'DB_DATABASE',
|
||||||
'ADMIN_TOKEN_SECRET',
|
'ADMIN_TOKEN_SECRET',
|
||||||
'WORDPRESS_BASE_URL',
|
|
||||||
'FFLOGS_CLIENT_ID',
|
'FFLOGS_CLIENT_ID',
|
||||||
'FFLOGS_CLIENT_SECRET',
|
'FFLOGS_CLIENT_SECRET',
|
||||||
]
|
]
|
||||||
|
|||||||
@ -7,7 +7,7 @@ metadata:
|
|||||||
app: kt-template-online-api
|
app: kt-template-online-api
|
||||||
spec:
|
spec:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
revisionHistoryLimit: 3
|
revisionHistoryLimit: 5
|
||||||
strategy:
|
strategy:
|
||||||
type: RollingUpdate
|
type: RollingUpdate
|
||||||
rollingUpdate:
|
rollingUpdate:
|
||||||
|
|||||||
10
package.json
10
package.json
@ -45,19 +45,9 @@
|
|||||||
"pino-http": "^11.0.0",
|
"pino-http": "^11.0.0",
|
||||||
"pino-loki": "^3.0.0",
|
"pino-loki": "^3.0.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rehype-parse": "^9.0.1",
|
|
||||||
"rehype-raw": "^7.0.0",
|
|
||||||
"rehype-remark": "^10.0.1",
|
|
||||||
"rehype-sanitize": "^6.0.0",
|
|
||||||
"rehype-stringify": "^10.0.1",
|
|
||||||
"remark-gfm": "^4.0.1",
|
|
||||||
"remark-parse": "^11.0.0",
|
|
||||||
"remark-rehype": "^11.1.2",
|
|
||||||
"remark-stringify": "^11.0.0",
|
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"svg-captcha": "^1.4.0",
|
"svg-captcha": "^1.4.0",
|
||||||
"typeorm": "^0.3.28",
|
"typeorm": "^0.3.28",
|
||||||
"unified": "^11.0.5",
|
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
997
pnpm-lock.yaml
997
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -1,58 +0,0 @@
|
|||||||
-- Blog 初始化 SQL
|
|
||||||
-- 用途:补齐本地 Markdown 博客文章、分类和标签表结构。
|
|
||||||
-- 说明:生产环境建议关闭 DB_SYNC 后导入本文件;本文件不会清空已有数据。
|
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
|
||||||
SET FOREIGN_KEY_CHECKS = 0;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `blog_article` (
|
|
||||||
`id` bigint NOT NULL,
|
|
||||||
`title` varchar(255) NOT NULL,
|
|
||||||
`slug` varchar(255) NOT NULL DEFAULT '',
|
|
||||||
`status` varchar(32) NOT NULL DEFAULT 'draft',
|
|
||||||
`excerpt` text DEFAULT NULL,
|
|
||||||
`content_markdown` mediumtext DEFAULT NULL,
|
|
||||||
`content_html` mediumtext DEFAULT NULL,
|
|
||||||
`cover` text DEFAULT NULL,
|
|
||||||
`author_name` varchar(255) NOT NULL DEFAULT 'KwiTsukasa',
|
|
||||||
`category_items` text DEFAULT NULL,
|
|
||||||
`tag_items` text DEFAULT NULL,
|
|
||||||
`views` int NOT NULL DEFAULT 0,
|
|
||||||
`comments` int NOT NULL DEFAULT 0,
|
|
||||||
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
|
|
||||||
`publish_time` datetime DEFAULT NULL,
|
|
||||||
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
|
||||||
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_blog_article_slug` (`slug`),
|
|
||||||
KEY `idx_blog_article_status` (`status`),
|
|
||||||
KEY `idx_blog_article_publish_time` (`publish_time`),
|
|
||||||
KEY `idx_blog_article_is_deleted` (`is_deleted`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `blog_term` (
|
|
||||||
`id` bigint NOT NULL,
|
|
||||||
`kind` varchar(32) NOT NULL,
|
|
||||||
`name` varchar(255) NOT NULL,
|
|
||||||
`slug` varchar(255) NOT NULL DEFAULT '',
|
|
||||||
`description` text DEFAULT NULL,
|
|
||||||
`parent_id` varchar(64) DEFAULT NULL,
|
|
||||||
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
|
|
||||||
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
|
||||||
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_blog_term_kind_slug` (`kind`, `slug`),
|
|
||||||
KEY `idx_blog_term_parent_id` (`parent_id`),
|
|
||||||
KEY `idx_blog_term_is_deleted` (`is_deleted`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS `blog_theme_config` (
|
|
||||||
`id` varchar(64) NOT NULL,
|
|
||||||
`config` longtext NOT NULL,
|
|
||||||
`source` varchar(255) NOT NULL DEFAULT 'local',
|
|
||||||
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
|
||||||
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
|
||||||
PRIMARY KEY (`id`)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
SET FOREIGN_KEY_CHECKS = 1;
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
-- 增量补齐本地博客管理菜单。
|
|
||||||
-- 用途:已有库不需要重跑完整 vben-admin-init.sql 时,可单独执行本文件。
|
|
||||||
|
|
||||||
SET NAMES utf8mb4;
|
|
||||||
|
|
||||||
INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`)
|
|
||||||
VALUES
|
|
||||||
(2041700000000100300, 0, 'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog', '{"icon":"lucide:newspaper","order":100,"title":"博客管理"}', 1, 100),
|
|
||||||
(2041700000000100301, 2041700000000100300, 'BlogArticle', '/blog/article', '/blog/article/list', NULL, 'Blog:Article:List', 'menu', '{"icon":"lucide:file-text","title":"文章管理"}', 1, 0),
|
|
||||||
(2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0),
|
|
||||||
(2041700000000120302, 2041700000000100301, 'BlogArticleEdit', NULL, NULL, NULL, 'Blog:Article:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
|
||||||
(2041700000000120303, 2041700000000100301, 'BlogArticleDelete', NULL, NULL, NULL, 'Blog:Article:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
|
||||||
(2041700000000120304, 2041700000000100301, 'BlogArticleImport', NULL, NULL, NULL, 'Blog:Article:Import', 'button', '{"title":"导入 WordPress"}', 1, 1),
|
|
||||||
(2041700000000100302, 2041700000000100300, 'BlogCategory', '/blog/category', '/blog/category/list', NULL, 'Blog:Category:List', 'menu', '{"icon":"lucide:folder-tree","title":"分类管理"}', 1, 1),
|
|
||||||
(2041700000000120311, 2041700000000100302, 'BlogCategoryCreate', NULL, NULL, NULL, 'Blog:Category:Create', 'button', '{"title":"common.create"}', 1, 0),
|
|
||||||
(2041700000000120312, 2041700000000100302, 'BlogCategoryEdit', NULL, NULL, NULL, 'Blog:Category:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
|
||||||
(2041700000000120313, 2041700000000100302, 'BlogCategoryDelete', NULL, NULL, NULL, 'Blog:Category:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
|
||||||
(2041700000000100303, 2041700000000100300, 'BlogTag', '/blog/tag', '/blog/tag/list', NULL, 'Blog:Tag:List', 'menu', '{"icon":"lucide:tags","title":"标签管理"}', 1, 2),
|
|
||||||
(2041700000000120321, 2041700000000100303, 'BlogTagCreate', NULL, NULL, NULL, 'Blog:Tag:Create', 'button', '{"title":"common.create"}', 1, 0),
|
|
||||||
(2041700000000120322, 2041700000000100303, 'BlogTagEdit', NULL, NULL, NULL, 'Blog:Tag:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
|
||||||
(2041700000000120323, 2041700000000100303, 'BlogTagDelete', NULL, NULL, NULL, 'Blog:Tag:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
|
||||||
(2041700000000100304, 2041700000000100300, 'BlogTheme', '/blog/theme', '/blog/theme/config', NULL, 'Blog:Theme:List', 'menu', '{"icon":"lucide:palette","title":"主题配置"}', 1, 3),
|
|
||||||
(2041700000000120331, 2041700000000100304, 'BlogThemeSave', NULL, NULL, NULL, 'Blog:Theme:Save', 'button', '{"title":"保存配置"}', 1, 0),
|
|
||||||
(2041700000000120332, 2041700000000100304, 'BlogThemeImport', NULL, NULL, NULL, 'Blog:Theme:Import', 'button', '{"title":"导入 WordPress"}', 1, 1)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
`pid` = VALUES(`pid`),
|
|
||||||
`path` = VALUES(`path`),
|
|
||||||
`component` = VALUES(`component`),
|
|
||||||
`redirect` = VALUES(`redirect`),
|
|
||||||
`auth_code` = VALUES(`auth_code`),
|
|
||||||
`type` = VALUES(`type`),
|
|
||||||
`meta` = VALUES(`meta`),
|
|
||||||
`status` = VALUES(`status`),
|
|
||||||
`sort` = VALUES(`sort`),
|
|
||||||
`is_deleted` = 0;
|
|
||||||
|
|
||||||
INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`)
|
|
||||||
SELECT role.`id`, menu.`id`
|
|
||||||
FROM `admin_role` role
|
|
||||||
JOIN `admin_menu` menu ON menu.`name` LIKE 'Blog%'
|
|
||||||
WHERE role.`role_code` IN ('super', 'admin')
|
|
||||||
AND role.`is_deleted` = 0
|
|
||||||
AND menu.`is_deleted` = 0;
|
|
||||||
@ -189,7 +189,6 @@ VALUES
|
|||||||
(2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0),
|
(2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||||
(2041700000000120302, 2041700000000100301, 'BlogArticleEdit', NULL, NULL, NULL, 'Blog:Article:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
(2041700000000120302, 2041700000000100301, 'BlogArticleEdit', NULL, NULL, NULL, 'Blog:Article:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||||
(2041700000000120303, 2041700000000100301, 'BlogArticleDelete', NULL, NULL, NULL, 'Blog:Article:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
(2041700000000120303, 2041700000000100301, 'BlogArticleDelete', NULL, NULL, NULL, 'Blog:Article:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
||||||
(2041700000000120304, 2041700000000100301, 'BlogArticleImport', NULL, NULL, NULL, 'Blog:Article:Import', 'button', '{"title":"导入 WordPress"}', 1, 1),
|
|
||||||
(2041700000000100302, 2041700000000100300, 'BlogCategory', '/blog/category', '/blog/category/list', NULL, 'Blog:Category:List', 'menu', '{"icon":"lucide:folder-tree","title":"分类管理"}', 1, 1),
|
(2041700000000100302, 2041700000000100300, 'BlogCategory', '/blog/category', '/blog/category/list', NULL, 'Blog:Category:List', 'menu', '{"icon":"lucide:folder-tree","title":"分类管理"}', 1, 1),
|
||||||
(2041700000000120311, 2041700000000100302, 'BlogCategoryCreate', NULL, NULL, NULL, 'Blog:Category:Create', 'button', '{"title":"common.create"}', 1, 0),
|
(2041700000000120311, 2041700000000100302, 'BlogCategoryCreate', NULL, NULL, NULL, 'Blog:Category:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||||
(2041700000000120312, 2041700000000100302, 'BlogCategoryEdit', NULL, NULL, NULL, 'Blog:Category:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
(2041700000000120312, 2041700000000100302, 'BlogCategoryEdit', NULL, NULL, NULL, 'Blog:Category:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||||
@ -198,9 +197,6 @@ VALUES
|
|||||||
(2041700000000120321, 2041700000000100303, 'BlogTagCreate', NULL, NULL, NULL, 'Blog:Tag:Create', 'button', '{"title":"common.create"}', 1, 0),
|
(2041700000000120321, 2041700000000100303, 'BlogTagCreate', NULL, NULL, NULL, 'Blog:Tag:Create', 'button', '{"title":"common.create"}', 1, 0),
|
||||||
(2041700000000120322, 2041700000000100303, 'BlogTagEdit', NULL, NULL, NULL, 'Blog:Tag:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
(2041700000000120322, 2041700000000100303, 'BlogTagEdit', NULL, NULL, NULL, 'Blog:Tag:Edit', 'button', '{"title":"common.edit"}', 1, 0),
|
||||||
(2041700000000120323, 2041700000000100303, 'BlogTagDelete', NULL, NULL, NULL, 'Blog:Tag:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
(2041700000000120323, 2041700000000100303, 'BlogTagDelete', NULL, NULL, NULL, 'Blog:Tag:Delete', 'button', '{"title":"common.delete"}', 1, 0),
|
||||||
(2041700000000100304, 2041700000000100300, 'BlogTheme', '/blog/theme', '/blog/theme/config', NULL, 'Blog:Theme:List', 'menu', '{"icon":"lucide:palette","title":"主题配置"}', 1, 3),
|
|
||||||
(2041700000000120331, 2041700000000100304, 'BlogThemeSave', NULL, NULL, NULL, 'Blog:Theme:Save', 'button', '{"title":"保存配置"}', 1, 0),
|
|
||||||
(2041700000000120332, 2041700000000100304, 'BlogThemeImport', NULL, NULL, NULL, 'Blog:Theme:Import', 'button', '{"title":"导入 WordPress"}', 1, 1),
|
|
||||||
(2041700000000100009, 0, 'Project', '/vben-admin', NULL, NULL, NULL, 'catalog', '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}', 1, 9998),
|
(2041700000000100009, 0, 'Project', '/vben-admin', NULL, NULL, NULL, 'catalog', '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}', 1, 9998),
|
||||||
(2041700000000100901, 2041700000000100009, 'VbenDocument', '/vben-admin/document', 'IFrameView', NULL, NULL, 'embedded', '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}', 1, 0),
|
(2041700000000100901, 2041700000000100009, 'VbenDocument', '/vben-admin/document', 'IFrameView', NULL, NULL, 'embedded', '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}', 1, 0),
|
||||||
(2041700000000100902, 2041700000000100009, 'VbenGithub', '/vben-admin/github', 'IFrameView', NULL, NULL, 'link', '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}', 1, 0),
|
(2041700000000100902, 2041700000000100009, 'VbenGithub', '/vben-admin/github', 'IFrameView', NULL, NULL, 'link', '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}', 1, 0),
|
||||||
|
|||||||
@ -15,7 +15,6 @@ import {
|
|||||||
SaveBodyInterceptor,
|
SaveBodyInterceptor,
|
||||||
} from './common';
|
} from './common';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { BlogModule } from './blog/blog.module';
|
|
||||||
import { WordpressModule } from './wordpress/wordpress.module';
|
import { WordpressModule } from './wordpress/wordpress.module';
|
||||||
import { QqbotModule } from './qqbot/qqbot.module';
|
import { QqbotModule } from './qqbot/qqbot.module';
|
||||||
|
|
||||||
@ -65,7 +64,6 @@ import { QqbotModule } from './qqbot/qqbot.module';
|
|||||||
MinioClientModule,
|
MinioClientModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
BlogModule,
|
|
||||||
WordpressModule,
|
WordpressModule,
|
||||||
QqbotModule,
|
QqbotModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,135 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
Res,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
|
||||||
import { Public, vbenSuccess } from '@/common';
|
|
||||||
import {
|
|
||||||
BlogArticleBodyDto,
|
|
||||||
BlogArticleImportWordpressDto,
|
|
||||||
BlogArticleListQueryDto,
|
|
||||||
BlogArticleTermOptionsQueryDto,
|
|
||||||
BlogArticleUpdateBodyDto,
|
|
||||||
} from './blog-article.dto';
|
|
||||||
import { BlogArticleService } from './blog-article.service';
|
|
||||||
|
|
||||||
@ApiTags('Blog - 文章')
|
|
||||||
@Controller('blog/article')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class BlogArticleController {
|
|
||||||
constructor(private readonly blogArticleService: BlogArticleService) {}
|
|
||||||
|
|
||||||
@Get('public/list')
|
|
||||||
@Public()
|
|
||||||
@ApiOperation({ summary: '获取公开博客文章分页列表' })
|
|
||||||
async publicList(@Res() res, @Query() query: BlogArticleListQueryDto) {
|
|
||||||
const list = await this.blogArticleService.publicList(query);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(list));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('public/detail')
|
|
||||||
@Public()
|
|
||||||
@ApiOperation({ summary: '获取公开博客文章详情' })
|
|
||||||
@ApiQuery({ name: 'slug', required: false, type: String })
|
|
||||||
@ApiQuery({ name: 'id', required: false, type: String })
|
|
||||||
async publicDetail(
|
|
||||||
@Res() res,
|
|
||||||
@Query('slug') slug?: string,
|
|
||||||
@Query('id') id?: string,
|
|
||||||
) {
|
|
||||||
const detail = await this.blogArticleService.publicDetail({
|
|
||||||
id,
|
|
||||||
slug,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(detail));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('list')
|
|
||||||
@ApiOperation({ summary: '获取博客文章分页列表' })
|
|
||||||
async list(@Res() res, @Query() query: BlogArticleListQueryDto) {
|
|
||||||
const list = await this.blogArticleService.page(query);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(list));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('detail')
|
|
||||||
@ApiOperation({ summary: '获取博客文章详情' })
|
|
||||||
@ApiQuery({ name: 'id', type: String })
|
|
||||||
async detail(@Res() res, @Query('id') id: string) {
|
|
||||||
const detail = await this.blogArticleService.detail(id);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(detail));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('save')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '新增博客文章' })
|
|
||||||
async save(@Res() res, @Body() body: BlogArticleBodyDto) {
|
|
||||||
const result = await this.blogArticleService.save(body);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('update')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '编辑博客文章' })
|
|
||||||
async update(@Res() res, @Body() body: BlogArticleUpdateBodyDto) {
|
|
||||||
const result = await this.blogArticleService.update(body);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('remove')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '删除博客文章' })
|
|
||||||
@ApiQuery({ name: 'id', type: String })
|
|
||||||
async remove(@Res() res, @Query('id') id: string) {
|
|
||||||
const result = await this.blogArticleService.remove(id);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('category-options')
|
|
||||||
@ApiOperation({ summary: '获取本地博客文章分类选项' })
|
|
||||||
async categoryOptions(
|
|
||||||
@Res() res,
|
|
||||||
@Query() query: BlogArticleTermOptionsQueryDto,
|
|
||||||
) {
|
|
||||||
const result = await this.blogArticleService.categoryOptions(query);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('tag-options')
|
|
||||||
@ApiOperation({ summary: '获取本地博客文章标签选项' })
|
|
||||||
async tagOptions(
|
|
||||||
@Res() res,
|
|
||||||
@Query() query: BlogArticleTermOptionsQueryDto,
|
|
||||||
) {
|
|
||||||
const result = await this.blogArticleService.tagOptions(query);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('import-wordpress')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '从 WordPress 导入文章到本地博客' })
|
|
||||||
async importWordpress(
|
|
||||||
@Res() res,
|
|
||||||
@Body() body: BlogArticleImportWordpressDto,
|
|
||||||
) {
|
|
||||||
const result = await this.blogArticleService.importFromWordpress(body);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import type { BlogArticleStatus, BlogArticleTerm } from './blog-article.entity';
|
|
||||||
|
|
||||||
export class BlogArticleListQueryDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
default: 1,
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
pageNo?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
default: 10,
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
pageSize?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
search?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
enum: ['draft', 'pending', 'private', 'publish'],
|
|
||||||
})
|
|
||||||
status?: BlogArticleStatus | 'any';
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '分类 slug、名称或逗号分隔值',
|
|
||||||
})
|
|
||||||
categories?: string | string[];
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '标签 slug、名称或逗号分隔值',
|
|
||||||
})
|
|
||||||
tags?: string | string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BlogArticleBodyDto {
|
|
||||||
@ApiProperty()
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
slug?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
enum: ['draft', 'pending', 'private', 'publish'],
|
|
||||||
})
|
|
||||||
status?: BlogArticleStatus;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
excerpt?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
content?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
enum: ['html', 'markdown'],
|
|
||||||
})
|
|
||||||
contentFormat?: 'html' | 'markdown';
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
cover?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
authorName?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
categories?: Array<BlogArticleTerm | string>;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
tags?: Array<BlogArticleTerm | string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BlogArticleUpdateBodyDto extends BlogArticleBodyDto {
|
|
||||||
@ApiProperty()
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BlogArticleImportWordpressDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
default: true,
|
|
||||||
description: '是否按 WordPress total 分页导入全部文章',
|
|
||||||
})
|
|
||||||
all?: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
default: 1,
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
pageNo?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
default: 100,
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
pageSize?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
default: false,
|
|
||||||
description: '已存在同 slug 文章时是否覆盖',
|
|
||||||
})
|
|
||||||
overwrite?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BlogArticleTermOptionsQueryDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
default: 1,
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
pageNo?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
default: 200,
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
pageSize?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '分类或标签关键词',
|
|
||||||
})
|
|
||||||
search?: string;
|
|
||||||
}
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
import {
|
|
||||||
BeforeInsert,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
PrimaryColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
|
||||||
|
|
||||||
export type BlogArticleStatus = 'draft' | 'pending' | 'private' | 'publish';
|
|
||||||
|
|
||||||
export type BlogArticleTerm = {
|
|
||||||
count?: number;
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Entity('blog_article')
|
|
||||||
export class BlogArticle {
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@PrimaryColumn({
|
|
||||||
type: 'bigint',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
@Column()
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
default: '',
|
|
||||||
})
|
|
||||||
slug: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
enum: ['draft', 'pending', 'private', 'publish'],
|
|
||||||
})
|
|
||||||
@Column({
|
|
||||||
default: 'draft',
|
|
||||||
})
|
|
||||||
status: BlogArticleStatus;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
type: 'text',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
excerpt: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
name: 'content_markdown',
|
|
||||||
type: 'mediumtext',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
contentMarkdown: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
name: 'content_html',
|
|
||||||
type: 'mediumtext',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
contentHtml: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
type: 'text',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
cover: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
name: 'author_name',
|
|
||||||
default: 'KwiTsukasa',
|
|
||||||
})
|
|
||||||
authorName: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
name: 'category_items',
|
|
||||||
type: 'simple-json',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
categoryItems: BlogArticleTerm[];
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
name: 'tag_items',
|
|
||||||
type: 'simple-json',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
tagItems: BlogArticleTerm[];
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
default: 0,
|
|
||||||
})
|
|
||||||
views: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
default: 0,
|
|
||||||
})
|
|
||||||
comments: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
default: false,
|
|
||||||
name: 'is_deleted',
|
|
||||||
})
|
|
||||||
isDeleted: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
name: 'publish_time',
|
|
||||||
nullable: true,
|
|
||||||
type: 'datetime',
|
|
||||||
})
|
|
||||||
@FormatDateTime()
|
|
||||||
publishTime: Date;
|
|
||||||
|
|
||||||
@CreateDateColumn({
|
|
||||||
name: 'create_time',
|
|
||||||
})
|
|
||||||
@FormatDateTime()
|
|
||||||
createTime: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({
|
|
||||||
name: 'update_time',
|
|
||||||
})
|
|
||||||
@FormatDateTime()
|
|
||||||
updateTime: Date;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
categories?: string[];
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
tags?: string[];
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
categoriesResolved?: BlogArticleTerm[];
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
tagsResolved?: BlogArticleTerm[];
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
excerptText?: string;
|
|
||||||
|
|
||||||
@BeforeInsert()
|
|
||||||
createId() {
|
|
||||||
ensureSnowflakeId(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,450 +0,0 @@
|
|||||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Brackets, Repository } from 'typeorm';
|
|
||||||
import { MarkdownService, throwVbenError, ToolsService } from '@/common';
|
|
||||||
import {
|
|
||||||
BlogArticle,
|
|
||||||
type BlogArticleStatus,
|
|
||||||
type BlogArticleTerm,
|
|
||||||
} from './blog-article.entity';
|
|
||||||
import type {
|
|
||||||
BlogArticleBodyDto,
|
|
||||||
BlogArticleImportWordpressDto,
|
|
||||||
BlogArticleListQueryDto,
|
|
||||||
BlogArticleTermOptionsQueryDto,
|
|
||||||
BlogArticleUpdateBodyDto,
|
|
||||||
} from './blog-article.dto';
|
|
||||||
import { WordpressService } from '@/wordpress/wordpress.service';
|
|
||||||
import { BlogTermService } from './blog-term.service';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BlogArticleService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(BlogArticle)
|
|
||||||
private readonly articleRepository: Repository<BlogArticle>,
|
|
||||||
private readonly markdownService: MarkdownService,
|
|
||||||
private readonly toolsService: ToolsService,
|
|
||||||
private readonly wordpressService: WordpressService,
|
|
||||||
private readonly blogTermService: BlogTermService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async page(query: BlogArticleListQueryDto) {
|
|
||||||
return this.queryPage(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
async publicList(query: BlogArticleListQueryDto) {
|
|
||||||
return this.queryPage({
|
|
||||||
...query,
|
|
||||||
status: 'publish',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async detail(id: string | number) {
|
|
||||||
const article = await this.articleRepository.findOne({
|
|
||||||
where: {
|
|
||||||
id: `${id}`,
|
|
||||||
isDeleted: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
throwVbenError('文章不存在', HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.toResponse(article);
|
|
||||||
}
|
|
||||||
|
|
||||||
async publicDetail(query: { id?: string; slug?: string }) {
|
|
||||||
const article = await this.articleRepository.findOne({
|
|
||||||
where: query.id
|
|
||||||
? {
|
|
||||||
id: `${query.id}`,
|
|
||||||
isDeleted: false,
|
|
||||||
status: 'publish',
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
isDeleted: false,
|
|
||||||
slug: this.normalizeSlug(query.slug || ''),
|
|
||||||
status: 'publish',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
throwVbenError('文章不存在或未发布', HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.toResponse(article);
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(body: BlogArticleBodyDto) {
|
|
||||||
const articleEntity = await this.getArticleEntity(body);
|
|
||||||
await this.syncArticleTerms(articleEntity);
|
|
||||||
|
|
||||||
const article = this.articleRepository.create(articleEntity);
|
|
||||||
const saved = await this.articleRepository.save(article);
|
|
||||||
|
|
||||||
return this.toResponse(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(body: BlogArticleUpdateBodyDto) {
|
|
||||||
const article = await this.articleRepository.findOne({
|
|
||||||
where: {
|
|
||||||
id: `${body.id}`,
|
|
||||||
isDeleted: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
throwVbenError('文章不存在', HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
const articleEntity = await this.getArticleEntity(body, article);
|
|
||||||
await this.syncArticleTerms(articleEntity);
|
|
||||||
|
|
||||||
Object.assign(article, articleEntity);
|
|
||||||
const saved = await this.articleRepository.save(article);
|
|
||||||
|
|
||||||
return this.toResponse(saved);
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(id: string | number) {
|
|
||||||
const result = await this.articleRepository.update(
|
|
||||||
{
|
|
||||||
id: `${id}`,
|
|
||||||
isDeleted: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isDeleted: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (result.affected || 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async categoryOptions(query: BlogArticleTermOptionsQueryDto = {}) {
|
|
||||||
return this.blogTermService.options('category', query);
|
|
||||||
}
|
|
||||||
|
|
||||||
async tagOptions(query: BlogArticleTermOptionsQueryDto = {}) {
|
|
||||||
return this.blogTermService.options('tag', query);
|
|
||||||
}
|
|
||||||
|
|
||||||
async importFromWordpress(query: BlogArticleImportWordpressDto = {}) {
|
|
||||||
const pageNo = this.toolsService.toPositiveNumber(query.pageNo, 1);
|
|
||||||
const pageSize = Math.min(
|
|
||||||
this.toolsService.toPositiveNumber(query.pageSize, 100),
|
|
||||||
100,
|
|
||||||
);
|
|
||||||
const importAll = this.toolsService.normalizeBoolean(query.all, true);
|
|
||||||
const overwrite = this.toolsService.normalizeBoolean(query.overwrite);
|
|
||||||
const firstWordpressPage = await this.wordpressService.publicArticleList({
|
|
||||||
pageNo,
|
|
||||||
pageSize,
|
|
||||||
});
|
|
||||||
const result = {
|
|
||||||
created: 0,
|
|
||||||
items: [] as Array<{
|
|
||||||
action: 'created' | 'skipped' | 'updated';
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
}>,
|
|
||||||
pageCount: 0,
|
|
||||||
skipped: 0,
|
|
||||||
total: firstWordpressPage.total || firstWordpressPage.list?.length || 0,
|
|
||||||
updated: 0,
|
|
||||||
};
|
|
||||||
const wordpressPages = [firstWordpressPage];
|
|
||||||
|
|
||||||
if (importAll && firstWordpressPage.total > pageSize) {
|
|
||||||
const totalPages = Math.ceil(firstWordpressPage.total / pageSize);
|
|
||||||
|
|
||||||
for (
|
|
||||||
let currentPageNo = pageNo + 1;
|
|
||||||
currentPageNo <= totalPages;
|
|
||||||
currentPageNo += 1
|
|
||||||
) {
|
|
||||||
wordpressPages.push(
|
|
||||||
await this.wordpressService.publicArticleList({
|
|
||||||
pageNo: currentPageNo,
|
|
||||||
pageSize,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const wordpressPage of wordpressPages) {
|
|
||||||
result.pageCount += 1;
|
|
||||||
|
|
||||||
for (const source of wordpressPage.list as Array<Record<string, any>>) {
|
|
||||||
const detail = (await this.wordpressService.publicArticleDetail({
|
|
||||||
id: source.id,
|
|
||||||
slug: source.slug,
|
|
||||||
})) as Record<string, any>;
|
|
||||||
const importEntity = await this.getWordpressImportEntity(detail);
|
|
||||||
await this.syncArticleTerms(importEntity);
|
|
||||||
const existing = await this.articleRepository.findOne({
|
|
||||||
where: {
|
|
||||||
isDeleted: false,
|
|
||||||
slug: importEntity.slug,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing && !overwrite) {
|
|
||||||
result.skipped += 1;
|
|
||||||
result.items.push({
|
|
||||||
action: 'skipped',
|
|
||||||
id: existing.id,
|
|
||||||
slug: existing.slug,
|
|
||||||
title: existing.title,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const saved = await this.articleRepository.save(
|
|
||||||
existing
|
|
||||||
? Object.assign(existing, importEntity)
|
|
||||||
: this.articleRepository.create(importEntity),
|
|
||||||
);
|
|
||||||
const action = existing ? 'updated' : 'created';
|
|
||||||
|
|
||||||
result[action] += 1;
|
|
||||||
result.items.push({
|
|
||||||
action,
|
|
||||||
id: saved.id,
|
|
||||||
slug: saved.slug,
|
|
||||||
title: saved.title,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async queryPage(query: BlogArticleListQueryDto) {
|
|
||||||
const { pageSize, skip } = this.toolsService.getPageParams(query);
|
|
||||||
const builder = this.articleRepository
|
|
||||||
.createQueryBuilder('article')
|
|
||||||
.where('article.isDeleted = :isDeleted', { isDeleted: false });
|
|
||||||
const status = query.status || 'any';
|
|
||||||
const categoryKeywords = this.normalizeQueryList(query.categories);
|
|
||||||
const tagKeywords = this.normalizeQueryList(query.tags);
|
|
||||||
|
|
||||||
if (status !== 'any') {
|
|
||||||
builder.andWhere('article.status = :status', { status });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query.search) {
|
|
||||||
builder.andWhere(
|
|
||||||
new Brackets((qb) => {
|
|
||||||
qb.where('article.title LIKE :search', {
|
|
||||||
search: `%${query.search}%`,
|
|
||||||
})
|
|
||||||
.orWhere('article.excerpt LIKE :search', {
|
|
||||||
search: `%${query.search}%`,
|
|
||||||
})
|
|
||||||
.orWhere('article.contentMarkdown LIKE :search', {
|
|
||||||
search: `%${query.search}%`,
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
categoryKeywords.forEach((keyword, index) => {
|
|
||||||
builder.andWhere(`article.categoryItems LIKE :category${index}`, {
|
|
||||||
[`category${index}`]: `%${keyword}%`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
tagKeywords.forEach((keyword, index) => {
|
|
||||||
builder.andWhere(`article.tagItems LIKE :tag${index}`, {
|
|
||||||
[`tag${index}`]: `%${keyword}%`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const [list, total] = await builder
|
|
||||||
.orderBy('article.publishTime', 'DESC')
|
|
||||||
.addOrderBy('article.updateTime', 'DESC')
|
|
||||||
.skip(skip)
|
|
||||||
.take(pageSize)
|
|
||||||
.getManyAndCount();
|
|
||||||
|
|
||||||
return this.toolsService.page(list.map((item) => this.toResponse(item)), total);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getArticleEntity(
|
|
||||||
body: BlogArticleBodyDto,
|
|
||||||
current?: BlogArticle,
|
|
||||||
): Promise<Partial<BlogArticle>> {
|
|
||||||
const contentFormat = body.contentFormat || 'markdown';
|
|
||||||
const nextArticle: Partial<BlogArticle> = this.toolsService.pickDefined({
|
|
||||||
authorName: body.authorName,
|
|
||||||
cover: body.cover,
|
|
||||||
excerpt: body.excerpt,
|
|
||||||
slug: this.normalizeSlug(body.slug || body.title || current?.slug || ''),
|
|
||||||
status: body.status || current?.status || ('draft' as BlogArticleStatus),
|
|
||||||
title: body.title,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (body.categories) {
|
|
||||||
nextArticle.categoryItems = this.normalizeTerms(body.categories);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.tags) {
|
|
||||||
nextArticle.tagItems = this.normalizeTerms(body.tags);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.content !== undefined) {
|
|
||||||
if (contentFormat === 'markdown') {
|
|
||||||
nextArticle.contentMarkdown = body.content;
|
|
||||||
nextArticle.contentHtml = await this.markdownService.renderToHtml(
|
|
||||||
body.content,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
nextArticle.contentMarkdown = current?.contentMarkdown || '';
|
|
||||||
nextArticle.contentHtml = await this.markdownService.renderToHtml(
|
|
||||||
body.content,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
nextArticle.status === 'publish' &&
|
|
||||||
!current?.publishTime &&
|
|
||||||
!nextArticle.publishTime
|
|
||||||
) {
|
|
||||||
nextArticle.publishTime = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextArticle;
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeTerms(values: Array<BlogArticleTerm | string>) {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
return values
|
|
||||||
.map((item) => {
|
|
||||||
const name =
|
|
||||||
typeof item === 'string'
|
|
||||||
? this.toolsService.toTrimmedString(item)
|
|
||||||
: this.toolsService.toTrimmedString(item.name);
|
|
||||||
const slug =
|
|
||||||
typeof item === 'string'
|
|
||||||
? this.normalizeSlug(item)
|
|
||||||
: this.normalizeSlug(item.slug || item.name);
|
|
||||||
|
|
||||||
return this.toolsService.pickDefined({
|
|
||||||
id: typeof item === 'string' ? undefined : item.id,
|
|
||||||
name,
|
|
||||||
slug,
|
|
||||||
}) as BlogArticleTerm;
|
|
||||||
})
|
|
||||||
.filter((item) => {
|
|
||||||
if (!item.name || seen.has(item.slug)) return false;
|
|
||||||
seen.add(item.slug);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getWordpressImportEntity(source: Record<string, any>) {
|
|
||||||
const rawContentHtml = source.contentHtml || source.content?.rendered || '';
|
|
||||||
const contentHtml = await this.markdownService.sanitizeHtml(rawContentHtml);
|
|
||||||
const contentMarkdown =
|
|
||||||
source.contentMarkdown ||
|
|
||||||
this.markdownService.extractSource(source.content?.raw) ||
|
|
||||||
(await this.markdownService.renderHtmlToMarkdown(rawContentHtml));
|
|
||||||
const status = (source.status === 'publish'
|
|
||||||
? 'publish'
|
|
||||||
: 'draft') as BlogArticleStatus;
|
|
||||||
|
|
||||||
return this.toolsService.pickDefined({
|
|
||||||
authorName: source.authorName,
|
|
||||||
categoryItems: this.normalizeTerms(
|
|
||||||
(source.categoriesResolved || []).map((item) => ({
|
|
||||||
id: item.id ? `${item.id}` : undefined,
|
|
||||||
name: item.name,
|
|
||||||
slug: item.slug || item.name,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
comments: Number(source.comment_count || 0),
|
|
||||||
contentHtml,
|
|
||||||
contentMarkdown,
|
|
||||||
cover: source.cover,
|
|
||||||
excerpt:
|
|
||||||
source.excerptText ||
|
|
||||||
this.stripHtml(source.excerpt?.rendered || source.excerpt || ''),
|
|
||||||
publishTime: this.parseDate(source.date || source.modified),
|
|
||||||
slug: this.normalizeSlug(source.slug || source.title?.rendered || ''),
|
|
||||||
status,
|
|
||||||
tagItems: this.normalizeTerms(
|
|
||||||
(source.tagsResolved || []).map((item) => ({
|
|
||||||
id: item.id ? `${item.id}` : undefined,
|
|
||||||
name: item.name,
|
|
||||||
slug: item.slug || item.name,
|
|
||||||
})),
|
|
||||||
),
|
|
||||||
title: this.stripHtml(source.title?.rendered || source.title || ''),
|
|
||||||
views: Number(source.views || 0),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeQueryList(value?: string | string[]) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value
|
|
||||||
.flatMap((item) => item.split(','))
|
|
||||||
.map((item) => this.normalizeSlug(item))
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value) return [];
|
|
||||||
|
|
||||||
return value
|
|
||||||
.split(',')
|
|
||||||
.map((item) => this.normalizeSlug(item))
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async syncArticleTerms(article: Partial<BlogArticle>) {
|
|
||||||
if (article.categoryItems) {
|
|
||||||
await this.blogTermService.syncTerms('category', article.categoryItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (article.tagItems) {
|
|
||||||
await this.blogTermService.syncTerms('tag', article.tagItems);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private toResponse(article: BlogArticle) {
|
|
||||||
const categoriesResolved = article.categoryItems || [];
|
|
||||||
const tagsResolved = article.tagItems || [];
|
|
||||||
const excerptText =
|
|
||||||
article.excerpt ||
|
|
||||||
this.stripHtml(article.contentHtml || '').slice(0, 120);
|
|
||||||
|
|
||||||
return Object.assign(article, {
|
|
||||||
categories: categoriesResolved.map((item) => item.name),
|
|
||||||
categoriesResolved,
|
|
||||||
excerptText,
|
|
||||||
tags: tagsResolved.map((item) => item.name),
|
|
||||||
tagsResolved,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeSlug(value: string) {
|
|
||||||
return this.toolsService.normalizeSlugText(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stripHtml(value: string) {
|
|
||||||
return value
|
|
||||||
.replace(/<!--[\s\S]*?-->/g, '')
|
|
||||||
.replace(/<[^>]*>/g, ' ')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseDate(value: unknown) {
|
|
||||||
if (!value) return null;
|
|
||||||
const date = new Date(`${value}`);
|
|
||||||
|
|
||||||
return Number.isNaN(date.getTime()) ? null : date;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,130 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Post,
|
|
||||||
Query,
|
|
||||||
Res,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
|
||||||
import { vbenSuccess } from '@/common';
|
|
||||||
import type { BlogTermKind } from './blog-term.entity';
|
|
||||||
import {
|
|
||||||
BlogTermBodyDto,
|
|
||||||
BlogTermListQueryDto,
|
|
||||||
BlogTermUpdateBodyDto,
|
|
||||||
} from './blog-term.dto';
|
|
||||||
import { BlogTermService } from './blog-term.service';
|
|
||||||
|
|
||||||
@ApiTags('Blog - 分类标签')
|
|
||||||
@Controller('blog')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class BlogTermController {
|
|
||||||
constructor(private readonly blogTermService: BlogTermService) {}
|
|
||||||
|
|
||||||
@Get('category/list')
|
|
||||||
@ApiOperation({ summary: '获取本地博客分类分页列表' })
|
|
||||||
async categoryList(@Res() res, @Query() query: BlogTermListQueryDto) {
|
|
||||||
const list = await this.blogTermService.page('category', query);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(list));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('category/detail')
|
|
||||||
@ApiOperation({ summary: '获取本地博客分类详情' })
|
|
||||||
@ApiQuery({ name: 'id', type: String })
|
|
||||||
async categoryDetail(@Res() res, @Query('id') id: string) {
|
|
||||||
const detail = await this.blogTermService.detail('category', id);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(detail));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('category/save')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '新增本地博客分类' })
|
|
||||||
async categorySave(@Res() res, @Body() body: BlogTermBodyDto) {
|
|
||||||
const result = await this.blogTermService.save('category', body);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('category/update')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '编辑本地博客分类' })
|
|
||||||
async categoryUpdate(@Res() res, @Body() body: BlogTermUpdateBodyDto) {
|
|
||||||
const result = await this.blogTermService.update('category', body);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('category/remove')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '删除本地博客分类' })
|
|
||||||
@ApiQuery({ name: 'id', type: String })
|
|
||||||
async categoryRemove(@Res() res, @Query('id') id: string) {
|
|
||||||
const result = await this.blogTermService.remove('category', id);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('tag/list')
|
|
||||||
@ApiOperation({ summary: '获取本地博客标签分页列表' })
|
|
||||||
async tagList(@Res() res, @Query() query: BlogTermListQueryDto) {
|
|
||||||
const list = await this.blogTermService.page('tag', query);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(list));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('tag/detail')
|
|
||||||
@ApiOperation({ summary: '获取本地博客标签详情' })
|
|
||||||
@ApiQuery({ name: 'id', type: String })
|
|
||||||
async tagDetail(@Res() res, @Query('id') id: string) {
|
|
||||||
const detail = await this.blogTermService.detail('tag', id);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(detail));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('tag/save')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '新增本地博客标签' })
|
|
||||||
async tagSave(@Res() res, @Body() body: BlogTermBodyDto) {
|
|
||||||
const result = await this.blogTermService.save('tag', body);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('tag/update')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '编辑本地博客标签' })
|
|
||||||
async tagUpdate(@Res() res, @Body() body: BlogTermUpdateBodyDto) {
|
|
||||||
const result = await this.blogTermService.update('tag', body);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('tag/remove')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '删除本地博客标签' })
|
|
||||||
@ApiQuery({ name: 'id', type: String })
|
|
||||||
async tagRemove(@Res() res, @Query('id') id: string) {
|
|
||||||
const result = await this.blogTermService.remove('tag', id);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('term/options')
|
|
||||||
@ApiOperation({ summary: '获取本地博客分类或标签选项' })
|
|
||||||
async options(
|
|
||||||
@Res() res,
|
|
||||||
@Query('kind') kind: BlogTermKind,
|
|
||||||
@Query() query: BlogTermListQueryDto,
|
|
||||||
) {
|
|
||||||
const result = await this.blogTermService.options(kind, query);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
|
|
||||||
export class BlogTermListQueryDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
default: 1,
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
pageNo?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
default: 10,
|
|
||||||
type: Number,
|
|
||||||
})
|
|
||||||
pageSize?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '分类或标签关键词',
|
|
||||||
})
|
|
||||||
search?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '是否隐藏没有关联文章的分类或标签',
|
|
||||||
})
|
|
||||||
hide_empty?: boolean;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '父级分类 ID,仅分类模块使用',
|
|
||||||
})
|
|
||||||
parent?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BlogTermBodyDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: '名称',
|
|
||||||
})
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '别名',
|
|
||||||
})
|
|
||||||
slug?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '描述',
|
|
||||||
})
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '父级分类 ID,仅分类模块使用',
|
|
||||||
})
|
|
||||||
parent?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BlogTermUpdateBodyDto extends BlogTermBodyDto {
|
|
||||||
@ApiProperty({
|
|
||||||
description: '本地分类或标签 ID',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
import {
|
|
||||||
BeforeInsert,
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
PrimaryColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { ensureSnowflakeId, FormatDateTime } from '@/common';
|
|
||||||
|
|
||||||
export type BlogTermKind = 'category' | 'tag';
|
|
||||||
|
|
||||||
@Entity('blog_term')
|
|
||||||
export class BlogTerm {
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@PrimaryColumn({
|
|
||||||
type: 'bigint',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
enum: ['category', 'tag'],
|
|
||||||
})
|
|
||||||
@Column()
|
|
||||||
kind: BlogTermKind;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
@Column()
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
default: '',
|
|
||||||
})
|
|
||||||
slug: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
type: 'text',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
name: 'parent_id',
|
|
||||||
nullable: true,
|
|
||||||
})
|
|
||||||
parentId: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
default: false,
|
|
||||||
name: 'is_deleted',
|
|
||||||
})
|
|
||||||
isDeleted: boolean;
|
|
||||||
|
|
||||||
@CreateDateColumn({
|
|
||||||
name: 'create_time',
|
|
||||||
})
|
|
||||||
@FormatDateTime()
|
|
||||||
createTime: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({
|
|
||||||
name: 'update_time',
|
|
||||||
})
|
|
||||||
@FormatDateTime()
|
|
||||||
updateTime: Date;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
count?: number;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
parent?: string;
|
|
||||||
|
|
||||||
@BeforeInsert()
|
|
||||||
createId() {
|
|
||||||
ensureSnowflakeId(this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,302 +0,0 @@
|
|||||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { throwVbenError, ToolsService } from '@/common';
|
|
||||||
import {
|
|
||||||
BlogArticle,
|
|
||||||
type BlogArticleTerm,
|
|
||||||
} from './blog-article.entity';
|
|
||||||
import { BlogTerm, type BlogTermKind } from './blog-term.entity';
|
|
||||||
import type {
|
|
||||||
BlogTermBodyDto,
|
|
||||||
BlogTermListQueryDto,
|
|
||||||
BlogTermUpdateBodyDto,
|
|
||||||
} from './blog-term.dto';
|
|
||||||
|
|
||||||
type CountedBlogTerm = BlogArticleTerm & {
|
|
||||||
count: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BlogTermService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(BlogTerm)
|
|
||||||
private readonly termRepository: Repository<BlogTerm>,
|
|
||||||
@InjectRepository(BlogArticle)
|
|
||||||
private readonly articleRepository: Repository<BlogArticle>,
|
|
||||||
private readonly toolsService: ToolsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async page(kind: BlogTermKind, query: BlogTermListQueryDto = {}) {
|
|
||||||
const { pageSize, skip } = this.toolsService.getPageParams(query);
|
|
||||||
const countMap = await this.collectArticleTermMap(kind);
|
|
||||||
const list = this.filterAndSortTerms(
|
|
||||||
(await this.getStoredTerms(kind)).map((term) =>
|
|
||||||
this.toResponse(term, countMap),
|
|
||||||
),
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.toolsService.page(list.slice(skip, skip + pageSize), list.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
async options(kind: BlogTermKind, query: BlogTermListQueryDto = {}) {
|
|
||||||
const { pageSize, skip } = this.toolsService.getPageParams(query, 1, 200);
|
|
||||||
const countMap = await this.collectArticleTermMap(kind);
|
|
||||||
const termMap = new Map<string, BlogTerm>();
|
|
||||||
|
|
||||||
(await this.getStoredTerms(kind)).forEach((term) => {
|
|
||||||
termMap.set(term.slug || this.normalizeSlug(term.name), term);
|
|
||||||
});
|
|
||||||
|
|
||||||
countMap.forEach((term, slug) => {
|
|
||||||
if (termMap.has(slug)) return;
|
|
||||||
termMap.set(
|
|
||||||
slug,
|
|
||||||
this.termRepository.create({
|
|
||||||
id: term.id || slug,
|
|
||||||
kind,
|
|
||||||
name: term.name,
|
|
||||||
slug,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const list = this.filterAndSortTerms(
|
|
||||||
Array.from(termMap.values()).map((term) => this.toResponse(term, countMap)),
|
|
||||||
query,
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.toolsService.page(list.slice(skip, skip + pageSize), list.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
async detail(kind: BlogTermKind, id: string | number) {
|
|
||||||
const term = await this.findExistingTerm(kind, id);
|
|
||||||
const countMap = await this.collectArticleTermMap(kind);
|
|
||||||
|
|
||||||
return this.toResponse(term, countMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(kind: BlogTermKind, body: BlogTermBodyDto) {
|
|
||||||
const entity = this.getTermEntity(kind, body);
|
|
||||||
await this.assertSlugAvailable(kind, entity.slug);
|
|
||||||
|
|
||||||
const saved = await this.termRepository.save(
|
|
||||||
this.termRepository.create(entity),
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.toResponse(saved, await this.collectArticleTermMap(kind));
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(kind: BlogTermKind, body: BlogTermUpdateBodyDto) {
|
|
||||||
const term = await this.findExistingTerm(kind, body.id);
|
|
||||||
const nextEntity = this.getTermEntity(kind, body);
|
|
||||||
|
|
||||||
await this.assertSlugAvailable(kind, nextEntity.slug, term.id);
|
|
||||||
Object.assign(term, nextEntity);
|
|
||||||
|
|
||||||
const saved = await this.termRepository.save(term);
|
|
||||||
|
|
||||||
return this.toResponse(saved, await this.collectArticleTermMap(kind));
|
|
||||||
}
|
|
||||||
|
|
||||||
async remove(kind: BlogTermKind, id: string | number) {
|
|
||||||
const result = await this.termRepository.update(
|
|
||||||
{
|
|
||||||
id: `${id}`,
|
|
||||||
isDeleted: false,
|
|
||||||
kind,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
isDeleted: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return (result.affected || 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async syncTerms(kind: BlogTermKind, terms: BlogArticleTerm[] = []) {
|
|
||||||
for (const term of this.normalizeTerms(terms)) {
|
|
||||||
const slug = term.slug || this.normalizeSlug(term.name);
|
|
||||||
const existing = await this.termRepository.findOne({
|
|
||||||
where: {
|
|
||||||
isDeleted: false,
|
|
||||||
kind,
|
|
||||||
slug,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing) continue;
|
|
||||||
|
|
||||||
await this.termRepository.save(
|
|
||||||
this.termRepository.create({
|
|
||||||
kind,
|
|
||||||
name: term.name,
|
|
||||||
slug,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getStoredTerms(kind: BlogTermKind) {
|
|
||||||
return this.termRepository.find({
|
|
||||||
where: {
|
|
||||||
isDeleted: false,
|
|
||||||
kind,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findExistingTerm(kind: BlogTermKind, id: string | number) {
|
|
||||||
const term = await this.termRepository.findOne({
|
|
||||||
where: {
|
|
||||||
id: `${id}`,
|
|
||||||
isDeleted: false,
|
|
||||||
kind,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!term) {
|
|
||||||
throwVbenError('分类或标签不存在', HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return term;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTermEntity(kind: BlogTermKind, body: BlogTermBodyDto) {
|
|
||||||
const name = this.toolsService.toTrimmedString(body.name);
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
throwVbenError('请填写分类或标签名称', HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
description: body.description || '',
|
|
||||||
kind,
|
|
||||||
name,
|
|
||||||
parentId:
|
|
||||||
kind === 'category' ? this.toolsService.toStringId(body.parent) : '',
|
|
||||||
slug: this.normalizeSlug(body.slug || name),
|
|
||||||
} as Partial<BlogTerm>;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async assertSlugAvailable(
|
|
||||||
kind: BlogTermKind,
|
|
||||||
slug: string,
|
|
||||||
currentId?: string,
|
|
||||||
) {
|
|
||||||
const existing = await this.termRepository.findOne({
|
|
||||||
where: {
|
|
||||||
isDeleted: false,
|
|
||||||
kind,
|
|
||||||
slug,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existing && existing.id !== currentId) {
|
|
||||||
throwVbenError('同名分类或标签已存在', HttpStatus.CONFLICT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async collectArticleTermMap(kind: BlogTermKind) {
|
|
||||||
const articles = await this.articleRepository.find({
|
|
||||||
select:
|
|
||||||
kind === 'category'
|
|
||||||
? {
|
|
||||||
categoryItems: true,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
tagItems: true,
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
isDeleted: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const termMap = new Map<string, CountedBlogTerm>();
|
|
||||||
|
|
||||||
articles.forEach((article) => {
|
|
||||||
const source =
|
|
||||||
kind === 'category' ? article.categoryItems || [] : article.tagItems || [];
|
|
||||||
|
|
||||||
this.normalizeTerms(source).forEach((term) => {
|
|
||||||
const slug = term.slug || this.normalizeSlug(term.name);
|
|
||||||
const current = termMap.get(slug);
|
|
||||||
|
|
||||||
if (current) {
|
|
||||||
current.count += 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
termMap.set(slug, {
|
|
||||||
...term,
|
|
||||||
count: 1,
|
|
||||||
id: term.id || slug,
|
|
||||||
slug,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return termMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeTerms(values: BlogArticleTerm[]) {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
return values
|
|
||||||
.map((item) => {
|
|
||||||
const name = this.toolsService.toTrimmedString(item.name);
|
|
||||||
const slug = this.normalizeSlug(item.slug || item.name);
|
|
||||||
|
|
||||||
return this.toolsService.pickDefined({
|
|
||||||
id: item.id,
|
|
||||||
name,
|
|
||||||
slug,
|
|
||||||
}) as BlogArticleTerm;
|
|
||||||
})
|
|
||||||
.filter((item) => {
|
|
||||||
if (!item.name || seen.has(item.slug)) return false;
|
|
||||||
seen.add(item.slug);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private filterAndSortTerms(
|
|
||||||
terms: BlogTerm[],
|
|
||||||
query: BlogTermListQueryDto = {},
|
|
||||||
) {
|
|
||||||
const search = this.toolsService.toTrimmedString(query.search).toLowerCase();
|
|
||||||
const parent = this.toolsService.toStringId(query.parent);
|
|
||||||
const hideEmpty = this.toolsService.normalizeBoolean(query.hide_empty);
|
|
||||||
|
|
||||||
return terms
|
|
||||||
.filter((term) => {
|
|
||||||
if (hideEmpty && !term.count) return false;
|
|
||||||
if (parent && term.parent !== parent) return false;
|
|
||||||
if (!search) return true;
|
|
||||||
return `${term.name} ${term.slug} ${term.description || ''}`
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(search);
|
|
||||||
})
|
|
||||||
.sort((left, right) => {
|
|
||||||
const countDiff = (right.count || 0) - (left.count || 0);
|
|
||||||
if (countDiff !== 0) return countDiff;
|
|
||||||
return left.name.localeCompare(right.name, 'zh-CN');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private toResponse(term: BlogTerm, countMap: Map<string, CountedBlogTerm>) {
|
|
||||||
const slug = term.slug || this.normalizeSlug(term.name);
|
|
||||||
const countedTerm = countMap.get(slug);
|
|
||||||
|
|
||||||
return Object.assign(term, {
|
|
||||||
count: countedTerm?.count || 0,
|
|
||||||
id: term.id || slug,
|
|
||||||
parent: term.parentId || undefined,
|
|
||||||
slug,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeSlug(value: unknown) {
|
|
||||||
return this.toolsService.normalizeSlugText(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
HttpCode,
|
|
||||||
HttpStatus,
|
|
||||||
Post,
|
|
||||||
Res,
|
|
||||||
UseGuards,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
|
||||||
import { Public, vbenSuccess } from '@/common';
|
|
||||||
import { BlogThemeConfigBodyDto } from './blog-theme-config.dto';
|
|
||||||
import { BlogThemeConfigService } from './blog-theme-config.service';
|
|
||||||
|
|
||||||
@ApiTags('Blog - 主题')
|
|
||||||
@Controller('blog/theme')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class BlogThemeConfigController {
|
|
||||||
constructor(
|
|
||||||
private readonly blogThemeConfigService: BlogThemeConfigService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('config')
|
|
||||||
@Public()
|
|
||||||
@ApiOperation({ summary: '获取本地博客主题配置' })
|
|
||||||
async config(@Res() res) {
|
|
||||||
const config = await this.blogThemeConfigService.publicConfig();
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(config));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('save')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '保存本地博客主题配置' })
|
|
||||||
async save(@Res() res, @Body() body: BlogThemeConfigBodyDto) {
|
|
||||||
const result = await this.blogThemeConfigService.save(body);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('import-wordpress')
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@ApiOperation({ summary: '从 WordPress 导入主题配置到本地博客' })
|
|
||||||
async importWordpress(@Res() res) {
|
|
||||||
const result = await this.blogThemeConfigService.importFromWordpress();
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(result));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import type { WordpressArgonThemeConfig } from '@/wordpress/wordpress.types';
|
|
||||||
|
|
||||||
export class BlogThemeConfigBodyDto {
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: 'Argon 主题配置 JSON',
|
|
||||||
})
|
|
||||||
config?: WordpressArgonThemeConfig;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
description: '配置来源',
|
|
||||||
})
|
|
||||||
source?: string;
|
|
||||||
}
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import {
|
|
||||||
Column,
|
|
||||||
CreateDateColumn,
|
|
||||||
Entity,
|
|
||||||
PrimaryColumn,
|
|
||||||
UpdateDateColumn,
|
|
||||||
} from 'typeorm';
|
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
||||||
import { FormatDateTime } from '@/common';
|
|
||||||
import type { WordpressArgonThemeConfig } from '@/wordpress/wordpress.types';
|
|
||||||
|
|
||||||
@Entity('blog_theme_config')
|
|
||||||
export class BlogThemeConfig {
|
|
||||||
@ApiProperty()
|
|
||||||
@PrimaryColumn({
|
|
||||||
length: 64,
|
|
||||||
type: 'varchar',
|
|
||||||
})
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
@Column({
|
|
||||||
type: 'simple-json',
|
|
||||||
})
|
|
||||||
config: WordpressArgonThemeConfig;
|
|
||||||
|
|
||||||
@ApiPropertyOptional()
|
|
||||||
@Column({
|
|
||||||
default: 'local',
|
|
||||||
})
|
|
||||||
source: string;
|
|
||||||
|
|
||||||
@CreateDateColumn({
|
|
||||||
name: 'create_time',
|
|
||||||
})
|
|
||||||
@FormatDateTime()
|
|
||||||
createTime: Date;
|
|
||||||
|
|
||||||
@UpdateDateColumn({
|
|
||||||
name: 'update_time',
|
|
||||||
})
|
|
||||||
@FormatDateTime()
|
|
||||||
updateTime: Date;
|
|
||||||
}
|
|
||||||
@ -1,146 +0,0 @@
|
|||||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { throwVbenError, ToolsService } from '@/common';
|
|
||||||
import { WordpressService } from '@/wordpress/wordpress.service';
|
|
||||||
import type { WordpressArgonThemeConfig } from '@/wordpress/wordpress.types';
|
|
||||||
import { BlogThemeConfigBodyDto } from './blog-theme-config.dto';
|
|
||||||
import { BlogThemeConfig } from './blog-theme-config.entity';
|
|
||||||
|
|
||||||
const DEFAULT_THEME_ID = 'argon';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class BlogThemeConfigService {
|
|
||||||
constructor(
|
|
||||||
@InjectRepository(BlogThemeConfig)
|
|
||||||
private readonly themeRepository: Repository<BlogThemeConfig>,
|
|
||||||
private readonly wordpressService: WordpressService,
|
|
||||||
private readonly toolsService: ToolsService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async publicConfig() {
|
|
||||||
const localConfig = await this.themeRepository.findOne({
|
|
||||||
where: {
|
|
||||||
id: DEFAULT_THEME_ID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return localConfig?.config || this.getDefaultConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
async save(body: BlogThemeConfigBodyDto) {
|
|
||||||
if (!body.config) {
|
|
||||||
throwVbenError('请提供主题配置', HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.upsertConfig(
|
|
||||||
body.config,
|
|
||||||
this.toolsService.toTrimmedString(body.source) || 'local',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async importFromWordpress() {
|
|
||||||
const config = await this.wordpressService.themeConfig();
|
|
||||||
|
|
||||||
return this.upsertConfig(config, 'wordpress');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async upsertConfig(
|
|
||||||
config: WordpressArgonThemeConfig,
|
|
||||||
source: string,
|
|
||||||
) {
|
|
||||||
const existing = await this.themeRepository.findOne({
|
|
||||||
where: {
|
|
||||||
id: DEFAULT_THEME_ID,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const saved = await this.themeRepository.save(
|
|
||||||
existing
|
|
||||||
? Object.assign(existing, { config, source })
|
|
||||||
: this.themeRepository.create({
|
|
||||||
config,
|
|
||||||
id: DEFAULT_THEME_ID,
|
|
||||||
source,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return saved.config;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDefaultConfig(): WordpressArgonThemeConfig {
|
|
||||||
return {
|
|
||||||
argonConfig: {
|
|
||||||
codeHighlight: {
|
|
||||||
breakLine: false,
|
|
||||||
enable: true,
|
|
||||||
hideLinenumber: false,
|
|
||||||
transparentLinenumber: false,
|
|
||||||
},
|
|
||||||
dateFormat: 'YMD',
|
|
||||||
disablePjax: true,
|
|
||||||
foldLongComments: false,
|
|
||||||
foldLongShuoshuo: false,
|
|
||||||
headroom: 'false',
|
|
||||||
language: 'zh_CN',
|
|
||||||
lazyload: {
|
|
||||||
effect: 'fadeIn',
|
|
||||||
threshold: 800,
|
|
||||||
},
|
|
||||||
pangu: 'article',
|
|
||||||
pjaxAnimationDuration: 600,
|
|
||||||
waterflowColumns: '1',
|
|
||||||
wpPath: '/',
|
|
||||||
zoomify: false,
|
|
||||||
},
|
|
||||||
backgroundDarkBrightness: 0.65,
|
|
||||||
backgroundDarkImage: '/argon/theme/img-2-1200x1000.jpg',
|
|
||||||
backgroundDarkOpacity: 1,
|
|
||||||
backgroundImage: '/argon/theme/img-2-1200x1000.jpg',
|
|
||||||
backgroundOpacity: 1,
|
|
||||||
bodyClass: ['home', 'blog', 'wp-theme-argon'],
|
|
||||||
darkmodeAutoSwitch: 'alwayson',
|
|
||||||
enableCustomThemeColor: true,
|
|
||||||
headerMenu: [
|
|
||||||
{
|
|
||||||
href: '/',
|
|
||||||
label: '首页',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
href: '/archives',
|
|
||||||
label: '归档',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
htmlClass: [
|
|
||||||
'triple-column',
|
|
||||||
'immersion-color',
|
|
||||||
'toolbar-blur',
|
|
||||||
'article-header-style-default',
|
|
||||||
],
|
|
||||||
site: {
|
|
||||||
authorAvatar: '/argon/theme/profile.jpg',
|
|
||||||
authorName: 'KwiTsukasa',
|
|
||||||
description: '',
|
|
||||||
home: '',
|
|
||||||
title: 'KwiTsukasa的小站',
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
sidebarMenu: [
|
|
||||||
{
|
|
||||||
href: '/',
|
|
||||||
icon: 'fa-home',
|
|
||||||
label: '首页',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
external: true,
|
|
||||||
href: '/admin',
|
|
||||||
icon: 'fa-user',
|
|
||||||
label: '管理',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
themeCardRadius: 4,
|
|
||||||
themeColor: '#c3a1ed',
|
|
||||||
themeColorRgb: '195,161,237',
|
|
||||||
themeVersion: '1.3.5',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import { Module } from '@nestjs/common';
|
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
||||||
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
|
|
||||||
import { CommonModule } from '@/common';
|
|
||||||
import { WordpressModule } from '@/wordpress/wordpress.module';
|
|
||||||
import { BlogArticleController } from './blog-article.controller';
|
|
||||||
import { BlogArticle } from './blog-article.entity';
|
|
||||||
import { BlogArticleService } from './blog-article.service';
|
|
||||||
import { BlogThemeConfigController } from './blog-theme-config.controller';
|
|
||||||
import { BlogThemeConfig } from './blog-theme-config.entity';
|
|
||||||
import { BlogThemeConfigService } from './blog-theme-config.service';
|
|
||||||
import { BlogTermController } from './blog-term.controller';
|
|
||||||
import { BlogTerm } from './blog-term.entity';
|
|
||||||
import { BlogTermService } from './blog-term.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
AdminAuthGuardModule,
|
|
||||||
CommonModule,
|
|
||||||
WordpressModule,
|
|
||||||
TypeOrmModule.forFeature([BlogArticle, BlogTerm, BlogThemeConfig]),
|
|
||||||
],
|
|
||||||
controllers: [
|
|
||||||
BlogArticleController,
|
|
||||||
BlogTermController,
|
|
||||||
BlogThemeConfigController,
|
|
||||||
],
|
|
||||||
providers: [BlogArticleService, BlogTermService, BlogThemeConfigService],
|
|
||||||
exports: [BlogArticleService, BlogTermService, BlogThemeConfigService],
|
|
||||||
})
|
|
||||||
export class BlogModule {}
|
|
||||||
@ -1,11 +1,10 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { LokiLogPublisherService } from './logger/loki-log-publisher.service';
|
import { LokiLogPublisherService } from './logger/loki-log-publisher.service';
|
||||||
import { MarkdownService } from './services/markdown.service';
|
|
||||||
import { ToolsService } from './services/tool.service';
|
import { ToolsService } from './services/tool.service';
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
exports: [LokiLogPublisherService, MarkdownService, ToolsService],
|
exports: [LokiLogPublisherService, ToolsService],
|
||||||
providers: [LokiLogPublisherService, MarkdownService, ToolsService],
|
providers: [LokiLogPublisherService, ToolsService],
|
||||||
})
|
})
|
||||||
export class CommonModule {}
|
export class CommonModule {}
|
||||||
|
|||||||
@ -9,7 +9,6 @@ export * from './interceptors/save-body.interceptor';
|
|||||||
export * from './logger/loki-log-publisher.service';
|
export * from './logger/loki-log-publisher.service';
|
||||||
export * from './logger/pino-logger.config';
|
export * from './logger/pino-logger.config';
|
||||||
export * from './response/vben-response';
|
export * from './response/vben-response';
|
||||||
export * from './services/markdown.service';
|
|
||||||
export * from './services/tool.service';
|
export * from './services/tool.service';
|
||||||
export * from './snowflake/snowflake-id';
|
export * from './snowflake/snowflake-id';
|
||||||
export * from './snowflake/snowflake-id.subscriber';
|
export * from './snowflake/snowflake-id.subscriber';
|
||||||
|
|||||||
@ -1,234 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
type MarkdownProcessor = {
|
|
||||||
process: (value: string) => Promise<unknown>;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UnifiedModule = typeof import('unified');
|
|
||||||
type RemarkParseModule = typeof import('remark-parse');
|
|
||||||
type RemarkGfmModule = typeof import('remark-gfm');
|
|
||||||
type RemarkRehypeModule = typeof import('remark-rehype');
|
|
||||||
type RehypeRawModule = typeof import('rehype-raw');
|
|
||||||
type RehypeParseModule = typeof import('rehype-parse');
|
|
||||||
type RehypeRemarkModule = typeof import('rehype-remark');
|
|
||||||
type RehypeSanitizeModule = typeof import('rehype-sanitize');
|
|
||||||
type RehypeStringifyModule = typeof import('rehype-stringify');
|
|
||||||
type RemarkStringifyModule = typeof import('remark-stringify');
|
|
||||||
|
|
||||||
const importEsm = new Function(
|
|
||||||
'specifier',
|
|
||||||
'return import(specifier)',
|
|
||||||
) as <T>(specifier: string) => Promise<T>;
|
|
||||||
|
|
||||||
const MARKDOWN_SOURCE_PATTERN =
|
|
||||||
/<!--\s*kt-markdown-source:([A-Za-z0-9+/=]+)\s*-->/;
|
|
||||||
const CONTENT_CLASS_PATTERN =
|
|
||||||
/^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-)/;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class MarkdownService {
|
|
||||||
private processorPromise?: Promise<MarkdownProcessor>;
|
|
||||||
private htmlToMarkdownProcessorPromise?: Promise<MarkdownProcessor>;
|
|
||||||
private sanitizeHtmlProcessorPromise?: Promise<MarkdownProcessor>;
|
|
||||||
|
|
||||||
async renderToHtml(markdown?: string | null) {
|
|
||||||
const processor = await this.getProcessor();
|
|
||||||
const file = await processor.process(markdown || '');
|
|
||||||
|
|
||||||
return `${file}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async renderHtmlToMarkdown(html?: string | null) {
|
|
||||||
const processor = await this.getHtmlToMarkdownProcessor();
|
|
||||||
const file = await processor.process(this.stripSourceMarker(html || ''));
|
|
||||||
|
|
||||||
return `${file}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
async sanitizeHtml(html?: string | null) {
|
|
||||||
const processor = await this.getSanitizeHtmlProcessor();
|
|
||||||
const file = await processor.process(this.stripSourceMarker(html || ''));
|
|
||||||
|
|
||||||
return `${file}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
embedSourceHtml(html: string, markdown?: string | null) {
|
|
||||||
const source = markdown || '';
|
|
||||||
if (!source) return this.stripSourceMarker(html);
|
|
||||||
|
|
||||||
return `${this.stripSourceMarker(html)}\n<!-- kt-markdown-source:${Buffer.from(
|
|
||||||
source,
|
|
||||||
'utf8',
|
|
||||||
).toString('base64')} -->`;
|
|
||||||
}
|
|
||||||
|
|
||||||
extractSource(value?: unknown) {
|
|
||||||
const text = `${value ?? ''}`;
|
|
||||||
const source = MARKDOWN_SOURCE_PATTERN.exec(text)?.[1];
|
|
||||||
if (!source) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
return Buffer.from(source, 'base64').toString('utf8');
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stripSourceMarker(value?: unknown) {
|
|
||||||
return `${value ?? ''}`.replace(MARKDOWN_SOURCE_PATTERN, '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getProcessor() {
|
|
||||||
if (!this.processorPromise) {
|
|
||||||
this.processorPromise = this.createProcessor();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.processorPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getHtmlToMarkdownProcessor() {
|
|
||||||
if (!this.htmlToMarkdownProcessorPromise) {
|
|
||||||
this.htmlToMarkdownProcessorPromise =
|
|
||||||
this.createHtmlToMarkdownProcessor();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.htmlToMarkdownProcessorPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSanitizeHtmlProcessor() {
|
|
||||||
if (!this.sanitizeHtmlProcessorPromise) {
|
|
||||||
this.sanitizeHtmlProcessorPromise = this.createSanitizeHtmlProcessor();
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.sanitizeHtmlProcessorPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createProcessor(): Promise<MarkdownProcessor> {
|
|
||||||
const [
|
|
||||||
{ unified },
|
|
||||||
{ default: remarkParse },
|
|
||||||
{ default: remarkGfm },
|
|
||||||
{ default: remarkRehype },
|
|
||||||
{ default: rehypeRaw },
|
|
||||||
{ default: rehypeSanitize, defaultSchema },
|
|
||||||
{ default: rehypeStringify },
|
|
||||||
] = await Promise.all([
|
|
||||||
importEsm<UnifiedModule>('unified'),
|
|
||||||
importEsm<RemarkParseModule>('remark-parse'),
|
|
||||||
importEsm<RemarkGfmModule>('remark-gfm'),
|
|
||||||
importEsm<RemarkRehypeModule>('remark-rehype'),
|
|
||||||
importEsm<RehypeRawModule>('rehype-raw'),
|
|
||||||
importEsm<RehypeSanitizeModule>('rehype-sanitize'),
|
|
||||||
importEsm<RehypeStringifyModule>('rehype-stringify'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const schema = this.createSanitizeSchema(defaultSchema);
|
|
||||||
|
|
||||||
return unified()
|
|
||||||
.use(remarkParse)
|
|
||||||
.use(remarkGfm)
|
|
||||||
.use(remarkRehype, {
|
|
||||||
allowDangerousHtml: true,
|
|
||||||
clobberPrefix: 'kt-md-',
|
|
||||||
footnoteBackContent: '返回',
|
|
||||||
footnoteLabel: '脚注',
|
|
||||||
})
|
|
||||||
.use(rehypeRaw)
|
|
||||||
.use(rehypeSanitize, schema)
|
|
||||||
.use(rehypeStringify) as MarkdownProcessor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createHtmlToMarkdownProcessor(): Promise<MarkdownProcessor> {
|
|
||||||
const [
|
|
||||||
{ unified },
|
|
||||||
{ default: rehypeParse },
|
|
||||||
{ default: rehypeRemark },
|
|
||||||
{ default: remarkGfm },
|
|
||||||
{ default: remarkStringify },
|
|
||||||
] = await Promise.all([
|
|
||||||
importEsm<UnifiedModule>('unified'),
|
|
||||||
importEsm<RehypeParseModule>('rehype-parse'),
|
|
||||||
importEsm<RehypeRemarkModule>('rehype-remark'),
|
|
||||||
importEsm<RemarkGfmModule>('remark-gfm'),
|
|
||||||
importEsm<RemarkStringifyModule>('remark-stringify'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return unified()
|
|
||||||
.use(rehypeParse, { fragment: true })
|
|
||||||
.use(rehypeRemark)
|
|
||||||
.use(remarkGfm)
|
|
||||||
.use(remarkStringify, {
|
|
||||||
bullet: '-',
|
|
||||||
fences: true,
|
|
||||||
listItemIndent: 'one',
|
|
||||||
}) as MarkdownProcessor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async createSanitizeHtmlProcessor(): Promise<MarkdownProcessor> {
|
|
||||||
const [
|
|
||||||
{ unified },
|
|
||||||
{ default: rehypeParse },
|
|
||||||
{ default: rehypeSanitize, defaultSchema },
|
|
||||||
{ default: rehypeStringify },
|
|
||||||
] = await Promise.all([
|
|
||||||
importEsm<UnifiedModule>('unified'),
|
|
||||||
importEsm<RehypeParseModule>('rehype-parse'),
|
|
||||||
importEsm<RehypeSanitizeModule>('rehype-sanitize'),
|
|
||||||
importEsm<RehypeStringifyModule>('rehype-stringify'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return unified()
|
|
||||||
.use(rehypeParse, { fragment: true })
|
|
||||||
.use(rehypeSanitize, this.createSanitizeSchema(defaultSchema))
|
|
||||||
.use(rehypeStringify) as MarkdownProcessor;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createSanitizeSchema(defaultSchema: unknown) {
|
|
||||||
const schema = defaultSchema as Record<string, any>;
|
|
||||||
const attributes = schema.attributes || {};
|
|
||||||
const classNameAttribute = ['className', CONTENT_CLASS_PATTERN];
|
|
||||||
|
|
||||||
return {
|
|
||||||
...schema,
|
|
||||||
tagNames: [
|
|
||||||
...new Set([
|
|
||||||
...(schema.tagNames || []),
|
|
||||||
'figcaption',
|
|
||||||
'figure',
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
attributes: {
|
|
||||||
...attributes,
|
|
||||||
a: [...(attributes.a || []), 'target', 'rel', classNameAttribute],
|
|
||||||
blockquote: [...(attributes.blockquote || []), classNameAttribute],
|
|
||||||
code: [...(attributes.code || []), classNameAttribute],
|
|
||||||
div: [...(attributes.div || []), classNameAttribute],
|
|
||||||
figcaption: [...(attributes.figcaption || []), classNameAttribute],
|
|
||||||
figure: [...(attributes.figure || []), classNameAttribute],
|
|
||||||
h1: [...(attributes.h1 || []), classNameAttribute],
|
|
||||||
h2: [...(attributes.h2 || []), classNameAttribute],
|
|
||||||
h3: [...(attributes.h3 || []), classNameAttribute],
|
|
||||||
h4: [...(attributes.h4 || []), classNameAttribute],
|
|
||||||
h5: [...(attributes.h5 || []), classNameAttribute],
|
|
||||||
h6: [...(attributes.h6 || []), classNameAttribute],
|
|
||||||
img: [
|
|
||||||
...(attributes.img || []),
|
|
||||||
'loading',
|
|
||||||
classNameAttribute,
|
|
||||||
],
|
|
||||||
li: [...(attributes.li || []), classNameAttribute],
|
|
||||||
ol: [...(attributes.ol || []), classNameAttribute],
|
|
||||||
p: [...(attributes.p || []), classNameAttribute],
|
|
||||||
pre: [...(attributes.pre || []), classNameAttribute],
|
|
||||||
span: [...(attributes.span || []), classNameAttribute],
|
|
||||||
table: [...(attributes.table || []), classNameAttribute],
|
|
||||||
tbody: [...(attributes.tbody || []), classNameAttribute],
|
|
||||||
td: [...(attributes.td || []), classNameAttribute],
|
|
||||||
th: [...(attributes.th || []), classNameAttribute],
|
|
||||||
thead: [...(attributes.thead || []), classNameAttribute],
|
|
||||||
tr: [...(attributes.tr || []), classNameAttribute],
|
|
||||||
ul: [...(attributes.ul || []), classNameAttribute],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -132,17 +132,6 @@ export class ToolsService {
|
|||||||
return value === undefined || value === null ? '' : `${value}`;
|
return value === undefined || value === null ? '' : `${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizeSlugText(value: unknown) {
|
|
||||||
const text = this.toTrimmedString(value);
|
|
||||||
if (!text) return '';
|
|
||||||
|
|
||||||
try {
|
|
||||||
return decodeURIComponent(text).toLowerCase().replace(/\s+/g, '-');
|
|
||||||
} catch {
|
|
||||||
return text.toLowerCase().replace(/\s+/g, '-');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toPositiveNumber(
|
toPositiveNumber(
|
||||||
value: number | string | undefined | null,
|
value: number | string | undefined | null,
|
||||||
fallback: number,
|
fallback: number,
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
||||||
import { Public, vbenSuccess } from '@/common';
|
import { vbenSuccess } from '@/common';
|
||||||
import {
|
import {
|
||||||
WordpressArticleBodyDto,
|
WordpressArticleBodyDto,
|
||||||
WordpressArticleListQueryDto,
|
WordpressArticleListQueryDto,
|
||||||
@ -37,33 +37,6 @@ import { WordpressService } from './wordpress.service';
|
|||||||
export class WordpressArticleController {
|
export class WordpressArticleController {
|
||||||
constructor(private readonly wordpressService: WordpressService) {}
|
constructor(private readonly wordpressService: WordpressService) {}
|
||||||
|
|
||||||
@Get('public/list')
|
|
||||||
@Public()
|
|
||||||
@ApiOperation({ summary: '获取公开 WordPress 文章分页列表' })
|
|
||||||
async publicList(@Res() res, @Query() query: WordpressArticleListQueryDto) {
|
|
||||||
const list = await this.wordpressService.publicArticleList(query);
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(list));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('public/detail')
|
|
||||||
@Public()
|
|
||||||
@ApiOperation({ summary: '获取公开 WordPress 文章详情' })
|
|
||||||
@ApiQuery({ name: 'slug', required: false, type: String })
|
|
||||||
@ApiQuery({ name: 'id', required: false, type: Number })
|
|
||||||
async publicDetail(
|
|
||||||
@Res() res,
|
|
||||||
@Query('slug') slug?: string,
|
|
||||||
@Query('id') id?: string,
|
|
||||||
) {
|
|
||||||
const detail = await this.wordpressService.publicArticleDetail({
|
|
||||||
id,
|
|
||||||
slug,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(detail));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('list')
|
@Get('list')
|
||||||
@ApiOperation({ summary: '获取 WordPress 文章分页列表' })
|
@ApiOperation({ summary: '获取 WordPress 文章分页列表' })
|
||||||
async list(
|
async list(
|
||||||
|
|||||||
@ -1,21 +0,0 @@
|
|||||||
import { Controller, Get, Res, UseGuards } from '@nestjs/common';
|
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
|
||||||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
|
||||||
import { Public, vbenSuccess } from '@/common';
|
|
||||||
import { WordpressService } from './wordpress.service';
|
|
||||||
|
|
||||||
@ApiTags('WordPress - 主题')
|
|
||||||
@Controller('wordpress/theme')
|
|
||||||
@UseGuards(JwtAuthGuard)
|
|
||||||
export class WordpressThemeController {
|
|
||||||
constructor(private readonly wordpressService: WordpressService) {}
|
|
||||||
|
|
||||||
@Get('config')
|
|
||||||
@Public()
|
|
||||||
@ApiOperation({ summary: '获取 WordPress Argon 主题配置' })
|
|
||||||
async config(@Res() res) {
|
|
||||||
const config = await this.wordpressService.themeConfig();
|
|
||||||
|
|
||||||
return res.send(vbenSuccess(config));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -77,12 +77,6 @@ export class WordpressArticleBodyDto {
|
|||||||
})
|
})
|
||||||
content?: string;
|
content?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
|
||||||
enum: ['html', 'markdown'],
|
|
||||||
description: '文章内容格式,markdown 会在后端转换为安全 HTML',
|
|
||||||
})
|
|
||||||
contentFormat?: 'html' | 'markdown';
|
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
description: '文章摘要',
|
description: '文章摘要',
|
||||||
})
|
})
|
||||||
|
|||||||
@ -5,7 +5,6 @@ import { WordpressAuthController } from './wordpress-auth.controller';
|
|||||||
import { WordpressCategoryController } from './wordpress-category.controller';
|
import { WordpressCategoryController } from './wordpress-category.controller';
|
||||||
import { WordpressService } from './wordpress.service';
|
import { WordpressService } from './wordpress.service';
|
||||||
import { WordpressTagController } from './wordpress-tag.controller';
|
import { WordpressTagController } from './wordpress-tag.controller';
|
||||||
import { WordpressThemeController } from './wordpress-theme.controller';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AdminAuthGuardModule],
|
imports: [AdminAuthGuardModule],
|
||||||
@ -14,7 +13,6 @@ import { WordpressThemeController } from './wordpress-theme.controller';
|
|||||||
WordpressArticleController,
|
WordpressArticleController,
|
||||||
WordpressTagController,
|
WordpressTagController,
|
||||||
WordpressCategoryController,
|
WordpressCategoryController,
|
||||||
WordpressThemeController,
|
|
||||||
],
|
],
|
||||||
providers: [WordpressService],
|
providers: [WordpressService],
|
||||||
exports: [WordpressService],
|
exports: [WordpressService],
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import type { Request, Response as ExpressResponse } from 'express';
|
import type { Request, Response as ExpressResponse } from 'express';
|
||||||
import { MarkdownService, throwVbenError, ToolsService } from '@/common';
|
import { throwVbenError, ToolsService } from '@/common';
|
||||||
import type {
|
import type {
|
||||||
WordpressArticleBodyDto,
|
WordpressArticleBodyDto,
|
||||||
WordpressArticleListQueryDto,
|
WordpressArticleListQueryDto,
|
||||||
@ -17,8 +17,6 @@ import type {
|
|||||||
WordpressPagedQueryDto,
|
WordpressPagedQueryDto,
|
||||||
WordpressRequestOptions,
|
WordpressRequestOptions,
|
||||||
WordpressResponse,
|
WordpressResponse,
|
||||||
WordpressArgonThemeConfig,
|
|
||||||
WordpressArgonMenuItem,
|
|
||||||
} from './wordpress.types';
|
} from './wordpress.types';
|
||||||
|
|
||||||
const WORDPRESS_COOKIE_PREFIXES = [
|
const WORDPRESS_COOKIE_PREFIXES = [
|
||||||
@ -36,7 +34,6 @@ export class WordpressService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
private readonly markdownService: MarkdownService,
|
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -188,7 +185,7 @@ export class WordpressService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list: response.data.map((item) => this.normalizeArticleResponse(item)),
|
list: response.data,
|
||||||
total: response.total || 0,
|
total: response.total || 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -201,17 +198,17 @@ export class WordpressService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.normalizeArticleResponse(response.data);
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async articleSave(body: WordpressArticleBodyDto, auth: WordpressAuthContext) {
|
async articleSave(body: WordpressArticleBodyDto, auth: WordpressAuthContext) {
|
||||||
const response = await this.request('/wp-json/wp/v2/posts', {
|
const response = await this.request('/wp-json/wp/v2/posts', {
|
||||||
auth,
|
auth,
|
||||||
body: await this.getArticleBody(body),
|
body: this.getArticleBody(body),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.normalizeArticleResponse(response.data);
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async articleUpdate(
|
async articleUpdate(
|
||||||
@ -220,11 +217,11 @@ export class WordpressService {
|
|||||||
) {
|
) {
|
||||||
const response = await this.request(`/wp-json/wp/v2/posts/${body.id}`, {
|
const response = await this.request(`/wp-json/wp/v2/posts/${body.id}`, {
|
||||||
auth,
|
auth,
|
||||||
body: await this.getArticleBody(body),
|
body: this.getArticleBody(body),
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.normalizeArticleResponse(response.data);
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async articleRemove(
|
async articleRemove(
|
||||||
@ -243,64 +240,6 @@ export class WordpressService {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async publicArticleList(query: WordpressArticleListQueryDto) {
|
|
||||||
const response = await this.publicRequest<any[]>('/wp-json/wp/v2/posts', {
|
|
||||||
query: {
|
|
||||||
...this.getPageQuery(query),
|
|
||||||
_embed: 'author,wp:featuredmedia,wp:term',
|
|
||||||
author: query.author,
|
|
||||||
categories: this.normalizeIdQuery(query.categories),
|
|
||||||
context: 'view',
|
|
||||||
search: query.search,
|
|
||||||
status: 'publish',
|
|
||||||
tags: this.normalizeIdQuery(query.tags),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
list: response.data.map((item) => this.normalizeArticleResponse(item)),
|
|
||||||
total: response.total || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async publicArticleDetail(query: { id?: string; slug?: string }) {
|
|
||||||
if (query.id) {
|
|
||||||
const response = await this.publicRequest(
|
|
||||||
`/wp-json/wp/v2/posts/${query.id}`,
|
|
||||||
{
|
|
||||||
query: {
|
|
||||||
_embed: 'author,wp:featuredmedia,wp:term',
|
|
||||||
context: 'view',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.normalizeArticleResponse(response.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const slug = `${query.slug || ''}`.trim();
|
|
||||||
if (!slug) {
|
|
||||||
throwVbenError('文章别名不能为空', HttpStatus.BAD_REQUEST);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.publicRequest<any[]>('/wp-json/wp/v2/posts', {
|
|
||||||
query: {
|
|
||||||
_embed: 'author,wp:featuredmedia,wp:term',
|
|
||||||
context: 'view',
|
|
||||||
per_page: 1,
|
|
||||||
slug,
|
|
||||||
status: 'publish',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const [article] = response.data;
|
|
||||||
|
|
||||||
if (!article) {
|
|
||||||
throwVbenError('文章不存在或未发布', HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.normalizeArticleResponse(article);
|
|
||||||
}
|
|
||||||
|
|
||||||
async tagList(query: WordpressTermListQueryDto, auth: WordpressAuthContext) {
|
async tagList(query: WordpressTermListQueryDto, auth: WordpressAuthContext) {
|
||||||
return this.termList('/wp-json/wp/v2/tags', query, auth);
|
return this.termList('/wp-json/wp/v2/tags', query, auth);
|
||||||
}
|
}
|
||||||
@ -358,17 +297,6 @@ export class WordpressService {
|
|||||||
return this.termRemove('/wp-json/wp/v2/categories', id, force, auth);
|
return this.termRemove('/wp-json/wp/v2/categories', id, force, auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
async themeConfig(): Promise<WordpressArgonThemeConfig> {
|
|
||||||
const [rootResponse, homeResponse] = await Promise.all([
|
|
||||||
this.rawRequest('/?rest_route=/'),
|
|
||||||
this.rawRequest('/'),
|
|
||||||
]);
|
|
||||||
const root = await this.parseResponse(rootResponse);
|
|
||||||
const html = await homeResponse.text();
|
|
||||||
|
|
||||||
return this.parseArgonThemeConfig(html, root);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async termList(
|
private async termList(
|
||||||
path: string,
|
path: string,
|
||||||
query: WordpressTermListQueryDto,
|
query: WordpressTermListQueryDto,
|
||||||
@ -457,20 +385,6 @@ export class WordpressService {
|
|||||||
): Promise<WordpressResponse<T>> {
|
): Promise<WordpressResponse<T>> {
|
||||||
this.assertAuthContext(options.auth);
|
this.assertAuthContext(options.auth);
|
||||||
|
|
||||||
return this.executeJsonRequest(path, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async publicRequest<T>(
|
|
||||||
path: string,
|
|
||||||
options: Omit<WordpressRequestOptions, 'auth'> = {},
|
|
||||||
): Promise<WordpressResponse<T>> {
|
|
||||||
return this.executeJsonRequest(path, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async executeJsonRequest<T>(
|
|
||||||
path: string,
|
|
||||||
options: WordpressRequestOptions,
|
|
||||||
): Promise<WordpressResponse<T>> {
|
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), this.getTimeout());
|
const timer = setTimeout(() => controller.abort(), this.getTimeout());
|
||||||
|
|
||||||
@ -654,9 +568,9 @@ export class WordpressService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertAuthContext(auth?: WordpressAuthContext) {
|
private assertAuthContext(auth: WordpressAuthContext) {
|
||||||
const hasToken = !!auth?.authorization;
|
const hasToken = !!auth.authorization;
|
||||||
const hasCookieLogin = !!auth?.cookie && !!auth?.nonce;
|
const hasCookieLogin = !!auth.cookie && !!auth.nonce;
|
||||||
|
|
||||||
if (hasToken || hasCookieLogin) return;
|
if (hasToken || hasCookieLogin) return;
|
||||||
|
|
||||||
@ -667,7 +581,7 @@ export class WordpressService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getHeaders(auth: WordpressAuthContext | undefined, hasBody: boolean) {
|
private getHeaders(auth: WordpressAuthContext, hasBody: boolean) {
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
};
|
};
|
||||||
@ -676,15 +590,15 @@ export class WordpressService {
|
|||||||
headers['Content-Type'] = 'application/json';
|
headers['Content-Type'] = 'application/json';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth?.authorization) {
|
if (auth.authorization) {
|
||||||
headers.Authorization = auth.authorization;
|
headers.Authorization = auth.authorization;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth?.cookie) {
|
if (auth.cookie) {
|
||||||
headers.Cookie = auth.cookie;
|
headers.Cookie = auth.cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth?.nonce) {
|
if (auth.nonce) {
|
||||||
headers['X-WP-Nonce'] = auth.nonce;
|
headers['X-WP-Nonce'] = auth.nonce;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -789,10 +703,10 @@ export class WordpressService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getArticleBody(body: WordpressArticleBodyDto) {
|
private getArticleBody(body: WordpressArticleBodyDto) {
|
||||||
return this.toolsService.pickDefined({
|
return this.toolsService.pickDefined({
|
||||||
categories: this.normalizeIdList(body.categories),
|
categories: this.normalizeIdList(body.categories),
|
||||||
content: await this.getArticleContent(body),
|
content: body.content,
|
||||||
excerpt: body.excerpt,
|
excerpt: body.excerpt,
|
||||||
featured_media: body.featured_media,
|
featured_media: body.featured_media,
|
||||||
slug: body.slug,
|
slug: body.slug,
|
||||||
@ -803,96 +717,6 @@ export class WordpressService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getArticleContent(body: WordpressArticleBodyDto) {
|
|
||||||
if (body.content === undefined) return undefined;
|
|
||||||
if (body.contentFormat !== 'markdown') return body.content;
|
|
||||||
|
|
||||||
const html = await this.markdownService.renderToHtml(body.content);
|
|
||||||
return this.markdownService.embedSourceHtml(html, body.content);
|
|
||||||
}
|
|
||||||
|
|
||||||
private normalizeArticleResponse(article: Record<string, any>) {
|
|
||||||
const content = article?.content;
|
|
||||||
const contentValue =
|
|
||||||
typeof content === 'string'
|
|
||||||
? content
|
|
||||||
: content?.raw || content?.rendered || '';
|
|
||||||
const contentMarkdown = this.markdownService.extractSource(contentValue);
|
|
||||||
const contentHtml = this.markdownService.stripSourceMarker(contentValue);
|
|
||||||
|
|
||||||
const nextArticle: Record<string, any> = {
|
|
||||||
...article,
|
|
||||||
authorName: this.getEmbeddedAuthorName(article),
|
|
||||||
categoriesResolved: this.getEmbeddedTerms(article, 'category'),
|
|
||||||
contentHtml,
|
|
||||||
cover: this.getEmbeddedFeaturedMediaUrl(article),
|
|
||||||
excerptText: this.stripHtml(article?.excerpt?.rendered || article?.excerpt),
|
|
||||||
tagsResolved: this.getEmbeddedTerms(article, 'post_tag'),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (contentMarkdown) {
|
|
||||||
nextArticle.contentMarkdown = contentMarkdown;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof content === 'string') {
|
|
||||||
nextArticle.content = this.markdownService.stripSourceMarker(content);
|
|
||||||
return nextArticle;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextArticle.content = {
|
|
||||||
...content,
|
|
||||||
raw: content.raw
|
|
||||||
? this.markdownService.stripSourceMarker(content.raw)
|
|
||||||
: content.raw,
|
|
||||||
rendered: content.rendered
|
|
||||||
? this.markdownService.stripSourceMarker(content.rendered)
|
|
||||||
: content.rendered,
|
|
||||||
};
|
|
||||||
|
|
||||||
return nextArticle;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getEmbeddedTerms(article: Record<string, any>, taxonomy: string) {
|
|
||||||
const terms = article?._embedded?.['wp:term'];
|
|
||||||
if (!Array.isArray(terms)) return [];
|
|
||||||
|
|
||||||
return terms
|
|
||||||
.flat()
|
|
||||||
.filter((item) => item?.taxonomy === taxonomy)
|
|
||||||
.map((item) =>
|
|
||||||
this.toolsService.pickDefined({
|
|
||||||
count: item.count,
|
|
||||||
id: item.id,
|
|
||||||
name: this.decodeHtmlEntities(item.name || ''),
|
|
||||||
slug: item.slug,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getEmbeddedAuthorName(article: Record<string, any>) {
|
|
||||||
const [author] = article?._embedded?.author || [];
|
|
||||||
|
|
||||||
return this.decodeHtmlEntities(author?.name || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private getEmbeddedFeaturedMediaUrl(article: Record<string, any>) {
|
|
||||||
const [media] = article?._embedded?.['wp:featuredmedia'] || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
media?.media_details?.sizes?.large?.source_url ||
|
|
||||||
media?.media_details?.sizes?.full?.source_url ||
|
|
||||||
media?.source_url ||
|
|
||||||
''
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stripHtml(value: unknown) {
|
|
||||||
return this.decodeHtmlEntities(`${value ?? ''}`)
|
|
||||||
.replace(/<[^>]*>/g, '')
|
|
||||||
.replace(/\s+/g, ' ')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTermBody(body: WordpressTermBodyDto) {
|
private getTermBody(body: WordpressTermBodyDto) {
|
||||||
return this.toolsService.pickDefined({
|
return this.toolsService.pickDefined({
|
||||||
description: body.description,
|
description: body.description,
|
||||||
@ -942,395 +766,6 @@ export class WordpressService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseArgonThemeConfig(
|
|
||||||
html: string,
|
|
||||||
root: any,
|
|
||||||
): WordpressArgonThemeConfig {
|
|
||||||
const argonConfigBlock = this.getScriptObjectBlock(html, 'argonConfig');
|
|
||||||
|
|
||||||
return {
|
|
||||||
argonConfig: {
|
|
||||||
codeHighlight: {
|
|
||||||
breakLine: this.getJsObjectBoolean(
|
|
||||||
this.getJsObjectBlock(argonConfigBlock, 'code_highlight'),
|
|
||||||
'break_line',
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
enable: this.getJsObjectBoolean(
|
|
||||||
this.getJsObjectBlock(argonConfigBlock, 'code_highlight'),
|
|
||||||
'enable',
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
hideLinenumber: this.getJsObjectBoolean(
|
|
||||||
this.getJsObjectBlock(argonConfigBlock, 'code_highlight'),
|
|
||||||
'hide_linenumber',
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
transparentLinenumber: this.getJsObjectBoolean(
|
|
||||||
this.getJsObjectBlock(argonConfigBlock, 'code_highlight'),
|
|
||||||
'transparent_linenumber',
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
dateFormat: this.getJsObjectString(
|
|
||||||
argonConfigBlock,
|
|
||||||
'dateFormat',
|
|
||||||
'YMD',
|
|
||||||
),
|
|
||||||
disablePjax: this.getJsObjectBoolean(
|
|
||||||
argonConfigBlock,
|
|
||||||
'disable_pjax',
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
foldLongComments: this.getJsObjectBoolean(
|
|
||||||
argonConfigBlock,
|
|
||||||
'fold_long_comments',
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
foldLongShuoshuo: this.getJsObjectBoolean(
|
|
||||||
argonConfigBlock,
|
|
||||||
'fold_long_shuoshuo',
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
headroom: this.getJsObjectRawValue(argonConfigBlock, 'headroom') || '',
|
|
||||||
language: this.getJsObjectString(argonConfigBlock, 'language', ''),
|
|
||||||
lazyload: {
|
|
||||||
effect: this.getJsObjectString(
|
|
||||||
this.getJsObjectBlock(argonConfigBlock, 'lazyload'),
|
|
||||||
'effect',
|
|
||||||
'',
|
|
||||||
),
|
|
||||||
threshold: this.getJsObjectNumber(
|
|
||||||
this.getJsObjectBlock(argonConfigBlock, 'lazyload'),
|
|
||||||
'threshold',
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
pangu: this.getJsObjectString(argonConfigBlock, 'pangu', ''),
|
|
||||||
pjaxAnimationDuration: this.getJsObjectNumber(
|
|
||||||
argonConfigBlock,
|
|
||||||
'pjax_animation_durtion',
|
|
||||||
0,
|
|
||||||
),
|
|
||||||
waterflowColumns:
|
|
||||||
this.getJsObjectRawValue(argonConfigBlock, 'waterflow_columns') || '',
|
|
||||||
wpPath: this.getJsObjectString(argonConfigBlock, 'wp_path', ''),
|
|
||||||
zoomify: this.getJsObjectBoolean(argonConfigBlock, 'zoomify', false),
|
|
||||||
},
|
|
||||||
backgroundDarkBrightness: this.getArgonBackgroundDarkBrightness(html),
|
|
||||||
backgroundDarkImage: this.getArgonBackgroundImage(html, '#content:after'),
|
|
||||||
backgroundDarkOpacity: this.getArgonBackgroundDarkOpacity(html),
|
|
||||||
backgroundImage: this.getArgonBackgroundImage(html),
|
|
||||||
backgroundOpacity: this.getArgonBackgroundOpacity(html),
|
|
||||||
bodyClass: this.getTagClassList(html, 'body'),
|
|
||||||
darkmodeAutoSwitch: this.getInlineJsString(
|
|
||||||
html,
|
|
||||||
'darkmodeAutoSwitch',
|
|
||||||
),
|
|
||||||
enableCustomThemeColor:
|
|
||||||
this.getMetaContent(html, 'argon-enable-custom-theme-color') === 'true',
|
|
||||||
headerMenu: this.getArgonMenuItems(html, 'navbar_global'),
|
|
||||||
htmlClass: this.getTagClassList(html, 'html').filter(
|
|
||||||
(item) => item !== 'no-js',
|
|
||||||
),
|
|
||||||
site: {
|
|
||||||
authorAvatar: this.getArgonAuthorAvatar(html),
|
|
||||||
authorName: this.getArgonAuthorName(html),
|
|
||||||
description: root?.description || '',
|
|
||||||
home: root?.home || '',
|
|
||||||
title: root?.name || this.getMetaContent(html, 'og:title') || '',
|
|
||||||
url: root?.url || '',
|
|
||||||
},
|
|
||||||
sidebarMenu: this.getArgonMenuItems(html, 'leftbar_part1_menu'),
|
|
||||||
themeCardRadius: this.toNonNegativeNumber(
|
|
||||||
this.getMetaContent(html, 'theme-card-radius'),
|
|
||||||
4,
|
|
||||||
),
|
|
||||||
themeColor: this.getMetaContent(html, 'theme-color'),
|
|
||||||
themeColorRgb: this.getMetaContent(html, 'theme-color-rgb'),
|
|
||||||
themeVersion: this.getMetaContent(html, 'theme-version'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private getArgonBackgroundImage(html: string, selector = '#content:before') {
|
|
||||||
const block = this.getCssBlock(html, selector);
|
|
||||||
const rawValue =
|
|
||||||
/background(?:-image)?\s*:\s*url\(([^)]+)\)/i.exec(block)?.[1] || '';
|
|
||||||
const value = rawValue.trim().replace(/^['"]|['"]$/g, '');
|
|
||||||
|
|
||||||
return this.decodeUriText(this.decodeHtmlEntities(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
private getArgonBackgroundOpacity(html: string) {
|
|
||||||
const value = this.getCssDeclaration(
|
|
||||||
this.getCssBlock(html, '#content:before'),
|
|
||||||
'opacity',
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.toNonNegativeNumber(value, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getArgonBackgroundDarkOpacity(html: string) {
|
|
||||||
const darkValue = this.getCssDeclaration(
|
|
||||||
this.getCssBlock(html, 'html.darkmode #content:after'),
|
|
||||||
'opacity',
|
|
||||||
);
|
|
||||||
const baseValue = this.getCssDeclaration(
|
|
||||||
this.getCssBlock(html, '#content:after'),
|
|
||||||
'opacity',
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.toNonNegativeNumber(darkValue || baseValue, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getArgonBackgroundDarkBrightness(html: string) {
|
|
||||||
const filter = this.getCssDeclaration(
|
|
||||||
this.getCssBlock(html, 'html.darkmode #content:before'),
|
|
||||||
'filter',
|
|
||||||
);
|
|
||||||
const value = /brightness\(([^)]+)\)/i.exec(filter)?.[1] || '';
|
|
||||||
|
|
||||||
return this.toNonNegativeNumber(value, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getArgonAuthorAvatar(html: string) {
|
|
||||||
const style = this.getTagAttributeById(
|
|
||||||
html,
|
|
||||||
'leftbar_overview_author_image',
|
|
||||||
'style',
|
|
||||||
);
|
|
||||||
const value =
|
|
||||||
/background(?:-image)?\s*:\s*url\(([^)]+)\)/i.exec(style)?.[1] || '';
|
|
||||||
|
|
||||||
return this.decodeUriText(
|
|
||||||
this.decodeHtmlEntities(value.trim().replace(/^['"]|['"]$/g, '')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getArgonAuthorName(html: string) {
|
|
||||||
const pattern =
|
|
||||||
/<[^>]*id=["']leftbar_overview_author_name["'][^>]*>([\s\S]*?)<\/[^>]+>/i;
|
|
||||||
|
|
||||||
return this.stripHtml(pattern.exec(html)?.[1] || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private getArgonMenuItems(
|
|
||||||
html: string,
|
|
||||||
elementId: string,
|
|
||||||
): WordpressArgonMenuItem[] {
|
|
||||||
const menuHtml = this.getElementHtmlById(html, elementId);
|
|
||||||
|
|
||||||
return Array.from(menuHtml.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi))
|
|
||||||
.map((match) => {
|
|
||||||
const attrText = match[1] || '';
|
|
||||||
const href = this.getHtmlAttribute(attrText, 'href');
|
|
||||||
const label = this.stripHtml(match[2] || '');
|
|
||||||
const icon = /<i\b[^>]*class=["']([^"']*)["']/i.exec(match[2] || '')?.[1] || '';
|
|
||||||
|
|
||||||
if (!href || !label) return null;
|
|
||||||
|
|
||||||
const menuItem: WordpressArgonMenuItem = {
|
|
||||||
href: this.decodeHtmlEntities(href),
|
|
||||||
label,
|
|
||||||
};
|
|
||||||
const iconName = icon
|
|
||||||
.split(/\s+/)
|
|
||||||
.find((item) => item.startsWith('fa-'));
|
|
||||||
|
|
||||||
if (/^https?:\/\//i.test(href)) {
|
|
||||||
menuItem.external = true;
|
|
||||||
}
|
|
||||||
if (iconName) {
|
|
||||||
menuItem.icon = iconName;
|
|
||||||
}
|
|
||||||
|
|
||||||
return menuItem;
|
|
||||||
})
|
|
||||||
.filter((item): item is WordpressArgonMenuItem => !!item);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCssBlock(html: string, selector: string) {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`${this.escapeRegex(selector)}\\s*\\{(?<body>[^}]*)\\}`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
return pattern.exec(html)?.groups?.body || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCssDeclaration(block: string, property: string) {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`${this.escapeRegex(property)}\\s*:\\s*([^;]+)`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
return pattern.exec(block)?.[1]?.trim() || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTagAttributeById(html: string, id: string, attribute: string) {
|
|
||||||
const tagPattern = new RegExp(
|
|
||||||
`<[^>]*id=["']${this.escapeRegex(id)}["'][^>]*>`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
const tag = tagPattern.exec(html)?.[0] || '';
|
|
||||||
const attrPattern = new RegExp(
|
|
||||||
`${this.escapeRegex(attribute)}=["']([^"']*)["']`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
return attrPattern.exec(tag)?.[1] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private getElementHtmlById(html: string, id: string) {
|
|
||||||
const startPattern = new RegExp(
|
|
||||||
`<(?<tag>[a-z0-9-]+)[^>]*id=["']${this.escapeRegex(id)}["'][^>]*>`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
const startMatch = startPattern.exec(html);
|
|
||||||
if (!startMatch?.groups?.tag) return '';
|
|
||||||
|
|
||||||
const tag = startMatch.groups.tag;
|
|
||||||
const startIndex = startMatch.index + startMatch[0].length;
|
|
||||||
const endPattern = new RegExp(`</${this.escapeRegex(tag)}>`, 'i');
|
|
||||||
const endMatch = endPattern.exec(html.slice(startIndex));
|
|
||||||
|
|
||||||
return endMatch
|
|
||||||
? html.slice(startIndex, startIndex + endMatch.index)
|
|
||||||
: html.slice(startIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getHtmlAttribute(attrText: string, attribute: string) {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`${this.escapeRegex(attribute)}=["']([^"']*)["']`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
return pattern.exec(attrText)?.[1] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMetaContent(html: string, name: string) {
|
|
||||||
const escapedName = this.escapeRegex(name);
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`<meta[^>]+(?:name|property)=["']${escapedName}["'][^>]+content=["']([^"']*)["']`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.decodeHtmlEntities(pattern.exec(html)?.[1] || '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTagClassList(html: string, tagName: 'body' | 'html') {
|
|
||||||
const pattern = new RegExp(`<${tagName}[^>]*class=["']([^"']*)["']`, 'i');
|
|
||||||
const classValue = pattern.exec(html)?.[1] || '';
|
|
||||||
|
|
||||||
return classValue
|
|
||||||
.split(/\s+/)
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getScriptObjectBlock(html: string, name: string) {
|
|
||||||
const pattern = new RegExp(`${this.escapeRegex(name)}\\s*=\\s*\\{`, 'i');
|
|
||||||
const match = pattern.exec(html);
|
|
||||||
if (!match) return '';
|
|
||||||
|
|
||||||
return this.readBraceBlock(html, match.index + match[0].lastIndexOf('{'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private getJsObjectBlock(block: string, key: string) {
|
|
||||||
const pattern = new RegExp(`${this.escapeRegex(key)}\\s*:\\s*\\{`, 'i');
|
|
||||||
const match = pattern.exec(block);
|
|
||||||
if (!match) return '';
|
|
||||||
|
|
||||||
return this.readBraceBlock(block, match.index + match[0].lastIndexOf('{'));
|
|
||||||
}
|
|
||||||
|
|
||||||
private readBraceBlock(value: string, startIndex: number) {
|
|
||||||
let depth = 0;
|
|
||||||
for (let index = startIndex; index < value.length; index += 1) {
|
|
||||||
const char = value[index];
|
|
||||||
if (char === '{') depth += 1;
|
|
||||||
if (char === '}') depth -= 1;
|
|
||||||
if (depth === 0) {
|
|
||||||
return value.slice(startIndex + 1, index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private getInlineJsString(html: string, key: string) {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`${this.escapeRegex(key)}\\s*=\\s*["']([^"']*)["']`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
return pattern.exec(html)?.[1] || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
private getJsObjectString(block: string, key: string, fallback: string) {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`${this.escapeRegex(key)}\\s*:\\s*["']([^"']*)["']`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
|
|
||||||
return pattern.exec(block)?.[1] || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getJsObjectNumber(block: string, key: string, fallback: number) {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`${this.escapeRegex(key)}\\s*:\\s*([0-9]+)`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
const value = Number(pattern.exec(block)?.[1]);
|
|
||||||
|
|
||||||
return Number.isFinite(value) ? value : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getJsObjectBoolean(block: string, key: string, fallback: boolean) {
|
|
||||||
const rawValue = this.getJsObjectRawValue(block, key);
|
|
||||||
if (rawValue === 'true') return true;
|
|
||||||
if (rawValue === 'false') return false;
|
|
||||||
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private toNonNegativeNumber(value: unknown, fallback: number) {
|
|
||||||
const nextValue = Number(value);
|
|
||||||
|
|
||||||
return Number.isFinite(nextValue) && nextValue >= 0 ? nextValue : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getJsObjectRawValue(block: string, key: string) {
|
|
||||||
const pattern = new RegExp(
|
|
||||||
`${this.escapeRegex(key)}\\s*:\\s*(["'][^"']*["']|true|false|[0-9]+)`,
|
|
||||||
'i',
|
|
||||||
);
|
|
||||||
const value = pattern.exec(block)?.[1] || '';
|
|
||||||
|
|
||||||
return value.replace(/^["']|["']$/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeHtmlEntities(value: string) {
|
|
||||||
return value
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'")
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeUriText(value: string) {
|
|
||||||
try {
|
|
||||||
return decodeURI(value);
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private escapeRegex(value: string) {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
private getWordpressResponseErrorMessage(data: any, status: number) {
|
private getWordpressResponseErrorMessage(data: any, status: number) {
|
||||||
if (data?.message) return data.message;
|
if (data?.message) return data.message;
|
||||||
if (typeof data === 'string' && data) return data;
|
if (typeof data === 'string' && data) return data;
|
||||||
|
|||||||
@ -46,7 +46,7 @@ export type WordpressPagedQueryDto =
|
|||||||
| WordpressTermListQueryDto;
|
| WordpressTermListQueryDto;
|
||||||
|
|
||||||
export type WordpressRequestOptions = {
|
export type WordpressRequestOptions = {
|
||||||
auth?: WordpressAuthContext;
|
auth: WordpressAuthContext;
|
||||||
body?: Record<string, unknown>;
|
body?: Record<string, unknown>;
|
||||||
method?: 'GET' | 'POST' | 'DELETE';
|
method?: 'GET' | 'POST' | 'DELETE';
|
||||||
query?: Record<string, unknown>;
|
query?: Record<string, unknown>;
|
||||||
@ -56,59 +56,3 @@ export type WordpressResponse<T> = {
|
|||||||
data: T;
|
data: T;
|
||||||
total?: number;
|
total?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WordpressArgonMenuItem = {
|
|
||||||
external?: boolean;
|
|
||||||
href: string;
|
|
||||||
icon?: string;
|
|
||||||
label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WordpressArgonThemeConfig = {
|
|
||||||
argonConfig: {
|
|
||||||
codeHighlight: {
|
|
||||||
breakLine: boolean;
|
|
||||||
enable: boolean;
|
|
||||||
hideLinenumber: boolean;
|
|
||||||
transparentLinenumber: boolean;
|
|
||||||
};
|
|
||||||
dateFormat: string;
|
|
||||||
disablePjax: boolean;
|
|
||||||
foldLongComments: boolean;
|
|
||||||
foldLongShuoshuo: boolean;
|
|
||||||
headroom: boolean | string;
|
|
||||||
language: string;
|
|
||||||
lazyload: {
|
|
||||||
effect: string;
|
|
||||||
threshold: number;
|
|
||||||
};
|
|
||||||
pangu: string;
|
|
||||||
pjaxAnimationDuration: number;
|
|
||||||
waterflowColumns: number | string;
|
|
||||||
wpPath: string;
|
|
||||||
zoomify: boolean;
|
|
||||||
};
|
|
||||||
backgroundDarkBrightness?: number;
|
|
||||||
backgroundDarkImage?: string;
|
|
||||||
backgroundDarkOpacity?: number;
|
|
||||||
backgroundImage?: string;
|
|
||||||
backgroundOpacity?: number;
|
|
||||||
bodyClass: string[];
|
|
||||||
darkmodeAutoSwitch: string;
|
|
||||||
enableCustomThemeColor: boolean;
|
|
||||||
headerMenu?: WordpressArgonMenuItem[];
|
|
||||||
htmlClass: string[];
|
|
||||||
site: {
|
|
||||||
authorAvatar?: string;
|
|
||||||
authorName?: string;
|
|
||||||
description: string;
|
|
||||||
home: string;
|
|
||||||
title: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
sidebarMenu?: WordpressArgonMenuItem[];
|
|
||||||
themeCardRadius: number;
|
|
||||||
themeColor: string;
|
|
||||||
themeColorRgb: string;
|
|
||||||
themeVersion: string;
|
|
||||||
};
|
|
||||||
|
|||||||
@ -15,16 +15,9 @@ import { SystemLogController } from '../src/admin/system-log/system-log.controll
|
|||||||
import { SystemLogService } from '../src/admin/system-log/system-log.service';
|
import { SystemLogService } from '../src/admin/system-log/system-log.service';
|
||||||
import {
|
import {
|
||||||
ApiExceptionFilter,
|
ApiExceptionFilter,
|
||||||
IS_PUBLIC_KEY,
|
|
||||||
SaveBodyInterceptor,
|
SaveBodyInterceptor,
|
||||||
ToolsService,
|
ToolsService,
|
||||||
} from '../src/common';
|
} from '../src/common';
|
||||||
import { BlogArticleController } from '../src/blog/blog-article.controller';
|
|
||||||
import { BlogArticleService } from '../src/blog/blog-article.service';
|
|
||||||
import { BlogThemeConfigController } from '../src/blog/blog-theme-config.controller';
|
|
||||||
import { BlogThemeConfigService } from '../src/blog/blog-theme-config.service';
|
|
||||||
import { BlogTermController } from '../src/blog/blog-term.controller';
|
|
||||||
import { BlogTermService } from '../src/blog/blog-term.service';
|
|
||||||
import { MinioClientController } from '../src/minio/minio.controller';
|
import { MinioClientController } from '../src/minio/minio.controller';
|
||||||
import { MinioClientService } from '../src/minio/minio.service';
|
import { MinioClientService } from '../src/minio/minio.service';
|
||||||
import { WordpressArticleController } from '../src/wordpress/wordpress-article.controller';
|
import { WordpressArticleController } from '../src/wordpress/wordpress-article.controller';
|
||||||
@ -32,7 +25,6 @@ import { WordpressAuthController } from '../src/wordpress/wordpress-auth.control
|
|||||||
import { WordpressCategoryController } from '../src/wordpress/wordpress-category.controller';
|
import { WordpressCategoryController } from '../src/wordpress/wordpress-category.controller';
|
||||||
import { WordpressService } from '../src/wordpress/wordpress.service';
|
import { WordpressService } from '../src/wordpress/wordpress.service';
|
||||||
import { WordpressTagController } from '../src/wordpress/wordpress-tag.controller';
|
import { WordpressTagController } from '../src/wordpress/wordpress-tag.controller';
|
||||||
import { WordpressThemeController } from '../src/wordpress/wordpress-theme.controller';
|
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
import {
|
import {
|
||||||
collectControllerRoutes,
|
collectControllerRoutes,
|
||||||
@ -162,56 +154,12 @@ const wordpressArticle = {
|
|||||||
status: 'draft',
|
status: 'draft',
|
||||||
};
|
};
|
||||||
|
|
||||||
const blogArticle = {
|
|
||||||
authorName: 'KwiTsukasa',
|
|
||||||
categories: ['技术'],
|
|
||||||
categoriesResolved: [{ name: '技术', slug: 'tech' }],
|
|
||||||
contentHtml: '<h1>本地文章</h1>',
|
|
||||||
contentMarkdown: '# 本地文章',
|
|
||||||
id: '2041800000000000001',
|
|
||||||
slug: 'local-article',
|
|
||||||
status: 'draft',
|
|
||||||
tags: ['Milkdown'],
|
|
||||||
tagsResolved: [{ name: 'Milkdown', slug: 'milkdown' }],
|
|
||||||
title: '本地文章',
|
|
||||||
};
|
|
||||||
|
|
||||||
const wordpressTerm = {
|
const wordpressTerm = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'WordPress 分类',
|
name: 'WordPress 分类',
|
||||||
slug: 'wordpress-category',
|
slug: 'wordpress-category',
|
||||||
};
|
};
|
||||||
|
|
||||||
const blogTerm = {
|
|
||||||
count: 1,
|
|
||||||
description: '本地分类描述',
|
|
||||||
id: '2041800000000000100',
|
|
||||||
name: '技术',
|
|
||||||
slug: 'tech',
|
|
||||||
};
|
|
||||||
|
|
||||||
const wordpressThemeConfig = {
|
|
||||||
bodyClass: ['home', 'blog', 'wp-theme-argon'],
|
|
||||||
darkmodeAutoSwitch: 'alwayson',
|
|
||||||
enableCustomThemeColor: true,
|
|
||||||
htmlClass: [
|
|
||||||
'triple-column',
|
|
||||||
'immersion-color',
|
|
||||||
'toolbar-blur',
|
|
||||||
'article-header-style-default',
|
|
||||||
],
|
|
||||||
site: {
|
|
||||||
description: '',
|
|
||||||
home: 'https://blog.kwitsukasa.top',
|
|
||||||
title: 'KwiTsukasa的小站',
|
|
||||||
url: 'https://blog.kwitsukasa.top',
|
|
||||||
},
|
|
||||||
themeCardRadius: 4,
|
|
||||||
themeColor: '#c3a1ed',
|
|
||||||
themeColorRgb: '195,161,237',
|
|
||||||
themeVersion: '1.3.5',
|
|
||||||
};
|
|
||||||
|
|
||||||
const componentServiceMock = {
|
const componentServiceMock = {
|
||||||
all: jest.fn(),
|
all: jest.fn(),
|
||||||
page: jest.fn(),
|
page: jest.fn(),
|
||||||
@ -271,34 +219,6 @@ const minioServiceMock = {
|
|||||||
removeObject: jest.fn(),
|
removeObject: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const blogArticleServiceMock = {
|
|
||||||
page: jest.fn(),
|
|
||||||
detail: jest.fn(),
|
|
||||||
save: jest.fn(),
|
|
||||||
update: jest.fn(),
|
|
||||||
remove: jest.fn(),
|
|
||||||
categoryOptions: jest.fn(),
|
|
||||||
tagOptions: jest.fn(),
|
|
||||||
publicList: jest.fn(),
|
|
||||||
publicDetail: jest.fn(),
|
|
||||||
importFromWordpress: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const blogTermServiceMock = {
|
|
||||||
detail: jest.fn(),
|
|
||||||
options: jest.fn(),
|
|
||||||
page: jest.fn(),
|
|
||||||
remove: jest.fn(),
|
|
||||||
save: jest.fn(),
|
|
||||||
update: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const blogThemeConfigServiceMock = {
|
|
||||||
importFromWordpress: jest.fn(),
|
|
||||||
publicConfig: jest.fn(),
|
|
||||||
save: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const wordpressServiceMock = {
|
const wordpressServiceMock = {
|
||||||
getAuthContext: jest.fn(),
|
getAuthContext: jest.fn(),
|
||||||
loginWithConfiguredAdmin: jest.fn(),
|
loginWithConfiguredAdmin: jest.fn(),
|
||||||
@ -310,8 +230,6 @@ const wordpressServiceMock = {
|
|||||||
articleSave: jest.fn(),
|
articleSave: jest.fn(),
|
||||||
articleUpdate: jest.fn(),
|
articleUpdate: jest.fn(),
|
||||||
articleRemove: jest.fn(),
|
articleRemove: jest.fn(),
|
||||||
publicArticleList: jest.fn(),
|
|
||||||
publicArticleDetail: jest.fn(),
|
|
||||||
tagList: jest.fn(),
|
tagList: jest.fn(),
|
||||||
tagDetail: jest.fn(),
|
tagDetail: jest.fn(),
|
||||||
tagSave: jest.fn(),
|
tagSave: jest.fn(),
|
||||||
@ -322,7 +240,6 @@ const wordpressServiceMock = {
|
|||||||
categorySave: jest.fn(),
|
categorySave: jest.fn(),
|
||||||
categoryUpdate: jest.fn(),
|
categoryUpdate: jest.fn(),
|
||||||
categoryRemove: jest.fn(),
|
categoryRemove: jest.fn(),
|
||||||
themeConfig: jest.fn(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const controllerClasses = [
|
const controllerClasses = [
|
||||||
@ -330,15 +247,11 @@ const controllerClasses = [
|
|||||||
ComponentController,
|
ComponentController,
|
||||||
DictController,
|
DictController,
|
||||||
SystemLogController,
|
SystemLogController,
|
||||||
BlogArticleController,
|
|
||||||
BlogTermController,
|
|
||||||
BlogThemeConfigController,
|
|
||||||
MinioClientController,
|
MinioClientController,
|
||||||
WordpressAuthController,
|
WordpressAuthController,
|
||||||
WordpressArticleController,
|
WordpressArticleController,
|
||||||
WordpressTagController,
|
WordpressTagController,
|
||||||
WordpressCategoryController,
|
WordpressCategoryController,
|
||||||
WordpressThemeController,
|
|
||||||
];
|
];
|
||||||
const controllerRoutes = collectControllerRoutes(controllerClasses);
|
const controllerRoutes = collectControllerRoutes(controllerClasses);
|
||||||
|
|
||||||
@ -477,522 +390,6 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /blog/article/list': async (server) => {
|
|
||||||
blogArticleServiceMock.page.mockResolvedValue({
|
|
||||||
list: [blogArticle],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/article/list')
|
|
||||||
.query({ pageNo: 1, pageSize: 10, search: '本地' })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogArticleServiceMock.page).toHaveBeenCalledWith({
|
|
||||||
pageNo: '1',
|
|
||||||
pageSize: '10',
|
|
||||||
search: '本地',
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: {
|
|
||||||
list: [blogArticle],
|
|
||||||
total: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/article/detail': async (server) => {
|
|
||||||
blogArticleServiceMock.detail.mockResolvedValue(blogArticle);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/article/detail')
|
|
||||||
.query({ id: blogArticle.id })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogArticleServiceMock.detail).toHaveBeenCalledWith(blogArticle.id);
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: blogArticle,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/article/public/list': async (server) => {
|
|
||||||
blogArticleServiceMock.publicList.mockResolvedValue({
|
|
||||||
list: [blogArticle],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/article/public/list')
|
|
||||||
.query({ pageNo: 1, pageSize: 10 })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogArticleServiceMock.publicList).toHaveBeenCalledWith({
|
|
||||||
pageNo: '1',
|
|
||||||
pageSize: '10',
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: {
|
|
||||||
list: [blogArticle],
|
|
||||||
total: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/article/public/detail': async (server) => {
|
|
||||||
blogArticleServiceMock.publicDetail.mockResolvedValue(blogArticle);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/article/public/detail')
|
|
||||||
.query({ slug: blogArticle.slug })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogArticleServiceMock.publicDetail).toHaveBeenCalledWith({
|
|
||||||
id: undefined,
|
|
||||||
slug: blogArticle.slug,
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: blogArticle,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/article/save': async (server) => {
|
|
||||||
blogArticleServiceMock.save.mockResolvedValue(blogArticle);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/article/save')
|
|
||||||
.send({
|
|
||||||
content: '# 本地文章',
|
|
||||||
contentFormat: 'markdown',
|
|
||||||
title: '本地文章',
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogArticleServiceMock.save).toHaveBeenCalledWith({
|
|
||||||
content: '# 本地文章',
|
|
||||||
contentFormat: 'markdown',
|
|
||||||
title: '本地文章',
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: blogArticle,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/article/update': async (server) => {
|
|
||||||
blogArticleServiceMock.update.mockResolvedValue(blogArticle);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/article/update')
|
|
||||||
.send({
|
|
||||||
id: blogArticle.id,
|
|
||||||
title: '本地文章',
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogArticleServiceMock.update).toHaveBeenCalledWith({
|
|
||||||
id: blogArticle.id,
|
|
||||||
title: '本地文章',
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: blogArticle,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/article/remove': async (server) => {
|
|
||||||
blogArticleServiceMock.remove.mockResolvedValue(true);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/article/remove')
|
|
||||||
.query({ id: blogArticle.id })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogArticleServiceMock.remove).toHaveBeenCalledWith(blogArticle.id);
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/article/category-options': async (server) => {
|
|
||||||
blogArticleServiceMock.categoryOptions.mockResolvedValue({
|
|
||||||
list: [{ count: 1, id: 'tech', name: '技术', slug: 'tech' }],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/article/category-options')
|
|
||||||
.query({ pageNo: 1, pageSize: 20, search: '技' })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogArticleServiceMock.categoryOptions).toHaveBeenCalledWith({
|
|
||||||
pageNo: '1',
|
|
||||||
pageSize: '20',
|
|
||||||
search: '技',
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: {
|
|
||||||
list: [{ count: 1, id: 'tech', name: '技术', slug: 'tech' }],
|
|
||||||
total: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/article/tag-options': async (server) => {
|
|
||||||
blogArticleServiceMock.tagOptions.mockResolvedValue({
|
|
||||||
list: [{ count: 1, id: 'milkdown', name: 'Milkdown', slug: 'milkdown' }],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/article/tag-options')
|
|
||||||
.query({ pageNo: 1, pageSize: 20, search: 'milk' })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogArticleServiceMock.tagOptions).toHaveBeenCalledWith({
|
|
||||||
pageNo: '1',
|
|
||||||
pageSize: '20',
|
|
||||||
search: 'milk',
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: {
|
|
||||||
list: [
|
|
||||||
{ count: 1, id: 'milkdown', name: 'Milkdown', slug: 'milkdown' },
|
|
||||||
],
|
|
||||||
total: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/article/import-wordpress': async (server) => {
|
|
||||||
blogArticleServiceMock.importFromWordpress.mockResolvedValue({
|
|
||||||
created: 1,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
action: 'created',
|
|
||||||
id: blogArticle.id,
|
|
||||||
slug: blogArticle.slug,
|
|
||||||
title: blogArticle.title,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
skipped: 0,
|
|
||||||
total: 1,
|
|
||||||
updated: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/article/import-wordpress')
|
|
||||||
.send({
|
|
||||||
overwrite: false,
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogArticleServiceMock.importFromWordpress).toHaveBeenCalledWith({
|
|
||||||
overwrite: false,
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: {
|
|
||||||
created: 1,
|
|
||||||
skipped: 0,
|
|
||||||
total: 1,
|
|
||||||
updated: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/category/list': async (server) => {
|
|
||||||
blogTermServiceMock.page.mockResolvedValue({
|
|
||||||
list: [blogTerm],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/category/list')
|
|
||||||
.query({ pageNo: 1, pageSize: 10, search: '技' })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.page).toHaveBeenCalledWith('category', {
|
|
||||||
pageNo: '1',
|
|
||||||
pageSize: '10',
|
|
||||||
search: '技',
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: {
|
|
||||||
list: [blogTerm],
|
|
||||||
total: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/category/detail': async (server) => {
|
|
||||||
blogTermServiceMock.detail.mockResolvedValue(blogTerm);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/category/detail')
|
|
||||||
.query({ id: blogTerm.id })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.detail).toHaveBeenCalledWith(
|
|
||||||
'category',
|
|
||||||
blogTerm.id,
|
|
||||||
);
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: blogTerm,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/category/save': async (server) => {
|
|
||||||
blogTermServiceMock.save.mockResolvedValue(blogTerm);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/category/save')
|
|
||||||
.send({
|
|
||||||
description: blogTerm.description,
|
|
||||||
name: blogTerm.name,
|
|
||||||
slug: blogTerm.slug,
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.save).toHaveBeenCalledWith('category', {
|
|
||||||
description: blogTerm.description,
|
|
||||||
name: blogTerm.name,
|
|
||||||
slug: blogTerm.slug,
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: blogTerm,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/category/update': async (server) => {
|
|
||||||
blogTermServiceMock.update.mockResolvedValue(blogTerm);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/category/update')
|
|
||||||
.send({
|
|
||||||
id: blogTerm.id,
|
|
||||||
name: blogTerm.name,
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.update).toHaveBeenCalledWith('category', {
|
|
||||||
id: blogTerm.id,
|
|
||||||
name: blogTerm.name,
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: blogTerm,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/category/remove': async (server) => {
|
|
||||||
blogTermServiceMock.remove.mockResolvedValue(true);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/category/remove')
|
|
||||||
.query({ id: blogTerm.id })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.remove).toHaveBeenCalledWith(
|
|
||||||
'category',
|
|
||||||
blogTerm.id,
|
|
||||||
);
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/tag/list': async (server) => {
|
|
||||||
blogTermServiceMock.page.mockResolvedValue({
|
|
||||||
list: [blogTerm],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/tag/list')
|
|
||||||
.query({ pageNo: 1, pageSize: 10, search: '技' })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.page).toHaveBeenCalledWith('tag', {
|
|
||||||
pageNo: '1',
|
|
||||||
pageSize: '10',
|
|
||||||
search: '技',
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: {
|
|
||||||
list: [blogTerm],
|
|
||||||
total: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/tag/detail': async (server) => {
|
|
||||||
blogTermServiceMock.detail.mockResolvedValue(blogTerm);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/tag/detail')
|
|
||||||
.query({ id: blogTerm.id })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.detail).toHaveBeenCalledWith('tag', blogTerm.id);
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: blogTerm,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/tag/save': async (server) => {
|
|
||||||
blogTermServiceMock.save.mockResolvedValue(blogTerm);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/tag/save')
|
|
||||||
.send({
|
|
||||||
name: blogTerm.name,
|
|
||||||
slug: blogTerm.slug,
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.save).toHaveBeenCalledWith('tag', {
|
|
||||||
name: blogTerm.name,
|
|
||||||
slug: blogTerm.slug,
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: blogTerm,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/tag/update': async (server) => {
|
|
||||||
blogTermServiceMock.update.mockResolvedValue(blogTerm);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/tag/update')
|
|
||||||
.send({
|
|
||||||
id: blogTerm.id,
|
|
||||||
name: blogTerm.name,
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.update).toHaveBeenCalledWith('tag', {
|
|
||||||
id: blogTerm.id,
|
|
||||||
name: blogTerm.name,
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: blogTerm,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/tag/remove': async (server) => {
|
|
||||||
blogTermServiceMock.remove.mockResolvedValue(true);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/tag/remove')
|
|
||||||
.query({ id: blogTerm.id })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.remove).toHaveBeenCalledWith('tag', blogTerm.id);
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/term/options': async (server) => {
|
|
||||||
blogTermServiceMock.options.mockResolvedValue({
|
|
||||||
list: [blogTerm],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/term/options')
|
|
||||||
.query({ kind: 'category', pageNo: 1, pageSize: 10 })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogTermServiceMock.options).toHaveBeenCalledWith('category', {
|
|
||||||
kind: 'category',
|
|
||||||
pageNo: '1',
|
|
||||||
pageSize: '10',
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: {
|
|
||||||
list: [blogTerm],
|
|
||||||
total: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /blog/theme/config': async (server) => {
|
|
||||||
blogThemeConfigServiceMock.publicConfig.mockResolvedValue(
|
|
||||||
wordpressThemeConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/blog/theme/config')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogThemeConfigServiceMock.publicConfig).toHaveBeenCalledWith();
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: wordpressThemeConfig,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/theme/save': async (server) => {
|
|
||||||
blogThemeConfigServiceMock.save.mockResolvedValue(wordpressThemeConfig);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/theme/save')
|
|
||||||
.send({
|
|
||||||
config: wordpressThemeConfig,
|
|
||||||
source: 'local-admin',
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(blogThemeConfigServiceMock.save).toHaveBeenCalledWith({
|
|
||||||
config: wordpressThemeConfig,
|
|
||||||
source: 'local-admin',
|
|
||||||
});
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: wordpressThemeConfig,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /blog/theme/import-wordpress': async (server) => {
|
|
||||||
blogThemeConfigServiceMock.importFromWordpress.mockResolvedValue(
|
|
||||||
wordpressThemeConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.post('/blog/theme/import-wordpress')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
blogThemeConfigServiceMock.importFromWordpress,
|
|
||||||
).toHaveBeenCalledWith();
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: wordpressThemeConfig,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /dict/getDictByKey': async (server) => {
|
'GET /dict/getDictByKey': async (server) => {
|
||||||
dictServiceMock.getDictByKey.mockResolvedValue(dictOptions);
|
dictServiceMock.getDictByKey.mockResolvedValue(dictOptions);
|
||||||
|
|
||||||
@ -1600,55 +997,6 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /wordpress/article/public/list': async (server) => {
|
|
||||||
wordpressServiceMock.publicArticleList.mockResolvedValue({
|
|
||||||
list: [wordpressArticle],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/wordpress/article/public/list')
|
|
||||||
.query({
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
search: '文章',
|
|
||||||
})
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(wordpressServiceMock.publicArticleList).toHaveBeenCalledWith({
|
|
||||||
pageNo: '1',
|
|
||||||
pageSize: '10',
|
|
||||||
search: '文章',
|
|
||||||
});
|
|
||||||
expect(wordpressServiceMock.getAuthContext).not.toHaveBeenCalled();
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: {
|
|
||||||
list: [wordpressArticle],
|
|
||||||
total: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'GET /wordpress/article/public/detail': async (server) => {
|
|
||||||
wordpressServiceMock.publicArticleDetail.mockResolvedValue(wordpressArticle);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/wordpress/article/public/detail')
|
|
||||||
.query({ slug: 'wordpress-article' })
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(wordpressServiceMock.publicArticleDetail).toHaveBeenCalledWith({
|
|
||||||
id: undefined,
|
|
||||||
slug: 'wordpress-article',
|
|
||||||
});
|
|
||||||
expect(wordpressServiceMock.getAuthContext).not.toHaveBeenCalled();
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: wordpressArticle,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
'POST /wordpress/article/save': async (server) => {
|
'POST /wordpress/article/save': async (server) => {
|
||||||
wordpressServiceMock.articleSave.mockResolvedValue(wordpressArticle);
|
wordpressServiceMock.articleSave.mockResolvedValue(wordpressArticle);
|
||||||
|
|
||||||
@ -1955,21 +1303,6 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
|||||||
data: true,
|
data: true,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
'GET /wordpress/theme/config': async (server) => {
|
|
||||||
wordpressServiceMock.themeConfig.mockResolvedValue(wordpressThemeConfig);
|
|
||||||
|
|
||||||
const response = await request(server)
|
|
||||||
.get('/wordpress/theme/config')
|
|
||||||
.expect(200);
|
|
||||||
|
|
||||||
expect(wordpressServiceMock.themeConfig).toHaveBeenCalledWith();
|
|
||||||
expect(wordpressServiceMock.getAuthContext).not.toHaveBeenCalled();
|
|
||||||
expect(response.body).toMatchObject({
|
|
||||||
code: 200,
|
|
||||||
data: wordpressThemeConfig,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('KT Template Online API (e2e)', () => {
|
describe('KT Template Online API (e2e)', () => {
|
||||||
@ -1999,18 +1332,6 @@ describe('KT Template Online API (e2e)', () => {
|
|||||||
provide: SystemLogService,
|
provide: SystemLogService,
|
||||||
useValue: systemLogServiceMock,
|
useValue: systemLogServiceMock,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: BlogArticleService,
|
|
||||||
useValue: blogArticleServiceMock,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: BlogTermService,
|
|
||||||
useValue: blogTermServiceMock,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: BlogThemeConfigService,
|
|
||||||
useValue: blogThemeConfigServiceMock,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provide: MinioClientService,
|
provide: MinioClientService,
|
||||||
useValue: minioServiceMock,
|
useValue: minioServiceMock,
|
||||||
@ -2057,27 +1378,6 @@ describe('KT Template Online API (e2e)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps Blog Web runtime endpoints public for WordPress replacement', () => {
|
|
||||||
expect(
|
|
||||||
Reflect.getMetadata(
|
|
||||||
IS_PUBLIC_KEY,
|
|
||||||
BlogArticleController.prototype.publicList,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
Reflect.getMetadata(
|
|
||||||
IS_PUBLIC_KEY,
|
|
||||||
BlogArticleController.prototype.publicDetail,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
Reflect.getMetadata(
|
|
||||||
IS_PUBLIC_KEY,
|
|
||||||
BlogThemeConfigController.prototype.config,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('generated route smoke tests', () => {
|
describe('generated route smoke tests', () => {
|
||||||
controllerRoutes.forEach((route) => {
|
controllerRoutes.forEach((route) => {
|
||||||
const key = routeKey(route);
|
const key = routeKey(route);
|
||||||
|
|||||||
@ -1,323 +0,0 @@
|
|||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { MarkdownService, ToolsService } from '../../src/common';
|
|
||||||
import { BlogArticle } from '../../src/blog/blog-article.entity';
|
|
||||||
import { BlogArticleService } from '../../src/blog/blog-article.service';
|
|
||||||
import { BlogTermService } from '../../src/blog/blog-term.service';
|
|
||||||
import { WordpressService } from '../../src/wordpress/wordpress.service';
|
|
||||||
|
|
||||||
describe('BlogArticleService', () => {
|
|
||||||
let service: BlogArticleService;
|
|
||||||
let markdownService: MarkdownService;
|
|
||||||
let wordpressService: {
|
|
||||||
publicArticleDetail: jest.Mock;
|
|
||||||
publicArticleList: jest.Mock;
|
|
||||||
};
|
|
||||||
let blogTermService: {
|
|
||||||
options: jest.Mock;
|
|
||||||
syncTerms: jest.Mock;
|
|
||||||
};
|
|
||||||
let repository: {
|
|
||||||
create: jest.Mock;
|
|
||||||
createQueryBuilder: jest.Mock;
|
|
||||||
find: jest.Mock;
|
|
||||||
findOne: jest.Mock;
|
|
||||||
save: jest.Mock;
|
|
||||||
update: jest.Mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
repository = {
|
|
||||||
create: jest.fn((payload) => ({ id: '1', ...payload })),
|
|
||||||
createQueryBuilder: jest.fn(),
|
|
||||||
find: jest.fn(),
|
|
||||||
findOne: jest.fn(),
|
|
||||||
save: jest.fn(async (article) => article),
|
|
||||||
update: jest.fn(),
|
|
||||||
};
|
|
||||||
markdownService = new MarkdownService();
|
|
||||||
jest
|
|
||||||
.spyOn(markdownService, 'renderToHtml')
|
|
||||||
.mockResolvedValue('<h1>标题</h1>\n<p>正文</p>');
|
|
||||||
jest
|
|
||||||
.spyOn(markdownService, 'renderHtmlToMarkdown')
|
|
||||||
.mockResolvedValue('# 导入标题\n\n导入正文');
|
|
||||||
jest
|
|
||||||
.spyOn(markdownService, 'sanitizeHtml')
|
|
||||||
.mockImplementation(async (html) =>
|
|
||||||
`${html ?? ''}`.replace(/<script[\s\S]*?<\/script>/g, ''),
|
|
||||||
);
|
|
||||||
wordpressService = {
|
|
||||||
publicArticleDetail: jest.fn(),
|
|
||||||
publicArticleList: jest.fn(),
|
|
||||||
};
|
|
||||||
blogTermService = {
|
|
||||||
options: jest.fn(),
|
|
||||||
syncTerms: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
BlogArticleService,
|
|
||||||
ToolsService,
|
|
||||||
{
|
|
||||||
provide: MarkdownService,
|
|
||||||
useValue: markdownService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: getRepositoryToken(BlogArticle),
|
|
||||||
useValue: repository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: WordpressService,
|
|
||||||
useValue: wordpressService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: BlogTermService,
|
|
||||||
useValue: blogTermService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = moduleRef.get(BlogArticleService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders markdown and normalizes local article fields before saving', async () => {
|
|
||||||
const result = await service.save({
|
|
||||||
categories: ['技术'],
|
|
||||||
content: '# 标题\n\n正文',
|
|
||||||
contentFormat: 'markdown',
|
|
||||||
status: 'publish',
|
|
||||||
tags: ['Milkdown'],
|
|
||||||
title: '测试 文章',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(markdownService.renderToHtml).toHaveBeenCalledWith('# 标题\n\n正文');
|
|
||||||
expect(repository.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
categoryItems: [{ name: '技术', slug: '技术' }],
|
|
||||||
contentHtml: '<h1>标题</h1>\n<p>正文</p>',
|
|
||||||
contentMarkdown: '# 标题\n\n正文',
|
|
||||||
slug: '测试-文章',
|
|
||||||
status: 'publish',
|
|
||||||
tagItems: [{ name: 'Milkdown', slug: 'milkdown' }],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(blogTermService.syncTerms).toHaveBeenCalledWith('category', [
|
|
||||||
{ name: '技术', slug: '技术' },
|
|
||||||
]);
|
|
||||||
expect(blogTermService.syncTerms).toHaveBeenCalledWith('tag', [
|
|
||||||
{ name: 'Milkdown', slug: 'milkdown' },
|
|
||||||
]);
|
|
||||||
expect(repository.create.mock.calls[0][0].publishTime).toBeInstanceOf(Date);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
categories: ['技术'],
|
|
||||||
categoriesResolved: [{ name: '技术', slug: '技术' }],
|
|
||||||
excerptText: '标题 正文',
|
|
||||||
tags: ['Milkdown'],
|
|
||||||
tagsResolved: [{ name: 'Milkdown', slug: 'milkdown' }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('queries public list with publish status and maps response fields', async () => {
|
|
||||||
const builder = createQueryBuilderMock([
|
|
||||||
{
|
|
||||||
categoryItems: [{ name: '技术', slug: 'tech' }],
|
|
||||||
contentHtml: '<p>正文</p>',
|
|
||||||
id: '1',
|
|
||||||
isDeleted: false,
|
|
||||||
slug: 'demo',
|
|
||||||
status: 'publish',
|
|
||||||
tagItems: [{ name: 'Milkdown', slug: 'milkdown' }],
|
|
||||||
title: 'Demo',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
repository.createQueryBuilder.mockReturnValue(builder);
|
|
||||||
|
|
||||||
const result = await service.publicList({
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(builder.andWhere).toHaveBeenCalledWith('article.status = :status', {
|
|
||||||
status: 'publish',
|
|
||||||
});
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
categories: ['技术'],
|
|
||||||
categoriesResolved: [{ name: '技术', slug: 'tech' }],
|
|
||||||
tags: ['Milkdown'],
|
|
||||||
tagsResolved: [{ name: 'Milkdown', slug: 'milkdown' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('imports WordPress public articles into local blog articles', async () => {
|
|
||||||
wordpressService.publicArticleList.mockResolvedValue({
|
|
||||||
list: [{ id: 50, slug: 'wordpress-post' }],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
wordpressService.publicArticleDetail.mockResolvedValue({
|
|
||||||
authorName: 'WordPress 作者',
|
|
||||||
categoriesResolved: [{ id: 1, name: 'NAS', slug: 'nas' }],
|
|
||||||
contentHtml: '<h1>导入标题</h1><p>导入正文</p><script>alert(1)</script>',
|
|
||||||
date: '2026-06-05T10:30:00',
|
|
||||||
excerptText: '导入摘要',
|
|
||||||
id: 50,
|
|
||||||
slug: 'wordpress-post',
|
|
||||||
status: 'publish',
|
|
||||||
tagsResolved: [{ id: 2, name: 'Milkdown', slug: 'milkdown' }],
|
|
||||||
title: { rendered: '导入标题' },
|
|
||||||
});
|
|
||||||
repository.findOne.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await service.importFromWordpress({
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(wordpressService.publicArticleList).toHaveBeenCalledWith({
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
expect(wordpressService.publicArticleDetail).toHaveBeenCalledWith({
|
|
||||||
id: 50,
|
|
||||||
slug: 'wordpress-post',
|
|
||||||
});
|
|
||||||
expect(repository.create).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
authorName: 'WordPress 作者',
|
|
||||||
categoryItems: [{ id: '1', name: 'NAS', slug: 'nas' }],
|
|
||||||
contentHtml: '<h1>导入标题</h1><p>导入正文</p>',
|
|
||||||
contentMarkdown: '# 导入标题\n\n导入正文',
|
|
||||||
excerpt: '导入摘要',
|
|
||||||
slug: 'wordpress-post',
|
|
||||||
status: 'publish',
|
|
||||||
tagItems: [{ id: '2', name: 'Milkdown', slug: 'milkdown' }],
|
|
||||||
title: '导入标题',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(blogTermService.syncTerms).toHaveBeenCalledWith('category', [
|
|
||||||
{ id: '1', name: 'NAS', slug: 'nas' },
|
|
||||||
]);
|
|
||||||
expect(blogTermService.syncTerms).toHaveBeenCalledWith('tag', [
|
|
||||||
{ id: '2', name: 'Milkdown', slug: 'milkdown' },
|
|
||||||
]);
|
|
||||||
expect(repository.create.mock.calls[0][0].publishTime).toBeInstanceOf(Date);
|
|
||||||
expect(markdownService.sanitizeHtml).toHaveBeenCalledWith(
|
|
||||||
'<h1>导入标题</h1><p>导入正文</p><script>alert(1)</script>',
|
|
||||||
);
|
|
||||||
expect(markdownService.renderHtmlToMarkdown).toHaveBeenCalledWith(
|
|
||||||
'<h1>导入标题</h1><p>导入正文</p><script>alert(1)</script>',
|
|
||||||
);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
created: 1,
|
|
||||||
skipped: 0,
|
|
||||||
total: 1,
|
|
||||||
updated: 0,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
action: 'created',
|
|
||||||
id: '1',
|
|
||||||
slug: 'wordpress-post',
|
|
||||||
title: '导入标题',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('imports all WordPress article pages when all is enabled', async () => {
|
|
||||||
wordpressService.publicArticleList
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
list: [
|
|
||||||
{ id: 50, slug: 'wordpress-post-1' },
|
|
||||||
{ id: 51, slug: 'wordpress-post-2' },
|
|
||||||
],
|
|
||||||
total: 3,
|
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
list: [{ id: 52, slug: 'wordpress-post-3' }],
|
|
||||||
total: 3,
|
|
||||||
});
|
|
||||||
wordpressService.publicArticleDetail.mockImplementation(async ({ id }) => ({
|
|
||||||
contentHtml: `<p>导入正文 ${id}</p>`,
|
|
||||||
date: '2026-06-05T10:30:00',
|
|
||||||
id,
|
|
||||||
slug: `wordpress-post-${Number(id) - 49}`,
|
|
||||||
status: 'publish',
|
|
||||||
title: { rendered: `导入标题 ${id}` },
|
|
||||||
}));
|
|
||||||
repository.findOne.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await service.importFromWordpress({
|
|
||||||
all: true,
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(wordpressService.publicArticleList).toHaveBeenNthCalledWith(1, {
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 2,
|
|
||||||
});
|
|
||||||
expect(wordpressService.publicArticleList).toHaveBeenNthCalledWith(2, {
|
|
||||||
pageNo: 2,
|
|
||||||
pageSize: 2,
|
|
||||||
});
|
|
||||||
expect(wordpressService.publicArticleDetail).toHaveBeenCalledTimes(3);
|
|
||||||
expect(repository.save).toHaveBeenCalledTimes(3);
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
created: 3,
|
|
||||||
pageCount: 2,
|
|
||||||
skipped: 0,
|
|
||||||
total: 3,
|
|
||||||
updated: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses local blog term service for category options', async () => {
|
|
||||||
blogTermService.options.mockResolvedValue({
|
|
||||||
list: [{ count: 2, id: 'tech', name: '技术', slug: 'tech' }],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.categoryOptions({
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
search: '技',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(blogTermService.options).toHaveBeenCalledWith('category', {
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
search: '技',
|
|
||||||
});
|
|
||||||
expect(result).toEqual({
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
count: 2,
|
|
||||||
id: 'tech',
|
|
||||||
name: '技术',
|
|
||||||
slug: 'tech',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function createQueryBuilderMock(list: Partial<BlogArticle>[]) {
|
|
||||||
const builder = {
|
|
||||||
addOrderBy: jest.fn().mockReturnThis(),
|
|
||||||
andWhere: jest.fn().mockReturnThis(),
|
|
||||||
getManyAndCount: jest.fn().mockResolvedValue([list as BlogArticle[], list.length]),
|
|
||||||
orderBy: jest.fn().mockReturnThis(),
|
|
||||||
skip: jest.fn().mockReturnThis(),
|
|
||||||
take: jest.fn().mockReturnThis(),
|
|
||||||
where: jest.fn().mockReturnThis(),
|
|
||||||
};
|
|
||||||
|
|
||||||
return builder;
|
|
||||||
}
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
describe('blog-init.sql', () => {
|
|
||||||
const sql = readFileSync(join(process.cwd(), 'sql/blog-init.sql'), 'utf8');
|
|
||||||
|
|
||||||
it('creates the local blog tables needed when DB_SYNC is disabled', () => {
|
|
||||||
expect(sql).toContain('CREATE TABLE IF NOT EXISTS `blog_article`');
|
|
||||||
expect(sql).toContain('CREATE TABLE IF NOT EXISTS `blog_term`');
|
|
||||||
expect(sql).toContain('CREATE TABLE IF NOT EXISTS `blog_theme_config`');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps article content and term relation columns aligned with entities', () => {
|
|
||||||
[
|
|
||||||
'`content_markdown` mediumtext',
|
|
||||||
'`content_html` mediumtext',
|
|
||||||
'`category_items` text',
|
|
||||||
'`tag_items` text',
|
|
||||||
'`publish_time` datetime',
|
|
||||||
'`is_deleted` tinyint(1)',
|
|
||||||
].forEach((column) => {
|
|
||||||
expect(sql).toContain(column);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps theme config columns aligned with BlogThemeConfig entity', () => {
|
|
||||||
expect(sql).toContain('`id` varchar(64) NOT NULL');
|
|
||||||
expect(sql).toContain('`config` longtext NOT NULL');
|
|
||||||
expect(sql).toContain("`source` varchar(255) NOT NULL DEFAULT 'local'");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
describe('blog-menu.sql', () => {
|
|
||||||
const sql = readFileSync(join(process.cwd(), 'sql/blog-menu.sql'), 'utf8');
|
|
||||||
|
|
||||||
function expectMenuRoute(
|
|
||||||
name: string,
|
|
||||||
path: string,
|
|
||||||
component: string,
|
|
||||||
authCode: string,
|
|
||||||
) {
|
|
||||||
expect(sql).toContain(
|
|
||||||
`'${name}', '${path}', '${component}', NULL, '${authCode}', 'menu'`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
it('adds local blog admin menus and action permissions', () => {
|
|
||||||
[
|
|
||||||
'Blog',
|
|
||||||
'BlogArticle',
|
|
||||||
'BlogArticleCreate',
|
|
||||||
'BlogArticleEdit',
|
|
||||||
'BlogArticleDelete',
|
|
||||||
'BlogArticleImport',
|
|
||||||
'BlogCategory',
|
|
||||||
'BlogTag',
|
|
||||||
'BlogTheme',
|
|
||||||
'BlogThemeSave',
|
|
||||||
'BlogThemeImport',
|
|
||||||
].forEach((name) => {
|
|
||||||
expect(sql).toContain(`'${name}'`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps database menu routes aligned with admin route components', () => {
|
|
||||||
expect(sql).toContain(
|
|
||||||
"'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog'",
|
|
||||||
);
|
|
||||||
expectMenuRoute(
|
|
||||||
'BlogArticle',
|
|
||||||
'/blog/article',
|
|
||||||
'/blog/article/list',
|
|
||||||
'Blog:Article:List',
|
|
||||||
);
|
|
||||||
expectMenuRoute(
|
|
||||||
'BlogCategory',
|
|
||||||
'/blog/category',
|
|
||||||
'/blog/category/list',
|
|
||||||
'Blog:Category:List',
|
|
||||||
);
|
|
||||||
expectMenuRoute(
|
|
||||||
'BlogTag',
|
|
||||||
'/blog/tag',
|
|
||||||
'/blog/tag/list',
|
|
||||||
'Blog:Tag:List',
|
|
||||||
);
|
|
||||||
expectMenuRoute(
|
|
||||||
'BlogTheme',
|
|
||||||
'/blog/theme',
|
|
||||||
'/blog/theme/config',
|
|
||||||
'Blog:Theme:List',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('grants blog menus to admin roles without deleting existing role menus', () => {
|
|
||||||
expect(sql).toContain('INSERT IGNORE INTO `admin_role_menu`');
|
|
||||||
expect(sql).toContain("menu.`name` LIKE 'Blog%'");
|
|
||||||
expect(sql).toContain("role.`role_code` IN ('super', 'admin')");
|
|
||||||
expect(sql).not.toMatch(/DELETE\s+FROM\s+`admin_role_menu`/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { ToolsService } from '../../src/common';
|
|
||||||
import { BlogArticle } from '../../src/blog/blog-article.entity';
|
|
||||||
import { BlogTerm } from '../../src/blog/blog-term.entity';
|
|
||||||
import { BlogTermService } from '../../src/blog/blog-term.service';
|
|
||||||
|
|
||||||
describe('BlogTermService', () => {
|
|
||||||
let service: BlogTermService;
|
|
||||||
let termRepository: {
|
|
||||||
create: jest.Mock;
|
|
||||||
find: jest.Mock;
|
|
||||||
findOne: jest.Mock;
|
|
||||||
save: jest.Mock;
|
|
||||||
update: jest.Mock;
|
|
||||||
};
|
|
||||||
let articleRepository: {
|
|
||||||
find: jest.Mock;
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
termRepository = {
|
|
||||||
create: jest.fn((payload) => payload),
|
|
||||||
find: jest.fn(),
|
|
||||||
findOne: jest.fn(),
|
|
||||||
save: jest.fn(async (payload) => ({ id: 'term-id', ...payload })),
|
|
||||||
update: jest.fn(),
|
|
||||||
};
|
|
||||||
articleRepository = {
|
|
||||||
find: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
BlogTermService,
|
|
||||||
ToolsService,
|
|
||||||
{
|
|
||||||
provide: getRepositoryToken(BlogTerm),
|
|
||||||
useValue: termRepository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: getRepositoryToken(BlogArticle),
|
|
||||||
useValue: articleRepository,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = moduleRef.get(BlogTermService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lists local terms with article counts and hide empty support', async () => {
|
|
||||||
termRepository.find.mockResolvedValue([
|
|
||||||
{ id: '1', kind: 'category', name: '技术', slug: 'tech' },
|
|
||||||
{ id: '2', kind: 'category', name: '生活', slug: 'life' },
|
|
||||||
]);
|
|
||||||
articleRepository.find.mockResolvedValue([
|
|
||||||
{ categoryItems: [{ name: '技术', slug: 'tech' }] },
|
|
||||||
{ categoryItems: [{ name: '技术', slug: 'tech' }] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await service.page('category', {
|
|
||||||
hide_empty: true,
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(termRepository.find).toHaveBeenCalledWith({
|
|
||||||
where: {
|
|
||||||
isDeleted: false,
|
|
||||||
kind: 'category',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
count: 2,
|
|
||||||
id: '1',
|
|
||||||
name: '技术',
|
|
||||||
slug: 'tech',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('merges stored terms and article-only terms for article options', async () => {
|
|
||||||
termRepository.find.mockResolvedValue([
|
|
||||||
{ id: '1', kind: 'category', name: '草稿分类', slug: 'draft' },
|
|
||||||
]);
|
|
||||||
articleRepository.find.mockResolvedValue([
|
|
||||||
{ categoryItems: [{ name: '技术', slug: 'tech' }] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const result = await service.options('category', {
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toMatchObject({
|
|
||||||
list: [
|
|
||||||
{
|
|
||||||
count: 1,
|
|
||||||
id: 'tech',
|
|
||||||
name: '技术',
|
|
||||||
slug: 'tech',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
count: 0,
|
|
||||||
id: '1',
|
|
||||||
name: '草稿分类',
|
|
||||||
slug: 'draft',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
total: 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('syncs article terms into local taxonomy once by slug', async () => {
|
|
||||||
termRepository.findOne
|
|
||||||
.mockResolvedValueOnce(null)
|
|
||||||
.mockResolvedValueOnce({ id: 'exists', name: 'Milkdown', slug: 'milkdown' });
|
|
||||||
|
|
||||||
await service.syncTerms('tag', [
|
|
||||||
{ name: 'Milkdown', slug: 'milkdown' },
|
|
||||||
{ name: 'Milkdown', slug: 'milkdown' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(termRepository.save).toHaveBeenCalledTimes(1);
|
|
||||||
expect(termRepository.create).toHaveBeenCalledWith({
|
|
||||||
kind: 'tag',
|
|
||||||
name: 'Milkdown',
|
|
||||||
slug: 'milkdown',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
import { ToolsService } from '../../src/common';
|
|
||||||
import { BlogThemeConfig } from '../../src/blog/blog-theme-config.entity';
|
|
||||||
import { BlogThemeConfigService } from '../../src/blog/blog-theme-config.service';
|
|
||||||
import { WordpressService } from '../../src/wordpress/wordpress.service';
|
|
||||||
|
|
||||||
describe('BlogThemeConfigService', () => {
|
|
||||||
let service: BlogThemeConfigService;
|
|
||||||
let repository: {
|
|
||||||
create: jest.Mock;
|
|
||||||
findOne: jest.Mock;
|
|
||||||
save: jest.Mock;
|
|
||||||
};
|
|
||||||
let wordpressService: {
|
|
||||||
themeConfig: jest.Mock;
|
|
||||||
};
|
|
||||||
const wordpressThemeConfig = {
|
|
||||||
argonConfig: {
|
|
||||||
codeHighlight: {
|
|
||||||
breakLine: false,
|
|
||||||
enable: true,
|
|
||||||
hideLinenumber: false,
|
|
||||||
transparentLinenumber: false,
|
|
||||||
},
|
|
||||||
dateFormat: 'YMD',
|
|
||||||
disablePjax: true,
|
|
||||||
foldLongComments: false,
|
|
||||||
foldLongShuoshuo: false,
|
|
||||||
headroom: 'false',
|
|
||||||
language: 'zh_CN',
|
|
||||||
lazyload: {
|
|
||||||
effect: 'fadeIn',
|
|
||||||
threshold: 800,
|
|
||||||
},
|
|
||||||
pangu: 'article',
|
|
||||||
pjaxAnimationDuration: 600,
|
|
||||||
waterflowColumns: '1',
|
|
||||||
wpPath: '/',
|
|
||||||
zoomify: false,
|
|
||||||
},
|
|
||||||
backgroundDarkBrightness: 0.65,
|
|
||||||
backgroundDarkImage: 'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
|
|
||||||
backgroundDarkOpacity: 1,
|
|
||||||
backgroundImage: 'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
|
|
||||||
backgroundOpacity: 1,
|
|
||||||
bodyClass: ['home', 'blog', 'wp-theme-argon'],
|
|
||||||
darkmodeAutoSwitch: 'alwayson',
|
|
||||||
enableCustomThemeColor: true,
|
|
||||||
htmlClass: [
|
|
||||||
'triple-column',
|
|
||||||
'immersion-color',
|
|
||||||
'toolbar-blur',
|
|
||||||
'article-header-style-default',
|
|
||||||
],
|
|
||||||
sidebarMenu: [
|
|
||||||
{
|
|
||||||
external: true,
|
|
||||||
href: 'http://blog.kwitsukasa.top/wp-admin/',
|
|
||||||
icon: 'fa-user',
|
|
||||||
label: '管理',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
site: {
|
|
||||||
authorAvatar: 'http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg',
|
|
||||||
authorName: 'KwiTsukasa',
|
|
||||||
description: '',
|
|
||||||
home: 'https://blog.kwitsukasa.top',
|
|
||||||
title: 'KwiTsukasa的小站',
|
|
||||||
url: 'https://blog.kwitsukasa.top',
|
|
||||||
},
|
|
||||||
themeCardRadius: 4,
|
|
||||||
themeColor: '#c3a1ed',
|
|
||||||
themeColorRgb: '195,161,237',
|
|
||||||
themeVersion: '1.3.5',
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
repository = {
|
|
||||||
create: jest.fn((payload) => payload),
|
|
||||||
findOne: jest.fn(),
|
|
||||||
save: jest.fn(async (payload) => payload),
|
|
||||||
};
|
|
||||||
wordpressService = {
|
|
||||||
themeConfig: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
|
||||||
providers: [
|
|
||||||
BlogThemeConfigService,
|
|
||||||
ToolsService,
|
|
||||||
{
|
|
||||||
provide: getRepositoryToken(BlogThemeConfig),
|
|
||||||
useValue: repository,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: WordpressService,
|
|
||||||
useValue: wordpressService,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = moduleRef.get(BlogThemeConfigService);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a local default config when no saved config exists', async () => {
|
|
||||||
repository.findOne.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const config = await service.publicConfig();
|
|
||||||
|
|
||||||
expect(config).toMatchObject({
|
|
||||||
site: {
|
|
||||||
authorAvatar: '/argon/theme/profile.jpg',
|
|
||||||
authorName: 'KwiTsukasa',
|
|
||||||
title: 'KwiTsukasa的小站',
|
|
||||||
},
|
|
||||||
sidebarMenu: [
|
|
||||||
{
|
|
||||||
href: '/',
|
|
||||||
icon: 'fa-home',
|
|
||||||
label: '首页',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
external: true,
|
|
||||||
href: '/admin',
|
|
||||||
icon: 'fa-user',
|
|
||||||
label: '管理',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
themeCardRadius: 4,
|
|
||||||
themeColor: '#c3a1ed',
|
|
||||||
backgroundDarkBrightness: 0.65,
|
|
||||||
backgroundDarkImage: '/argon/theme/img-2-1200x1000.jpg',
|
|
||||||
backgroundDarkOpacity: 1,
|
|
||||||
backgroundImage: '/argon/theme/img-2-1200x1000.jpg',
|
|
||||||
backgroundOpacity: 1,
|
|
||||||
});
|
|
||||||
expect(wordpressService.themeConfig).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('saves local theme config', async () => {
|
|
||||||
repository.findOne.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const result = await service.save({
|
|
||||||
config: wordpressThemeConfig,
|
|
||||||
source: 'local-admin',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(repository.create).toHaveBeenCalledWith({
|
|
||||||
config: wordpressThemeConfig,
|
|
||||||
id: 'argon',
|
|
||||||
source: 'local-admin',
|
|
||||||
});
|
|
||||||
expect(repository.save).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
config: wordpressThemeConfig,
|
|
||||||
id: 'argon',
|
|
||||||
source: 'local-admin',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toBe(wordpressThemeConfig);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('imports WordPress theme config into local storage', async () => {
|
|
||||||
repository.findOne.mockResolvedValue({
|
|
||||||
id: 'argon',
|
|
||||||
source: 'local',
|
|
||||||
});
|
|
||||||
wordpressService.themeConfig.mockResolvedValue(wordpressThemeConfig);
|
|
||||||
|
|
||||||
const result = await service.importFromWordpress();
|
|
||||||
|
|
||||||
expect(wordpressService.themeConfig).toHaveBeenCalledWith();
|
|
||||||
expect(repository.save).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
config: wordpressThemeConfig,
|
|
||||||
id: 'argon',
|
|
||||||
source: 'wordpress',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result).toBe(wordpressThemeConfig);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import { MarkdownService } from '../../src/common';
|
|
||||||
|
|
||||||
describe('MarkdownService', () => {
|
|
||||||
let service: MarkdownService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
service = new MarkdownService();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('embeds, extracts and strips markdown source marker', () => {
|
|
||||||
const markdown = '# 标题\n\n正文';
|
|
||||||
const html = service.embedSourceHtml('<h1>标题</h1>', markdown);
|
|
||||||
|
|
||||||
expect(service.extractSource(html)).toBe(markdown);
|
|
||||||
expect(service.stripSourceMarker(html)).toBe('<h1>标题</h1>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('sanitizes imported html through the html sanitize processor', async () => {
|
|
||||||
const process = jest
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue('<p>正文</p>');
|
|
||||||
|
|
||||||
(service as any).sanitizeHtmlProcessorPromise = Promise.resolve({
|
|
||||||
process,
|
|
||||||
});
|
|
||||||
|
|
||||||
const html = await service.sanitizeHtml(
|
|
||||||
'<p onclick="alert(1)">正文</p><script>alert(1)</script>',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(process).toHaveBeenCalledWith(
|
|
||||||
'<p onclick="alert(1)">正文</p><script>alert(1)</script>',
|
|
||||||
);
|
|
||||||
expect(html).toContain('<p>正文</p>');
|
|
||||||
expect(html).not.toContain('onclick');
|
|
||||||
expect(html).not.toContain('<script>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -78,7 +78,9 @@ describe('QqbotCommandParserService FFLogs parser', () => {
|
|||||||
encounterName: '上位护锁刃龙',
|
encounterName: '上位护锁刃龙',
|
||||||
serverSlug: '琥珀原',
|
serverSlug: '琥珀原',
|
||||||
});
|
});
|
||||||
expect(dictService.getDictItemsByKey).not.toHaveBeenCalled();
|
expect(dictService.getDictItemsByKey).not.toHaveBeenCalledWith(
|
||||||
|
'FFLOGS_ENCOUNTER_LABEL',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps character summary parsing when no encounter is provided', async () => {
|
it('keeps character summary parsing when no encounter is provided', async () => {
|
||||||
|
|||||||
@ -1,310 +0,0 @@
|
|||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { MarkdownService, ToolsService } from '../../src/common';
|
|
||||||
import { WordpressService } from '../../src/wordpress/wordpress.service';
|
|
||||||
|
|
||||||
describe('WordpressService theme config', () => {
|
|
||||||
const rootPayload = {
|
|
||||||
description: '',
|
|
||||||
home: 'https://blog.kwitsukasa.top',
|
|
||||||
name: 'KwiTsukasa的小站',
|
|
||||||
url: 'https://blog.kwitsukasa.top',
|
|
||||||
};
|
|
||||||
const html = `
|
|
||||||
<!doctype html>
|
|
||||||
<html lang="zh-Hans" class="no-js triple-column immersion-color toolbar-blur article-header-style-default ">
|
|
||||||
<head>
|
|
||||||
<meta name="theme-color" content="#c3a1ed">
|
|
||||||
<meta name="theme-color-rgb" content="195,161,237">
|
|
||||||
<meta name="argon-enable-custom-theme-color" content="true">
|
|
||||||
<meta name="theme-card-radius" content="4">
|
|
||||||
<meta name="theme-version" content="1.3.5">
|
|
||||||
</head>
|
|
||||||
<body class="home blog wp-theme-argon">
|
|
||||||
<div id="leftbar_overview_author_image" style="background-image: url(http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg)" class="rounded-circle shadow-sm" alt="avatar"></div>
|
|
||||||
<h6 id="leftbar_overview_author_name">KwiTsukasa</h6>
|
|
||||||
<ul id="leftbar_part1_menu" class="leftbar-menu">
|
|
||||||
<li><a href="https://blog.kwitsukasa.top"><i class="fa fa-home"></i> 首页</a></li>
|
|
||||||
<li><a href="http://blog.kwitsukasa.top/wp-admin/"><i class="fa fa-user"></i> 管理</a></li>
|
|
||||||
</ul>
|
|
||||||
<style>
|
|
||||||
#content:before {
|
|
||||||
background: url(https://s3.kwitsukasa.top/images/bg-%E5%86%AC%E6%BB%9A%E6%BB%9A.png);
|
|
||||||
background-position: center;
|
|
||||||
background-size: cover;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
html.darkmode #content:before {
|
|
||||||
filter: brightness(0.65);
|
|
||||||
}
|
|
||||||
#content:after {
|
|
||||||
background: url(https://s3.kwitsukasa.top/images/bg-%E5%86%AC%E6%BB%9A%E6%BB%9A.png);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
html.darkmode #content:after {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<script>
|
|
||||||
var argonConfig = {
|
|
||||||
wp_path: "/",
|
|
||||||
language: "zh_CN",
|
|
||||||
dateFormat: "YMD",
|
|
||||||
zoomify: false,
|
|
||||||
pangu: "article",
|
|
||||||
lazyload: {
|
|
||||||
threshold: 800,
|
|
||||||
effect: "fadeIn"
|
|
||||||
},
|
|
||||||
fold_long_comments: false,
|
|
||||||
fold_long_shuoshuo: false,
|
|
||||||
disable_pjax: false,
|
|
||||||
pjax_animation_durtion: 600,
|
|
||||||
headroom: "false",
|
|
||||||
waterflow_columns: "1",
|
|
||||||
code_highlight: {
|
|
||||||
enable: true,
|
|
||||||
hide_linenumber: false,
|
|
||||||
transparent_linenumber: false,
|
|
||||||
break_line: false
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var darkmodeAutoSwitch = "alwayson";
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
let service: WordpressService;
|
|
||||||
let fetchSpy: jest.SpyInstance;
|
|
||||||
let markdownService: MarkdownService;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
markdownService = new MarkdownService();
|
|
||||||
jest
|
|
||||||
.spyOn(markdownService, 'renderToHtml')
|
|
||||||
.mockResolvedValue('<h1>标题</h1>\n<p>正文</p>');
|
|
||||||
service = new WordpressService(
|
|
||||||
new ConfigService({
|
|
||||||
WORDPRESS_BASE_URL: 'https://blog.kwitsukasa.top',
|
|
||||||
}),
|
|
||||||
markdownService,
|
|
||||||
new ToolsService(),
|
|
||||||
);
|
|
||||||
fetchSpy = jest
|
|
||||||
.spyOn(globalThis, 'fetch')
|
|
||||||
.mockImplementation(async (url: URL | RequestInfo) => {
|
|
||||||
const target = `${url}`;
|
|
||||||
if (target.includes('rest_route=/')) {
|
|
||||||
return new Response(JSON.stringify(rootPayload), {
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(html, {
|
|
||||||
headers: { 'Content-Type': 'text/html' },
|
|
||||||
status: 200,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fetchSpy.mockRestore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts Argon theme config from WordPress REST root and homepage html', async () => {
|
|
||||||
const config = await service.themeConfig();
|
|
||||||
|
|
||||||
expect(config).toMatchObject({
|
|
||||||
backgroundDarkBrightness: 0.65,
|
|
||||||
backgroundDarkImage: 'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
|
|
||||||
backgroundDarkOpacity: 1,
|
|
||||||
backgroundImage: 'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
|
|
||||||
backgroundOpacity: 1,
|
|
||||||
bodyClass: ['home', 'blog', 'wp-theme-argon'],
|
|
||||||
darkmodeAutoSwitch: 'alwayson',
|
|
||||||
enableCustomThemeColor: true,
|
|
||||||
htmlClass: [
|
|
||||||
'triple-column',
|
|
||||||
'immersion-color',
|
|
||||||
'toolbar-blur',
|
|
||||||
'article-header-style-default',
|
|
||||||
],
|
|
||||||
sidebarMenu: [
|
|
||||||
{
|
|
||||||
external: true,
|
|
||||||
href: 'https://blog.kwitsukasa.top',
|
|
||||||
icon: 'fa-home',
|
|
||||||
label: '首页',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
external: true,
|
|
||||||
href: 'http://blog.kwitsukasa.top/wp-admin/',
|
|
||||||
icon: 'fa-user',
|
|
||||||
label: '管理',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
site: {
|
|
||||||
authorAvatar: 'http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg',
|
|
||||||
authorName: 'KwiTsukasa',
|
|
||||||
description: '',
|
|
||||||
home: 'https://blog.kwitsukasa.top',
|
|
||||||
title: 'KwiTsukasa的小站',
|
|
||||||
url: 'https://blog.kwitsukasa.top',
|
|
||||||
},
|
|
||||||
themeCardRadius: 4,
|
|
||||||
themeColor: '#c3a1ed',
|
|
||||||
themeColorRgb: '195,161,237',
|
|
||||||
themeVersion: '1.3.5',
|
|
||||||
});
|
|
||||||
expect(config.argonConfig).toMatchObject({
|
|
||||||
codeHighlight: {
|
|
||||||
breakLine: false,
|
|
||||||
enable: true,
|
|
||||||
hideLinenumber: false,
|
|
||||||
transparentLinenumber: false,
|
|
||||||
},
|
|
||||||
dateFormat: 'YMD',
|
|
||||||
disablePjax: false,
|
|
||||||
headroom: 'false',
|
|
||||||
language: 'zh_CN',
|
|
||||||
lazyload: {
|
|
||||||
effect: 'fadeIn',
|
|
||||||
threshold: 800,
|
|
||||||
},
|
|
||||||
pangu: 'article',
|
|
||||||
pjaxAnimationDuration: 600,
|
|
||||||
waterflowColumns: '1',
|
|
||||||
wpPath: '/',
|
|
||||||
zoomify: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders markdown article content before saving to WordPress', async () => {
|
|
||||||
fetchSpy.mockImplementation(async (url: URL | RequestInfo, init?: any) => {
|
|
||||||
const target = `${url}`;
|
|
||||||
if (target.includes('/wp-json/wp/v2/posts')) {
|
|
||||||
const body = JSON.parse(init.body);
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
content: { raw: body.content },
|
|
||||||
id: 1,
|
|
||||||
title: { raw: body.title },
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
status: 200,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ id: 1 }), { status: 200 });
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.articleSave(
|
|
||||||
{
|
|
||||||
content: '# 标题\n\n正文<script>alert(1)</script>',
|
|
||||||
contentFormat: 'markdown',
|
|
||||||
title: 'Markdown 文章',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nonce: 'rest-nonce',
|
|
||||||
cookie: 'wordpress_logged_in_demo=1',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('/wp-json/wp/v2/posts'),
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'POST',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const [, init] = fetchSpy.mock.calls[0];
|
|
||||||
const body = JSON.parse(init.body);
|
|
||||||
expect(body.content).toContain('<h1>标题</h1>');
|
|
||||||
expect(body.content).not.toContain('<script>');
|
|
||||||
expect(body.content).toContain('kt-markdown-source:');
|
|
||||||
expect(markdownService.renderToHtml).toHaveBeenCalledWith(
|
|
||||||
'# 标题\n\n正文<script>alert(1)</script>',
|
|
||||||
);
|
|
||||||
expect(result.contentMarkdown).toBe(
|
|
||||||
'# 标题\n\n正文<script>alert(1)</script>',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes public article list from WordPress view responses', async () => {
|
|
||||||
fetchSpy.mockImplementation(async (_url: URL | RequestInfo, init?: any) => {
|
|
||||||
expect(init?.headers?.Authorization).toBeUndefined();
|
|
||||||
expect(init?.headers?.Cookie).toBeUndefined();
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify([
|
|
||||||
{
|
|
||||||
_embedded: {
|
|
||||||
author: [{ name: '作者' }],
|
|
||||||
'wp:featuredmedia': [{ source_url: 'https://img.demo/cover.jpg' }],
|
|
||||||
'wp:term': [
|
|
||||||
[
|
|
||||||
{
|
|
||||||
count: 2,
|
|
||||||
id: 10,
|
|
||||||
name: '技术',
|
|
||||||
slug: 'tech',
|
|
||||||
taxonomy: 'category',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
count: 1,
|
|
||||||
id: 20,
|
|
||||||
name: 'Milkdown',
|
|
||||||
slug: 'milkdown',
|
|
||||||
taxonomy: 'post_tag',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
rendered: '<h1>标题</h1>',
|
|
||||||
},
|
|
||||||
excerpt: {
|
|
||||||
rendered: '<p>摘要</p>',
|
|
||||||
},
|
|
||||||
id: 1,
|
|
||||||
slug: 'demo',
|
|
||||||
title: {
|
|
||||||
rendered: '公开文章',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'x-wp-total': '1',
|
|
||||||
},
|
|
||||||
status: 200,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.publicArticleList({
|
|
||||||
pageNo: 1,
|
|
||||||
pageSize: 10,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('/wp-json/wp/v2/posts'),
|
|
||||||
expect.objectContaining({
|
|
||||||
method: 'GET',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(result.total).toBe(1);
|
|
||||||
expect(result.list[0]).toMatchObject({
|
|
||||||
authorName: '作者',
|
|
||||||
contentHtml: '<h1>标题</h1>',
|
|
||||||
cover: 'https://img.demo/cover.jpg',
|
|
||||||
excerptText: '摘要',
|
|
||||||
categoriesResolved: [{ id: 10, name: '技术', slug: 'tech' }],
|
|
||||||
tagsResolved: [{ id: 20, name: 'Milkdown', slug: 'milkdown' }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user