diff --git a/src/api/types.gen.ts b/src/api/types.gen.ts index 5b45ce8..f893308 100644 --- a/src/api/types.gen.ts +++ b/src/api/types.gen.ts @@ -218,6 +218,541 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/v1/auth/roles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** [角色管理] 角色列表 */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + /** + * @example [ + * { + * "kind": "role", + * "name": "moderator", + * "permissions": [ + * "admin:comment:read" + * ], + * "users_count": 1, + * "builtin": false + * } + * ] + */ + data?: { + /** @example role */ + kind?: string; + /** @example moderator */ + name?: string; + /** + * @example [ + * "admin:comment:read" + * ] + */ + permissions?: string[]; + /** @example 1 */ + users_count?: number; + /** @example false */ + builtin?: boolean; + }[]; + }; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example FORBIDDEN */ + code?: string; + /** @example 无权限访问 */ + message?: string; + }; + }; + }; + }; + }; + put?: never; + /** [角色管理] 新建角色 */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description 角色名(唯一)。 + * @example reviewer + */ + name: string; + }; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example role */ + kind?: string; + /** @example reviewer */ + name?: string; + /** @example [] */ + permissions?: unknown[]; + /** @example 0 */ + users_count?: number; + /** @example false */ + builtin?: boolean; + }; + }; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/auth/roles/{role}/permissions": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 角色名。 + * @example moderator + */ + role: string; + }; + cookie?: never; + }; + get?: never; + /** + * [角色管理] 同步角色权限 + * @description 全量替换该角色的权限集合。super-admin 角色不可修改。 + */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 角色名。 + * @example moderator + */ + role: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description 权限键列表。 + * @example [ + * "admin:comment:read" + * ] + */ + permissions: string[]; + }; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example role */ + kind?: string; + /** @example moderator */ + name?: string; + /** + * @example [ + * "admin:comment:read" + * ] + */ + permissions?: string[]; + /** @example 1 */ + users_count?: number; + /** @example false */ + builtin?: boolean; + }; + }; + }; + }; + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example VALIDATION_ERROR */ + code?: string; + /** @example The given data was invalid. */ + message?: string; + data?: { + errors?: { + /** + * @example [ + * "super-admin role cannot be modified." + * ] + */ + role?: string[]; + }; + }; + }; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/auth/roles/{id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the role. + * @example architecto + */ + id: string; + /** + * @description 角色名。 + * @example reviewer + */ + role: string; + }; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * [角色管理] 删除角色 + * @description 内建角色与仍有用户的角色不可删除。 + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the role. + * @example architecto + */ + id: string; + /** + * @description 角色名。 + * @example reviewer + */ + role: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + /** @example Role deleted */ + message?: string; + }; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/auth/permissions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * [角色管理] 权限目录 + * @description 按模块/分组返回全部已注册权限键及描述,用于权限矩阵渲染。 + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + comment?: { + /** @example comment */ + module?: string; + groups?: { + admin?: { + /** @example View reported comments */ + "admin:comment:read"?: string; + }; + }; + }; + }; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/auth/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** [角色管理] 搜索用户(角色分配用) */ + get: { + parameters: { + query: { + /** + * @description 按邮箱/用户名/UID 模糊搜索。 示例: moderator + * @example architecto + */ + q: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description Must not be greater than 100 characters. + * @example b + */ + q: string; + }; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + /** + * @example [ + * { + * "kind": "role_user", + * "uid": "01HXXXX", + * "display_name": "内容审核员", + * "email": "moderator@gig.local", + * "roles": [ + * "moderator" + * ] + * } + * ] + */ + data?: { + /** @example role_user */ + kind?: string; + /** @example 01HXXXX */ + uid?: string; + /** @example 内容审核员 */ + display_name?: string; + /** @example moderator@gig.local */ + email?: string; + /** + * @example [ + * "moderator" + * ] + */ + roles?: string[]; + }[]; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/auth/users/{uid}/roles": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 用户 UID。 + * @example 01HXXXX + */ + uid: string; + }; + cookie?: never; + }; + get?: never; + /** + * [角色管理] 分配用户角色 + * @description 全量替换用户的角色集合。不能修改自己的角色。 + */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 用户 UID。 + * @example 01HXXXX + */ + uid: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** + * @description 角色名列表。 + * @example [ + * "moderator" + * ] + */ + roles: string[]; + }; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example role_user */ + kind?: string; + /** @example 01HXXXX */ + uid?: string; + /** + * @example [ + * "moderator" + * ] + */ + roles?: string[]; + }; + }; + }; + }; + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example VALIDATION_ERROR */ + code?: string; + /** @example The given data was invalid. */ + message?: string; + data?: { + errors?: { + /** + * @example [ + * "You cannot modify your own roles." + * ] + */ + uid?: string[]; + }; + }; + }; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/v1/comments/reported": { parameters: { query?: never; @@ -576,6 +1111,487 @@ export interface paths { }; trace?: never; }; + "/api/admin/v1/dimzou/documents": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** [文档监控] 文档列表 */ + get: { + parameters: { + query?: { + /** + * @description 可选。按状态过滤: active(活跃), archived(已归档), trashed(回收站), all(默认)。 示例: active + * @example architecto + */ + state?: string; + /** + * @description 可选。按所有者过滤。 示例: 01HXXXX + * @example architecto + */ + owner_uid?: string; + /** + * @description 可选。按标题模糊搜索。 示例: 旅行 + * @example architecto + */ + q?: string; + /** + * @description 可选。页码。 示例: 1 + * @example 16 + */ + page?: number; + /** + * @description 可选。每页数量,最大100。 示例: 20 + * @example 16 + */ + page_size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @example archived + * @enum {string|null} + */ + state?: "active" | "archived" | "trashed" | "all" | null; + /** @example architecto */ + owner_uid?: string | null; + /** + * @description Must not be greater than 100 characters. + * @example n + */ + q?: string | null; + /** + * @description Must be at least 1. + * @example 67 + */ + page?: number | null; + /** + * @description Must be at least 1. Must not be greater than 100. + * @example 16 + */ + page_size?: number | null; + }; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + /** + * @example [ + * { + * "kind": "dimzou_document", + * "id": 16, + * "title": "三天短途旅行打包清单", + * "owner": { + * "uid": "01HXXXX", + * "display_name": "Alice" + * }, + * "category": { + * "id": "9f1c...", + * "name": "生活" + * }, + * "state": "active", + * "versions_count": 2, + * "published_version": 1, + * "draft_version": 2, + * "archived_at": null, + * "deleted_at": null, + * "created_at": "2026-06-20T05:00:00Z", + * "updated_at": "2026-06-21T05:00:00Z" + * } + * ] + */ + data?: { + /** @example dimzou_document */ + kind?: string; + /** @example 16 */ + id?: number; + /** @example 三天短途旅行打包清单 */ + title?: string; + owner?: { + /** @example 01HXXXX */ + uid?: string; + /** @example Alice */ + display_name?: string; + }; + category?: { + /** @example 9f1c... */ + id?: string; + /** @example 生活 */ + name?: string; + }; + /** @example active */ + state?: string; + /** @example 2 */ + versions_count?: number; + /** @example 1 */ + published_version?: number; + /** @example 2 */ + draft_version?: number; + /** @example null */ + archived_at?: string; + /** @example null */ + deleted_at?: string; + /** @example 2026-06-20T05:00:00Z */ + created_at?: string; + /** @example 2026-06-21T05:00:00Z */ + updated_at?: string; + }[]; + pagination?: { + /** @example 1 */ + current_page?: number; + /** @example null */ + next_page?: string; + /** @example 20 */ + page_size?: number; + /** @example null */ + prev_page?: string; + /** @example 1 */ + total_count?: number; + /** @example 1 */ + total_pages?: number; + }; + }; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example FORBIDDEN */ + code?: string; + /** @example 无权限访问 */ + message?: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/dimzou/documents/{id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 文档 ID。 + * @example 16 + */ + id: number; + }; + cookie?: never; + }; + /** + * [文档监控] 文档详情 + * @description 含版本列表、译文列表与待审计数。 + */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description 文档 ID。 + * @example 16 + */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + data?: { + /** @example dimzou_document */ + kind?: string; + /** @example 16 */ + id?: number; + /** @example 三天短途旅行打包清单 */ + title?: string; + /** @example active */ + state?: string; + /** @example [] */ + versions?: unknown[]; + /** + * @example [ + * { + * "id": 4, + * "language_code": "en", + * "status": "draft", + * "translated_document_id": 17, + * "created_by_uid": "01HXXXX", + * "created_at": "2026-06-20T05:32:14Z" + * } + * ] + */ + translations?: { + /** @example 4 */ + id?: number; + /** @example en */ + language_code?: string; + /** @example draft */ + status?: string; + /** @example 17 */ + translated_document_id?: number; + /** @example 01HXXXX */ + created_by_uid?: string; + /** @example 2026-06-20T05:32:14Z */ + created_at?: string; + }[]; + /** @example 3 */ + pending_revisions_count?: number; + /** @example 1 */ + pending_join_requests_count?: number; + }; + }; + }; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example NOT_FOUND */ + code?: string; + /** @example 请求的资源未找到。 */ + message?: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/dimzou/publications": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** [发布监控] 发布列表 */ + get: { + parameters: { + query?: { + /** + * @description 可选。按可见性过滤: public, group_only。 示例: public + * @example architecto + */ + visibility?: string; + /** + * @description 可选。按语言过滤。 示例: zh-CN + * @example architecto + */ + language?: string; + /** + * @description 可选。按源文档过滤。 示例: 16 + * @example 16 + */ + document_id?: number; + /** + * @description 可选。按标题模糊搜索。 示例: 旅行 + * @example architecto + */ + q?: string; + /** + * @description 可选。页码。 示例: 1 + * @example 16 + */ + page?: number; + /** + * @description 可选。每页数量,最大100。 示例: 20 + * @example 16 + */ + page_size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** + * @example public + * @enum {string|null} + */ + visibility?: "public" | "group_only" | null; + /** + * @description Must not be greater than 8 characters. + * @example bngzmiyv + */ + language?: string | null; + /** @example 16 */ + document_id?: number | null; + /** + * @description Must not be greater than 100 characters. + * @example n + */ + q?: string | null; + /** + * @description Must be at least 1. + * @example 67 + */ + page?: number | null; + /** + * @description Must be at least 1. Must not be greater than 100. + * @example 16 + */ + page_size?: number | null; + }; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example true */ + success?: boolean; + /** + * @example [ + * { + * "kind": "dimzou_publication", + * "id": 3, + * "document_id": 16, + * "document_version_id": 20, + * "version_number": 1, + * "visibility": "public", + * "title": "三天短途旅行打包清单", + * "summary": "轻装出行指南", + * "language": "zh-CN", + * "keywords": [ + * "旅行" + * ], + * "published_at": "2026-06-21T05:00:00Z", + * "block_count": 12, + * "author": { + * "uid": "01HXXXX", + * "display_name": "Alice" + * } + * } + * ] + */ + data?: { + /** @example dimzou_publication */ + kind?: string; + /** @example 3 */ + id?: number; + /** @example 16 */ + document_id?: number; + /** @example 20 */ + document_version_id?: number; + /** @example 1 */ + version_number?: number; + /** @example public */ + visibility?: string; + /** @example 三天短途旅行打包清单 */ + title?: string; + /** @example 轻装出行指南 */ + summary?: string; + /** @example zh-CN */ + language?: string; + /** + * @example [ + * "旅行" + * ] + */ + keywords?: string[]; + /** @example 2026-06-21T05:00:00Z */ + published_at?: string; + /** @example 12 */ + block_count?: number; + author?: { + /** @example 01HXXXX */ + uid?: string; + /** @example Alice */ + display_name?: string; + }; + }[]; + pagination?: { + /** @example 1 */ + current_page?: number; + /** @example null */ + next_page?: string; + /** @example 20 */ + page_size?: number; + /** @example null */ + prev_page?: string; + /** @example 1 */ + total_count?: number; + /** @example 1 */ + total_pages?: number; + }; + }; + }; + }; + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example false */ + success?: boolean; + /** @example FORBIDDEN */ + code?: string; + /** @example 无权限访问 */ + message?: string; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/v1/dimzou/translations": { parameters: { query?: never; @@ -629,7 +1645,7 @@ export interface paths { */ language_code?: string; /** - * @example awaiting_publish + * @example published * @enum {string} */ status?: "draft" | "awaiting_publish" | "published"; @@ -1409,12 +2425,12 @@ export interface paths { content: { "application/json": { /** - * @example confirmed + * @example paid * @enum {string|null} */ status?: "created" | "confirmed" | "started" | "consumed" | "paid" | "fulfilled" | "canceled" | null; /** - * @example UNPAID + * @example PAID * @enum {string|null} */ payment_status?: "UNPAID" | "PAID" | null; @@ -1693,7 +2709,7 @@ export interface paths { content: { "application/json": { /** - * @example busy + * @example offline * @enum {string|null} */ status?: "offline" | "online" | "inactive" | "busy" | null; @@ -2168,9 +3184,31 @@ export interface paths { cookie?: never; }; get: operations["getApiAdminV1StudioCategoryBriefsId"]; - put: operations["putApiAdminV1StudioCategoryBriefsId"]; + put?: never; post?: never; - delete: operations["deleteApiAdminV1StudioCategoryBriefsId"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/studio/category-briefs/{category_brief_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the category brief. + * @example architecto + */ + category_brief_id: string; + }; + cookie?: never; + }; + get?: never; + put: operations["putApiAdminV1StudioCategoryBriefsCategory_brief_id"]; + post?: never; + delete: operations["deleteApiAdminV1StudioCategoryBriefsCategory_brief_id"]; options?: never; head?: never; patch?: never; @@ -2206,9 +3244,31 @@ export interface paths { cookie?: never; }; get: operations["getApiAdminV1StudioSubtopicsId"]; - put: operations["putApiAdminV1StudioSubtopicsId"]; + put?: never; post?: never; - delete: operations["deleteApiAdminV1StudioSubtopicsId"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/studio/subtopics/{subtopic_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the subtopic. + * @example architecto + */ + subtopic_id: string; + }; + cookie?: never; + }; + get?: never; + put: operations["putApiAdminV1StudioSubtopicsSubtopic_id"]; + post?: never; + delete: operations["deleteApiAdminV1StudioSubtopicsSubtopic_id"]; options?: never; head?: never; patch?: never; @@ -2271,9 +3331,31 @@ export interface paths { cookie?: never; }; get: operations["getApiAdminV1StudioArticlesId"]; - put: operations["putApiAdminV1StudioArticlesId"]; + put?: never; post?: never; - delete: operations["deleteApiAdminV1StudioArticlesId"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/studio/articles/{article_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the article. + * @example architecto + */ + article_id: string; + }; + cookie?: never; + }; + get?: never; + put: operations["putApiAdminV1StudioArticlesArticle_id"]; + post?: never; + delete: operations["deleteApiAdminV1StudioArticlesArticle_id"]; options?: never; head?: never; patch?: never; @@ -2309,9 +3391,31 @@ export interface paths { cookie?: never; }; get: operations["getApiAdminV1StudioSeriesId"]; - put: operations["putApiAdminV1StudioSeriesId"]; + put?: never; post?: never; - delete: operations["deleteApiAdminV1StudioSeriesId"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/studio/series/{series_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the series. + * @example architecto + */ + series_id: string; + }; + cookie?: never; + }; + get?: never; + put: operations["putApiAdminV1StudioSeriesSeries_id"]; + post?: never; + delete: operations["deleteApiAdminV1StudioSeriesSeries_id"]; options?: never; head?: never; patch?: never; @@ -2347,9 +3451,31 @@ export interface paths { cookie?: never; }; get: operations["getApiAdminV1StudioRobotAuthorsId"]; - put: operations["putApiAdminV1StudioRobotAuthorsId"]; + put?: never; post?: never; - delete: operations["deleteApiAdminV1StudioRobotAuthorsId"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/studio/robot-authors/{robot_author_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the robot author. + * @example architecto + */ + robot_author_id: string; + }; + cookie?: never; + }; + get?: never; + put: operations["putApiAdminV1StudioRobotAuthorsRobot_author_id"]; + post?: never; + delete: operations["deleteApiAdminV1StudioRobotAuthorsRobot_author_id"]; options?: never; head?: never; patch?: never; @@ -2385,9 +3511,31 @@ export interface paths { cookie?: never; }; get: operations["getApiAdminV1StudioStylePresetsId"]; - put: operations["putApiAdminV1StudioStylePresetsId"]; + put?: never; post?: never; - delete: operations["deleteApiAdminV1StudioStylePresetsId"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/v1/studio/style-presets/{style_preset_id}": { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The ID of the style preset. + * @example architecto + */ + style_preset_id: string; + }; + cookie?: never; + }; + get?: never; + put: operations["putApiAdminV1StudioStylePresetsStyle_preset_id"]; + post?: never; + delete: operations["deleteApiAdminV1StudioStylePresetsStyle_preset_id"]; options?: never; head?: never; patch?: never; @@ -2431,7 +3579,7 @@ export interface paths { content: { "application/json": { /** - * @example blocked + * @example all * @enum {string|null} */ status?: "pending" | "blocked" | "all" | null; @@ -4156,7 +5304,7 @@ export interface operations { }; }; }; - putApiAdminV1StudioCategoryBriefsId: { + putApiAdminV1StudioCategoryBriefsCategory_brief_id: { parameters: { query?: never; header?: never; @@ -4165,14 +5313,14 @@ export interface operations { * @description The ID of the category brief. * @example architecto */ - id: string; + category_brief_id: string; }; cookie?: never; }; requestBody?: never; responses: never; }; - deleteApiAdminV1StudioCategoryBriefsId: { + deleteApiAdminV1StudioCategoryBriefsCategory_brief_id: { parameters: { query?: never; header?: never; @@ -4181,7 +5329,7 @@ export interface operations { * @description The ID of the category brief. * @example architecto */ - id: string; + category_brief_id: string; }; cookie?: never; }; @@ -4256,7 +5404,7 @@ export interface operations { }; }; }; - putApiAdminV1StudioSubtopicsId: { + putApiAdminV1StudioSubtopicsSubtopic_id: { parameters: { query?: never; header?: never; @@ -4265,14 +5413,14 @@ export interface operations { * @description The ID of the subtopic. * @example architecto */ - id: string; + subtopic_id: string; }; cookie?: never; }; requestBody?: never; responses: never; }; - deleteApiAdminV1StudioSubtopicsId: { + deleteApiAdminV1StudioSubtopicsSubtopic_id: { parameters: { query?: never; header?: never; @@ -4281,7 +5429,7 @@ export interface operations { * @description The ID of the subtopic. * @example architecto */ - id: string; + subtopic_id: string; }; cookie?: never; }; @@ -4388,7 +5536,7 @@ export interface operations { }; }; }; - putApiAdminV1StudioArticlesId: { + putApiAdminV1StudioArticlesArticle_id: { parameters: { query?: never; header?: never; @@ -4397,14 +5545,14 @@ export interface operations { * @description The ID of the article. * @example architecto */ - id: string; + article_id: string; }; cookie?: never; }; requestBody?: never; responses: never; }; - deleteApiAdminV1StudioArticlesId: { + deleteApiAdminV1StudioArticlesArticle_id: { parameters: { query?: never; header?: never; @@ -4413,7 +5561,7 @@ export interface operations { * @description The ID of the article. * @example architecto */ - id: string; + article_id: string; }; cookie?: never; }; @@ -4488,7 +5636,7 @@ export interface operations { }; }; }; - putApiAdminV1StudioSeriesId: { + putApiAdminV1StudioSeriesSeries_id: { parameters: { query?: never; header?: never; @@ -4497,14 +5645,14 @@ export interface operations { * @description The ID of the series. * @example architecto */ - id: string; + series_id: string; }; cookie?: never; }; requestBody?: never; responses: never; }; - deleteApiAdminV1StudioSeriesId: { + deleteApiAdminV1StudioSeriesSeries_id: { parameters: { query?: never; header?: never; @@ -4513,7 +5661,7 @@ export interface operations { * @description The ID of the series. * @example architecto */ - id: string; + series_id: string; }; cookie?: never; }; @@ -4588,7 +5736,7 @@ export interface operations { }; }; }; - putApiAdminV1StudioRobotAuthorsId: { + putApiAdminV1StudioRobotAuthorsRobot_author_id: { parameters: { query?: never; header?: never; @@ -4597,14 +5745,14 @@ export interface operations { * @description The ID of the robot author. * @example architecto */ - id: string; + robot_author_id: string; }; cookie?: never; }; requestBody?: never; responses: never; }; - deleteApiAdminV1StudioRobotAuthorsId: { + deleteApiAdminV1StudioRobotAuthorsRobot_author_id: { parameters: { query?: never; header?: never; @@ -4613,7 +5761,7 @@ export interface operations { * @description The ID of the robot author. * @example architecto */ - id: string; + robot_author_id: string; }; cookie?: never; }; @@ -4688,7 +5836,7 @@ export interface operations { }; }; }; - putApiAdminV1StudioStylePresetsId: { + putApiAdminV1StudioStylePresetsStyle_preset_id: { parameters: { query?: never; header?: never; @@ -4697,14 +5845,14 @@ export interface operations { * @description The ID of the style preset. * @example architecto */ - id: string; + style_preset_id: string; }; cookie?: never; }; requestBody?: never; responses: never; }; - deleteApiAdminV1StudioStylePresetsId: { + deleteApiAdminV1StudioStylePresetsStyle_preset_id: { parameters: { query?: never; header?: never; @@ -4713,7 +5861,7 @@ export interface operations { * @description The ID of the style preset. * @example architecto */ - id: string; + style_preset_id: string; }; cookie?: never; }; @@ -4759,7 +5907,7 @@ export interface operations { }; requestBody?: never; responses: { - 403: { + 401: { headers: { [name: string]: unknown; }; @@ -4767,9 +5915,9 @@ export interface operations { "application/json": { /** @example false */ success?: boolean; - /** @example You are not authorized to access this resource. (missing permission: admin:category:approve) */ + /** @example Authentication required */ message?: string; - /** @example HTTP_ERROR */ + /** @example UNAUTHENTICATED */ code?: string; }; }; diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts new file mode 100644 index 0000000..69dc8e7 --- /dev/null +++ b/src/auth/permissions.ts @@ -0,0 +1,53 @@ +import { redirect } from '@tanstack/react-router' +import { can, useAuthStore } from '@/auth/store' + +/** + * Admin permission keys — mirrors the backend catalog + * (Modules/{Module}/app/Permissions/{Module}Permissions.php). + */ +export const PERM = { + CATEGORY_READ: 'admin:category:read', + CATEGORY_CREATE: 'admin:category:create', + CATEGORY_UPDATE: 'admin:category:update', + CATEGORY_DELETE: 'admin:category:delete', + CATEGORY_APPROVE: 'admin:category:approve', + COMMENT_READ: 'admin:comment:read', + COMMENT_MODERATE: 'admin:comment:moderate', + FILEX_READ: 'admin:filex:read', + FILEX_MODERATE: 'admin:filex:moderate', + DIMZOU_READ: 'admin:dimzou:read', + DIMZOU_RETRANSLATE: 'admin:dimzou:retranslate', + EXC_READ: 'admin:exc:read', + STUDIO_READ: 'admin:studio:read', + STUDIO_MANAGE: 'admin:studio:manage', + STUDIO_AGENT_TOKEN_ISSUE: 'admin:studio:agent-token:issue', + LOCALE_READ: 'admin:locale:read', + LOCALE_CREATE: 'admin:locale:create', + LOCALE_UPDATE: 'admin:locale:update', + LOCALE_DELETE: 'admin:locale:delete', + SID_MANAGE: 'admin:sid:manage', + SID_OVERRIDE: 'admin:sid:override', + NOTIFICATION_READ: 'admin:notification:read', + ROBOT_TOKEN_ISSUE: 'auth:robot-token:issue', + // role management — only super-admin holds these + ROLE_READ: 'auth:role:read', + ROLE_CREATE: 'auth:role:create', + ROLE_UPDATE: 'auth:role:update', + ROLE_DELETE: 'auth:role:delete', + ROLE_ASSIGN: 'auth:role:assign', + PERMISSION_READ: 'auth:permission:read', +} as const + +export type Permission = (typeof PERM)[keyof typeof PERM] + +/** + * Route `beforeLoad` guard. Redirects home when the session is loaded and + * lacks the permission; lets the request through while the session is still + * empty (the API's 403 is the backstop, and /auth/me refreshes the store). + */ +export function requirePermission(permission: Permission): void { + const { user } = useAuthStore.getState() + if (user && !can(permission)) { + throw redirect({ to: '/' }) + } +} diff --git a/src/auth/store.test.ts b/src/auth/store.test.ts new file mode 100644 index 0000000..c9f02ce --- /dev/null +++ b/src/auth/store.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { can, hasRole, useAuthStore } from '@/auth/store' + +const user = { + kind: 'user' as const, + uid: '01TEST', + username: 'tester', + email: 't@example.com', + phone: null, + display_name: '测试', + avatar: null, + status: true, +} + +describe('can()', () => { + beforeEach(() => { + useAuthStore.getState().clear() + }) + + it('denies when the permission is missing', () => { + useAuthStore.getState().setSession(user, ['admin'], ['admin:category:read']) + expect(can('admin:category:read')).toBe(true) + expect(can('admin:sid:manage')).toBe(false) + }) + + it('super-admin passes every permission check', () => { + useAuthStore.getState().setSession(user, ['super-admin'], []) + expect(can('admin:sid:manage')).toBe(true) + expect(can('anything:at:all')).toBe(true) + }) + + it('hasRole matches exact role names', () => { + useAuthStore.getState().setSession(user, ['admin'], []) + expect(hasRole('admin')).toBe(true) + expect(hasRole('super-admin')).toBe(false) + }) +}) diff --git a/src/components/layout/nav.ts b/src/components/layout/nav.ts index 25dd977..43a357f 100644 --- a/src/components/layout/nav.ts +++ b/src/components/layout/nav.ts @@ -1,7 +1,9 @@ import type { LucideIcon } from 'lucide-react' import { + BookOpenText, Bot, ClipboardList, + FileText, Flag, FolderTree, Hash, @@ -16,9 +18,11 @@ import { Receipt, ScrollText, Send, + Shield, Smartphone, Sparkles, UserCheck, + Users, } from 'lucide-react' export interface NavItem { @@ -38,19 +42,19 @@ export const NAV_GROUPS: NavGroup[] = [ { label: '分类管理', items: [ - { label: '分类树', to: '/categories', icon: FolderTree }, - { label: '待审核分类', to: '/categories/pending', icon: ListChecks }, + { label: '分类树', to: '/categories', icon: FolderTree, permission: 'admin:category:read' }, + { label: '待审核分类', to: '/categories/pending', icon: ListChecks, permission: 'admin:category:approve' }, ], }, { label: '内容工作室', items: [ - { label: '机器人作者', to: '/studio/robot-authors', icon: Bot }, - { label: '文章系列', to: '/studio/series', icon: LayoutList }, - { label: '文章', to: '/studio/articles', icon: Newspaper }, - { label: '子话题', to: '/studio/subtopics', icon: Sparkles }, - { label: '分类简报', to: '/studio/category-briefs', icon: ScrollText }, - { label: '风格预设', to: '/studio/style-presets', icon: Palette }, + { label: '机器人作者', to: '/studio/robot-authors', icon: Bot, permission: 'admin:studio:read' }, + { label: '文章系列', to: '/studio/series', icon: LayoutList, permission: 'admin:studio:read' }, + { label: '文章', to: '/studio/articles', icon: Newspaper, permission: 'admin:studio:read' }, + { label: '子话题', to: '/studio/subtopics', icon: Sparkles, permission: 'admin:studio:read' }, + { label: '分类简报', to: '/studio/category-briefs', icon: ScrollText, permission: 'admin:studio:read' }, + { label: '风格预设', to: '/studio/style-presets', icon: Palette, permission: 'admin:studio:read' }, { label: 'Agent 接入', to: '/studio/agent-tokens', @@ -62,27 +66,36 @@ export const NAV_GROUPS: NavGroup[] = [ { label: '专长交易', items: [ - { label: '需求监控', to: '/exc/demands', icon: ClipboardList }, - { label: '派单监控', to: '/exc/dispatches', icon: Send }, - { label: '订单监控', to: '/exc/orders', icon: Receipt }, - { label: '服务商监控', to: '/exc/providers', icon: UserCheck }, + { label: '需求监控', to: '/exc/demands', icon: ClipboardList, permission: 'admin:exc:read' }, + { label: '派单监控', to: '/exc/dispatches', icon: Send, permission: 'admin:exc:read' }, + { label: '订单监控', to: '/exc/orders', icon: Receipt, permission: 'admin:exc:read' }, + { label: '服务商监控', to: '/exc/providers', icon: UserCheck, permission: 'admin:exc:read' }, + ], + }, + { + label: 'Dimzou 内容', + items: [ + { label: '文档监控', to: '/dimzou/documents', icon: FileText, permission: 'admin:dimzou:read' }, + { label: '发布监控', to: '/dimzou/publications', icon: BookOpenText, permission: 'admin:dimzou:read' }, + { label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages, permission: 'admin:dimzou:read' }, ], }, { label: '内容审核', items: [ - { label: '被举报评论', to: '/comment-reports', icon: MessageSquareWarning }, - { label: '被举报事件', to: '/event-reports', icon: Flag }, - { label: 'Dimzou 翻译', to: '/dimzou-translations', icon: Languages }, + { label: '被举报评论', to: '/comment-reports', icon: MessageSquareWarning, permission: 'admin:comment:read' }, + { label: '被举报事件', to: '/event-reports', icon: Flag, permission: 'admin:filex:read' }, ], }, { label: '用户与系统', items: [ - { label: 'SID 管理', to: '/sid', icon: Hash }, - { label: '语言设置', to: '/locales', icon: Languages }, - { label: '推送设备', to: '/devices', icon: Smartphone }, - { label: '机器人会话', to: '/robots', icon: Inbox }, + { label: '角色管理', to: '/roles', icon: Shield, permission: 'auth:role:read' }, + { label: '用户角色', to: '/admin-users', icon: Users, permission: 'auth:role:assign' }, + { label: 'SID 管理', to: '/sid', icon: Hash, permission: 'admin:sid:manage' }, + { label: '语言设置', to: '/locales', icon: Languages, permission: 'admin:locale:read' }, + { label: '推送设备', to: '/devices', icon: Smartphone, permission: 'admin:notification:read' }, + { label: '机器人会话', to: '/robots', icon: Inbox, permission: 'auth:robot-token:issue' }, ], }, ] diff --git a/src/lib/errors.ts b/src/lib/errors.ts index ba3724a..ea32fe9 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -17,6 +17,10 @@ export function handleApiError( } return } + if (error.status === 403) { + toast.error('无权限执行此操作') + return + } toast.error(error.message || `请求失败(${error.code})`) return } diff --git a/src/routes/_authed.tsx b/src/routes/_authed.tsx index 7645e03..6ef0d9b 100644 --- a/src/routes/_authed.tsx +++ b/src/routes/_authed.tsx @@ -3,7 +3,7 @@ import { useQuery } from '@tanstack/react-query' import { useEffect } from 'react' import { LogOut, Moon, Sun } from 'lucide-react' import { fetchMe } from '@/api/modules/auth' -import { isAuthenticated, useAuthStore } from '@/auth/store' +import { can, isAuthenticated, useAuthStore } from '@/auth/store' import { NAV_GROUPS } from '@/components/layout/nav' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' @@ -29,7 +29,8 @@ function AuthedLayout() { const navigate = useNavigate() const user = useAuthStore((s) => s.user) const roles = useAuthStore((s) => s.roles) - const permissions = useAuthStore((s) => s.permissions) + // subscribe so the sidebar re-filters when /auth/me refreshes permissions + useAuthStore((s) => s.permissions) // Re-validate the session on mount / focus; keeps roles+permissions fresh. const me = useQuery({ @@ -52,9 +53,8 @@ function AuthedLayout() { } }, [me.isError, navigate]) - const isSuper = roles.includes('super-admin') - const canSee = (permission?: string) => - !permission || isSuper || permissions.includes(permission) + // roles/permissions subscriptions above keep this reactive. + const canSee = (permission?: string) => !permission || can(permission) const logout = () => { useAuthStore.getState().clear() diff --git a/src/routes/_authed/categories/index.tsx b/src/routes/_authed/categories/index.tsx index ca05083..8d834ed 100644 --- a/src/routes/_authed/categories/index.tsx +++ b/src/routes/_authed/categories/index.tsx @@ -1,4 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' +import { useCan } from '@/auth/store' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { ChevronRight, Pencil, Plus, Trash2 } from 'lucide-react' @@ -15,10 +17,12 @@ import { Button } from '@/components/ui/button' import { Skeleton } from '@/components/ui/skeleton' export const Route = createFileRoute('/_authed/categories/')({ + beforeLoad: () => requirePermission(PERM.CATEGORY_READ), component: CategoriesPage, }) function CategoriesPage() { + const canCreate = useCan(PERM.CATEGORY_CREATE) const queryClient = useQueryClient() const tree = useQuery({ queryKey: ['categories', 'tree'], @@ -57,10 +61,12 @@ function CategoriesPage() { title="分类树" description="平台服务分类体系;含未启用分类" actions={ - + canCreate && ( + + ) } /> @@ -113,6 +119,9 @@ function TreeNode({ }) { const [open, setOpen] = useState(depth === 0) const children = node.children ?? [] + const canCreate = useCan(PERM.CATEGORY_CREATE) + const canUpdate = useCan(PERM.CATEGORY_UPDATE) + const canDelete = useCan(PERM.CATEGORY_DELETE) return (
0 && 'border-l')} style={{ marginLeft: depth > 0 ? 20 : 0 }}> @@ -132,24 +141,30 @@ function TreeNode({ {!node.is_active && 未启用} {node.approval_status === 'pending' && 待审核} - - - - - - } - title={`删除分类「${node.name ?? node.slug}」?`} - description="删除后不可恢复;有子分类或被引用时后端会拒绝。" - confirmLabel="删除" - destructive - onConfirm={() => onDelete(node)} - /> + {canCreate && ( + + )} + {canUpdate && ( + + )} + {canDelete && ( + + + + } + title={`删除分类「${node.name ?? node.slug}」?`} + description="删除后不可恢复;有子分类或被引用时后端会拒绝。" + confirmLabel="删除" + destructive + onConfirm={() => onDelete(node)} + /> + )}
{open && diff --git a/src/routes/_authed/categories/pending.tsx b/src/routes/_authed/categories/pending.tsx index 30765bc..8081a2b 100644 --- a/src/routes/_authed/categories/pending.tsx +++ b/src/routes/_authed/categories/pending.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' @@ -34,6 +35,7 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/categories/pending')({ + beforeLoad: () => requirePermission(PERM.CATEGORY_APPROVE), component: PendingCategoriesPage, }) diff --git a/src/routes/_authed/comment-reports.tsx b/src/routes/_authed/comment-reports.tsx index bfabcb9..9eb511d 100644 --- a/src/routes/_authed/comment-reports.tsx +++ b/src/routes/_authed/comment-reports.tsx @@ -1,4 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' +import { useCan } from '@/auth/store' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import { toast } from 'sonner' @@ -22,10 +24,12 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/comment-reports')({ + beforeLoad: () => requirePermission(PERM.COMMENT_READ), component: ReportedCommentsPage, }) function ReportedCommentsPage() { + const canModerate = useCan(PERM.COMMENT_MODERATE) const [status, setStatus] = useState('pending') const [detail, setDetail] = useState(null) const list = useListPage( @@ -92,7 +96,9 @@ function ReportedCommentsPage() { header: '操作', cell: ({ row }) => (
- {row.original.is_blocked ? ( + {!canModerate ? ( + + ) : row.original.is_blocked ? ( diff --git a/src/routes/_authed/devices.tsx b/src/routes/_authed/devices.tsx index 91b70f4..e9f98ab 100644 --- a/src/routes/_authed/devices.tsx +++ b/src/routes/_authed/devices.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import type { NotificationDevice } from '@/api/types' @@ -16,6 +17,7 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/devices')({ + beforeLoad: () => requirePermission(PERM.NOTIFICATION_READ), component: DevicesPage, }) diff --git a/src/routes/_authed/dimzou-translations.tsx b/src/routes/_authed/dimzou-translations.tsx index cda2961..1790bb8 100644 --- a/src/routes/_authed/dimzou-translations.tsx +++ b/src/routes/_authed/dimzou-translations.tsx @@ -1,4 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' +import { useCan } from '@/auth/store' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import { toast } from 'sonner' @@ -21,6 +23,7 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/dimzou-translations')({ + beforeLoad: () => requirePermission(PERM.DIMZOU_READ), component: DimzouTranslationsPage, }) @@ -31,6 +34,7 @@ const STATUS_LABELS: Record = { } function DimzouTranslationsPage() { + const canRetranslate = useCan(PERM.DIMZOU_RETRANSLATE) const [status, setStatus] = useState('all') // language filter applies on Enter/blur to avoid a request per keystroke const [languageInput, setLanguageInput] = useState('') @@ -111,18 +115,21 @@ function DimzouTranslationsPage() { }, { header: '操作', - cell: ({ row }) => ( - - 重新机器翻译 - - } - title={`重新翻译「${row.original.source_document?.title ?? `#${row.original.id}`}」→ ${row.original.language_code}?`} - description="将最新草稿版本中待译和失败的块重新入队机器翻译。" - onConfirm={() => requeue(row.original)} - /> - ), + cell: ({ row }) => + canRetranslate ? ( + + 重新机器翻译 + + } + title={`重新翻译「${row.original.source_document?.title ?? `#${row.original.id}`}」→ ${row.original.language_code}?`} + description="将最新草稿版本中待译和失败的块重新入队机器翻译。" + onConfirm={() => requeue(row.original)} + /> + ) : ( + + ), }, ] diff --git a/src/routes/_authed/event-reports.tsx b/src/routes/_authed/event-reports.tsx index 6dfa321..3df5f05 100644 --- a/src/routes/_authed/event-reports.tsx +++ b/src/routes/_authed/event-reports.tsx @@ -1,4 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' +import { useCan } from '@/auth/store' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import { toast } from 'sonner' @@ -23,10 +25,12 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/event-reports')({ + beforeLoad: () => requirePermission(PERM.FILEX_READ), component: ReportedEventsPage, }) function ReportedEventsPage() { + const canModerate = useCan(PERM.FILEX_MODERATE) const [status, setStatus] = useState('pending') const [detail, setDetail] = useState(null) const list = useListPage( @@ -97,7 +101,9 @@ function ReportedEventsPage() { header: '操作', cell: ({ row }) => (
- {row.original.is_blocked ? ( + {!canModerate ? ( + + ) : row.original.is_blocked ? ( diff --git a/src/routes/_authed/exc/demands.tsx b/src/routes/_authed/exc/demands.tsx index 8eac68c..31fcca1 100644 --- a/src/routes/_authed/exc/demands.tsx +++ b/src/routes/_authed/exc/demands.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' @@ -25,6 +26,7 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/exc/demands')({ + beforeLoad: () => requirePermission(PERM.EXC_READ), component: ExcDemandsPage, }) diff --git a/src/routes/_authed/exc/dispatches.tsx b/src/routes/_authed/exc/dispatches.tsx index d651715..caabeaa 100644 --- a/src/routes/_authed/exc/dispatches.tsx +++ b/src/routes/_authed/exc/dispatches.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import type { ExcDispatch } from '@/api/types' @@ -17,6 +18,7 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/exc/dispatches')({ + beforeLoad: () => requirePermission(PERM.EXC_READ), component: ExcDispatchesPage, }) diff --git a/src/routes/_authed/exc/orders.tsx b/src/routes/_authed/exc/orders.tsx index e3a008c..13061d2 100644 --- a/src/routes/_authed/exc/orders.tsx +++ b/src/routes/_authed/exc/orders.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' @@ -25,6 +26,7 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/exc/orders')({ + beforeLoad: () => requirePermission(PERM.EXC_READ), component: ExcOrdersPage, }) diff --git a/src/routes/_authed/exc/providers.tsx b/src/routes/_authed/exc/providers.tsx index c7b6a5c..dfde202 100644 --- a/src/routes/_authed/exc/providers.tsx +++ b/src/routes/_authed/exc/providers.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useQuery } from '@tanstack/react-query' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' @@ -24,6 +25,7 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/exc/providers')({ + beforeLoad: () => requirePermission(PERM.EXC_READ), component: ExcProvidersPage, }) diff --git a/src/routes/_authed/locales.tsx b/src/routes/_authed/locales.tsx index bb77e6f..7e1c9d1 100644 --- a/src/routes/_authed/locales.tsx +++ b/src/routes/_authed/locales.tsx @@ -1,4 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' +import { useCan } from '@/auth/store' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { Plus, Trash2 } from 'lucide-react' @@ -23,10 +25,14 @@ import { Label } from '@/components/ui/label' import { Switch } from '@/components/ui/switch' export const Route = createFileRoute('/_authed/locales')({ + beforeLoad: () => requirePermission(PERM.LOCALE_READ), component: LocalesPage, }) function LocalesPage() { + const canCreate = useCan(PERM.LOCALE_CREATE) + const canUpdate = useCan(PERM.LOCALE_UPDATE) + const canDelete = useCan(PERM.LOCALE_DELETE) const queryClient = useQueryClient() const locales = useQuery({ queryKey: ['locales'], queryFn: fetchLocales }) const [dialogOpen, setDialogOpen] = useState(false) @@ -65,25 +71,29 @@ function LocalesPage() { cell: ({ row }) => ( toggle(row.original, v)} /> ), }, { header: '操作', - cell: ({ row }) => ( - - - - } - title={`删除语言 ${row.original.code}?`} - confirmLabel="删除" - destructive - onConfirm={() => remove(row.original)} - /> - ), + cell: ({ row }) => + canDelete ? ( + + + + } + title={`删除语言 ${row.original.code}?`} + confirmLabel="删除" + destructive + onConfirm={() => remove(row.original)} + /> + ) : ( + + ), }, ] @@ -93,10 +103,12 @@ function LocalesPage() { title="语言设置" description="平台支持的界面/内容语言" actions={ - + canCreate && ( + + ) } /> requirePermission(PERM.ROBOT_TOKEN_ISSUE), component: RobotsPage, }) diff --git a/src/routes/_authed/sid.tsx b/src/routes/_authed/sid.tsx index 95874c3..dc265a4 100644 --- a/src/routes/_authed/sid.tsx +++ b/src/routes/_authed/sid.tsx @@ -1,4 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' +import { useCan } from '@/auth/store' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { Plus, Trash2 } from 'lucide-react' @@ -26,6 +28,7 @@ import { Switch } from '@/components/ui/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' export const Route = createFileRoute('/_authed/sid')({ + beforeLoad: () => requirePermission(PERM.SID_MANAGE), component: SidPage, }) @@ -190,6 +193,7 @@ function LuckyTab() { } function AssignTab() { + const canOverride = useCan(PERM.SID_OVERRIDE) const [sid, setSid] = useState('') const [checkResult, setCheckResult] = useState(null) const [userUid, setUserUid] = useState('') @@ -254,6 +258,11 @@ function AssignTab() { 可分配 )} + {checkResult.reserved && !canOverride && ( +

+ 分配保留号段需要 admin:sid:override 权限 +

+ )}
)}
@@ -266,7 +275,10 @@ function AssignTab() { placeholder="01JXXXXXXXXXXXXXXXXXXXXXXX" />
- diff --git a/src/routes/_authed/studio/agent-tokens.tsx b/src/routes/_authed/studio/agent-tokens.tsx index ff7589f..285813a 100644 --- a/src/routes/_authed/studio/agent-tokens.tsx +++ b/src/routes/_authed/studio/agent-tokens.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { KeyRound } from 'lucide-react' @@ -14,6 +15,7 @@ import { ShowOnceTokenDialog } from '@/components/show-once-token-dialog' import { Button } from '@/components/ui/button' export const Route = createFileRoute('/_authed/studio/agent-tokens')({ + beforeLoad: () => requirePermission(PERM.STUDIO_AGENT_TOKEN_ISSUE), component: AgentTokensPage, }) diff --git a/src/routes/_authed/studio/articles.tsx b/src/routes/_authed/studio/articles.tsx index 3aefd98..c425a56 100644 --- a/src/routes/_authed/studio/articles.tsx +++ b/src/routes/_authed/studio/articles.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import type { StudioArticle } from '@/api/types' @@ -19,6 +20,7 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/studio/articles')({ + beforeLoad: () => requirePermission(PERM.STUDIO_READ), component: ArticlesPage, }) diff --git a/src/routes/_authed/studio/category-briefs.tsx b/src/routes/_authed/studio/category-briefs.tsx index 9502a91..8e8eef5 100644 --- a/src/routes/_authed/studio/category-briefs.tsx +++ b/src/routes/_authed/studio/category-briefs.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import type { StudioCategoryBrief } from '@/api/types' @@ -10,6 +11,7 @@ import { PageHeader } from '@/components/page-header' import { Button } from '@/components/ui/button' export const Route = createFileRoute('/_authed/studio/category-briefs')({ + beforeLoad: () => requirePermission(PERM.STUDIO_READ), component: CategoryBriefsPage, }) diff --git a/src/routes/_authed/studio/robot-authors.tsx b/src/routes/_authed/studio/robot-authors.tsx index 71dad5a..7dba252 100644 --- a/src/routes/_authed/studio/robot-authors.tsx +++ b/src/routes/_authed/studio/robot-authors.tsx @@ -1,4 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' +import { useCan } from '@/auth/store' import { useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { X } from 'lucide-react' @@ -28,12 +30,14 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/studio/robot-authors')({ + beforeLoad: () => requirePermission(PERM.STUDIO_READ), component: RobotAuthorsPage, }) const STATUSES = ['pending', 'profiling', 'ready', 'paused'] as const function RobotAuthorsPage() { + const canManage = useCan(PERM.STUDIO_MANAGE) const queryClient = useQueryClient() const [status, setStatus] = useState('all') const [categoryId, setCategoryId] = useState(null) @@ -114,17 +118,21 @@ function RobotAuthorsPage() { - - {row.original.status === 'paused' ? ( - - ) : ( - + {canManage && ( + <> + + {row.original.status === 'paused' ? ( + + ) : ( + + )} + )}
), diff --git a/src/routes/_authed/studio/series.tsx b/src/routes/_authed/studio/series.tsx index 6924384..063212d 100644 --- a/src/routes/_authed/studio/series.tsx +++ b/src/routes/_authed/studio/series.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import type { StudioSeries } from '@/api/types' @@ -11,6 +12,7 @@ import { StatusPill } from '@/components/status-pill' import { Button } from '@/components/ui/button' export const Route = createFileRoute('/_authed/studio/series')({ + beforeLoad: () => requirePermission(PERM.STUDIO_READ), component: SeriesPage, }) diff --git a/src/routes/_authed/studio/style-presets.tsx b/src/routes/_authed/studio/style-presets.tsx index bbb78a7..440fa92 100644 --- a/src/routes/_authed/studio/style-presets.tsx +++ b/src/routes/_authed/studio/style-presets.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import { useState } from 'react' import type { ColumnDef } from '@tanstack/react-table' import type { StudioStylePreset } from '@/api/types' @@ -18,6 +19,7 @@ import { } from '@/components/ui/select' export const Route = createFileRoute('/_authed/studio/style-presets')({ + beforeLoad: () => requirePermission(PERM.STUDIO_READ), component: StylePresetsPage, }) diff --git a/src/routes/_authed/studio/subtopics.tsx b/src/routes/_authed/studio/subtopics.tsx index e1bf145..cfcac1e 100644 --- a/src/routes/_authed/studio/subtopics.tsx +++ b/src/routes/_authed/studio/subtopics.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' +import { PERM, requirePermission } from '@/auth/permissions' import type { ColumnDef } from '@tanstack/react-table' import type { StudioSubtopic } from '@/api/types' import { fetchSubtopics } from '@/api/modules/studio' @@ -8,6 +9,7 @@ import { PageHeader } from '@/components/page-header' import { StatusPill } from '@/components/status-pill' export const Route = createFileRoute('/_authed/studio/subtopics')({ + beforeLoad: () => requirePermission(PERM.STUDIO_READ), component: SubtopicsPage, })