diff --git a/API.md b/API.md index a5a4260..912336f 100644 --- a/API.md +++ b/API.md @@ -415,8 +415,8 @@ Query: | pageSize | number | 否 | 每页条数,默认 `10` | | search | string | 否 | 关键词搜索 | | status | string | 否 | 文章状态,默认 `any` | -| categories | string | 否 | 分类 ID,多个用逗号分隔 | -| tags | string | 否 | 标签 ID,多个用逗号分隔 | +| categories | string | 否 | 分类 ID,多个用逗号分隔,也兼容重复传参 | +| tags | string | 否 | 标签 ID,多个用逗号分隔,也兼容重复传参 | 新增/编辑 Body 常用字段: @@ -435,6 +435,8 @@ Query: } ``` +`categories` 与 `tags` 直接对应 WordPress 文章 REST 字段,传入 ID 数组即可把文章绑定到对应分类和标签;传空数组表示清空当前文章的对应绑定。 + ### WordPress Tag | 方法 | 路径 | 说明 | @@ -445,6 +447,8 @@ Query: | POST | `/wordpress/tag/update` | 编辑标签 | | POST | `/wordpress/tag/remove?id=1&force=true` | 删除标签 | +WordPress 标签 term 不支持回收站,删除时必须使用 `force=true`。 + 新增/编辑 Body: ```json @@ -466,6 +470,8 @@ Query: | POST | `/wordpress/category/update` | 编辑分类 | | POST | `/wordpress/category/remove?id=1&force=true` | 删除分类 | +WordPress 分类 term 不支持回收站,删除时必须使用 `force=true`;删除分类不会删除文章,文章会按 WordPress 自身规则解除或迁移分类关系。 + 新增/编辑 Body: ```json diff --git a/README.md b/README.md index 2f79010..4888de7 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ pnpm test:e2e # e2e 测试 - Admin、Component、Dict 与 MinIO 业务接口统一走 `JwtAuthGuard`;登录、刷新 token、退出登录和部分示例状态测试接口通过 `@Public()` 放行。 - WordPress 管理接口同样先走本系统 `JwtAuthGuard`,再透传客户端 WordPress 登录态访问 WordPress REST API;当前 WordPress 只有单管理员账号且不开放注册,账号配置放在 env 中,但不作为 BasicAuth 发送。 - Admin 前端只调用现有 `/auth/login`;后端会在登录流程里自动尝试登录 WordPress,把 WordPress cookie 写入本系统 httpOnly cookie,前端只持久化 REST nonce 和用户信息。WordPress 远程不可用时不会阻塞 Admin 主登录,后端会返回 `wordpressAuth=null` 并在菜单和按钮权限接口中过滤博客管理相关入口。 +- WordPress 文章的 `categories` 和 `tags` 按原生 REST API 语义透传 ID 数组;分类和标签 term 支持新增、编辑、强制删除,删除 term 不会删除文章。 - WordPress 客户端登录态优先通过 `X-WordPress-Authorization` 透传,也支持 `X-WP-Nonce` 加 WordPress 登录 cookie 的 REST cookie 认证。 - 如果 WordPress 服务器未开启 rewrite 导致 `/wp-json/*` 返回 404,后端会自动回退到 `?rest_route=/...` 形式继续访问 REST API。 - `kt-template-admin` 登录会写入 access token 与刷新 token cookie,`kt-template-online-web` 和 `kt-template-online-playground` 可在回跳后通过刷新 token 重新持久化登录态。 diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index 442d0da..6a4f8a4 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -137,6 +137,10 @@ VALUES (2041700000000120201, 2041700000000100202, 'SystemDeptCreate', NULL, NULL, NULL, 'System:Dept:Create', 'button', '{"title":"common.create"}', 1, 0), (2041700000000120202, 2041700000000100202, 'SystemDeptEdit', NULL, NULL, NULL, 'System:Dept:Edit', 'button', '{"title":"common.edit"}', 1, 0), (2041700000000120203, 2041700000000100202, 'SystemDeptDelete', NULL, NULL, NULL, 'System:Dept:Delete', 'button', '{"title":"common.delete"}', 1, 0), + (2041700000000100203, 2041700000000100002, 'SystemKtTableDemo', '/system/ktTableDemo', '/system/ktTableDemo/list', NULL, 'System:KtTableDemo:List', 'menu', '{"icon":"lucide:table-2","title":"system.ktTableDemo.title"}', 1, 3), + (2041700000000120204, 2041700000000100203, 'SystemKtTableDemoCreate', NULL, NULL, NULL, 'System:KtTableDemo:Create', 'button', '{"title":"common.create"}', 1, 0), + (2041700000000120205, 2041700000000100203, 'SystemKtTableDemoEdit', NULL, NULL, NULL, 'System:KtTableDemo:Edit', 'button', '{"title":"common.edit"}', 1, 0), + (2041700000000120206, 2041700000000100203, 'SystemKtTableDemoDelete', NULL, NULL, NULL, 'System:KtTableDemo:Delete', 'button', '{"title":"common.delete"}', 1, 0), (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), diff --git a/src/wordpress/wordpress.dto.ts b/src/wordpress/wordpress.dto.ts index 7129f19..0ecffeb 100644 --- a/src/wordpress/wordpress.dto.ts +++ b/src/wordpress/wordpress.dto.ts @@ -39,14 +39,14 @@ export class WordpressArticleListQueryDto extends WordpressPagedQueryDto { status?: string; @ApiPropertyOptional({ - description: '分类 ID,多个使用逗号分隔', + description: '分类 ID,多个使用逗号分隔或重复传参', }) - categories?: string; + categories?: string | string[]; @ApiPropertyOptional({ - description: '标签 ID,多个使用逗号分隔', + description: '标签 ID,多个使用逗号分隔或重复传参', }) - tags?: string; + tags?: string | string[]; @ApiPropertyOptional({ description: '作者 ID', diff --git a/src/wordpress/wordpress.service.ts b/src/wordpress/wordpress.service.ts index 56c3db2..7687dec 100644 --- a/src/wordpress/wordpress.service.ts +++ b/src/wordpress/wordpress.service.ts @@ -213,11 +213,11 @@ export class WordpressService { query: { ...this.getPageQuery(query), author: query.author, - categories: query.categories, + categories: this.normalizeIdQuery(query.categories), context: 'edit', search: query.search, status: query.status || 'any', - tags: query.tags, + tags: this.normalizeIdQuery(query.tags), }, }); @@ -429,14 +429,25 @@ export class WordpressService { const urls = this.getRequestUrls(path, options.query); for (let index = 0; index < urls.length; index += 1) { - const response = await fetch(urls[index], { + let response = await fetch(urls[index], { body: options.body ? JSON.stringify(options.body) : undefined, headers: this.getHeaders(options.auth, !!options.body), method: options.method || 'GET', redirect: 'follow', signal: controller.signal, }); - const data = await this.parseResponse(response); + let data = await this.parseResponse(response); + + // 部分 WordPress 网关会拦截 DELETE;REST API 官方支持用 _method=DELETE 通过 POST 兜底。 + if (!response.ok && response.status === 405 && options.method === 'DELETE') { + response = await fetch(this.getMethodOverrideUrl(urls[index], 'DELETE'), { + headers: this.getHeaders(options.auth, false), + method: 'POST', + redirect: 'follow', + signal: controller.signal, + }); + data = await this.parseResponse(response); + } // 兼容未开启 Apache rewrite 的 WordPress:/wp-json 404 时自动回退到 ?rest_route=。 if ( @@ -676,6 +687,14 @@ export class WordpressService { return url.toString(); } + private getMethodOverrideUrl(url: string, method: 'DELETE') { + const overrideUrl = new URL(url); + + overrideUrl.searchParams.set('_method', method); + + return overrideUrl.toString(); + } + private getTimeout() { return Number(this.configService.get('WORDPRESS_TIMEOUT_MS') || 15000); } @@ -747,6 +766,24 @@ export class WordpressService { .filter((item) => !Number.isNaN(item)); } + private normalizeIdQuery(value?: string | string[]) { + if (Array.isArray(value)) { + return value + .flatMap((item) => item.split(',')) + .map((item) => item.trim()) + .filter(Boolean) + .join(','); + } + + if (typeof value !== 'string') return value; + + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + .join(','); + } + private pickDefined(payload: Record) { return Object.entries(payload).reduce>( (acc, [key, value]) => {