From 55d024ae6c1c1ca38483207748b0d5f27fe08d4c Mon Sep 17 00:00:00 2001 From: kevin Date: Tue, 22 Sep 2026 11:52:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=A4=9A=E7=BA=A7=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E5=88=86=E7=B1=BB=E3=80=81=E5=86=85=E5=AE=B9=E5=88=86?= =?UTF-8?q?=E7=BA=A7=E4=B8=8E=E7=94=A8=E6=88=B7=E5=81=8F=E5=A5=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 迁移 v13 新增 video_categories(parent_id/level/path/slug/rating/icon/cover)与 video_category_translations,最多 3 级 - 公开树接口只返回启用且祖先启用的节点;管理员可增删改查,支持移动防环、子树层级同步、有子禁止删除、图标封面引用计数 - 迁移 v14 为分类增加分级字段(G/PG/PG13/NC16/M18/R21 = 0-5),存自身值并计算 effective_rating 向下覆盖;后台编辑器与树行展示覆盖提示 - 迁移 v15 为 users 增加 rating/locale/theme/theme_mode,PUT /me 支持部分更新与清空;前端登录/刷新应用账号偏好,切换语言/主题自动同步 - 分级常量统一为 model.Rating*,file.SyncRef 抽出供 site 与分类共用;后台新增视频分类管理页与三语文案 - 补充分类、分级、偏好相关测试,开发规范新增 4.1/4.2 说明,重新生成 Swagger 文档 --- docs/backend-development.md | 31 +- docs/docs.go | 482 ++++++++++++- docs/swagger.json | 482 ++++++++++++- docs/swagger.yaml | 341 ++++++++- frontend/src/api/auth.ts | 4 + frontend/src/api/file.ts | 16 + frontend/src/api/profile.ts | 10 +- frontend/src/api/videocategory.ts | 62 ++ .../src/components/layout/AdminLayout.vue | 1 + .../components/layout/LanguageSwitcher.vue | 10 +- .../src/components/layout/ThemeSwitcher.vue | 24 +- frontend/src/constants/rating.ts | 18 + frontend/src/i18n/index.ts | 2 + frontend/src/i18n/locales/en-US.ts | 59 +- frontend/src/i18n/locales/ja-JP.ts | 59 +- frontend/src/i18n/locales/zh-CN.ts | 59 +- frontend/src/router/index.ts | 5 + frontend/src/stores/auth.ts | 25 + frontend/src/stores/theme.ts | 2 + .../src/views/AdminVideoCategoriesView.vue | 676 ++++++++++++++++++ frontend/src/views/ProfileView.vue | 33 + internal/api/api.go | 9 +- internal/auth/auth_test.go | 77 ++ internal/auth/profile.go | 32 +- internal/database/database_test.go | 11 +- internal/database/migrate.go | 21 + internal/file/file.go | 16 + internal/model/rating.go | 11 + internal/model/user.go | 4 + internal/model/video_category.go | 41 ++ internal/site/site.go | 19 +- internal/videocategory/videocategory.go | 593 +++++++++++++++ internal/videocategory/videocategory_test.go | 464 ++++++++++++ 33 files changed, 3659 insertions(+), 40 deletions(-) create mode 100644 frontend/src/api/file.ts create mode 100644 frontend/src/api/videocategory.ts create mode 100644 frontend/src/constants/rating.ts create mode 100644 frontend/src/views/AdminVideoCategoriesView.vue create mode 100644 internal/model/rating.go create mode 100644 internal/model/video_category.go create mode 100644 internal/videocategory/videocategory.go create mode 100644 internal/videocategory/videocategory_test.go diff --git a/docs/backend-development.md b/docs/backend-development.md index 94d082d..3b0cac6 100644 --- a/docs/backend-development.md +++ b/docs/backend-development.md @@ -14,7 +14,8 @@ internal/ ├── usergroup/ 用户组管理 ├── note/ 便签 ├── site/ 站点信息 -├── nav/ 头部导航链接 +├── nav/ 头部/底部导航链接(多语言) +├── videocategory/ 多级视频分类(多语言、图标封面) ├── file/ 文件上传、删除、查看与本地存储 ├── avatar/ 当前用户头像 ├── httpx/ HTTP 公共能力:ErrorResponse、分页、ID 解析 @@ -97,6 +98,34 @@ password, err := utils.RandomString(16) - 仅日期字段使用 `model.Date`(JSON 为 `YYYY-MM-DD`,未设置输出 `null`) - 状态字段沿用 `int8`(`status`)或字符串枚举(`gender`、`file operation`),取值定义在 `model` 包 +### 4.1 视频分类分级 + +视频分类通过 `video_categories.rating` 保存自身分级,取值使用 `model.Rating*` 常量: + +| 等级 | 值 | 说明 | +| --- | --- | --- | +| G | 0 | 普通观众级,适合所有人观赏 | +| PG | 1 | 家长指导级,建议在家长指导下观赏 | +| PG13 | 2 | 特别辅导级,13 岁以下儿童建议在家长陪伴下观赏 | +| NC16 | 3 | 适合十六岁以上人士观赏 | +| M18 | 4 | 适合成年人观赏 | +| R21 | 5 | 适合二十一岁以上成年人观赏 | + +- 分级为“向下覆盖”:任一分类的生效等级 = 自身与所有祖先等级的最大值,接口返回 `effective_rating`(计算值,不落库) +- 子分类允许设置低于父级的等级,但展示与后续视频过滤必须使用 `effective_rating`,不得直接依赖 `rating` +- 创建/更新分类必须显式提交 `rating`,缺失或超出 0-5 返回 400 + +### 4.2 用户偏好 + +`users` 表保存用户偏好,空值表示回退本地或浏览器设置: + +- `rating`:年龄分级(默认 `G=0`,与内容分级共用 `model.Rating*`),仅存储,供后续内容过滤使用 +- `locale`:界面语言(`zh-CN`/`en-US`/`ja-JP`),空串跟随浏览器 +- `theme`:颜色主题(`emerald`/`indigo`/`orange`/`blue`),空串跟随本地 +- `theme_mode`:明暗模式(`light`/`dark`/`system`),空串跟随本地或浏览器 + +统一通过 `PUT /api/me` 部分更新:缺省字段不变、空串清空;前端登录/刷新时用账号偏好覆盖本地,登录状态下切换语言/主题会自动同步到账号。 + ## 5. 测试与验证 - 接口层测试使用 `internal/testutil`(临时库、鉴权路由、请求辅助),业务/工具测试放在对应包 diff --git a/docs/docs.go b/docs/docs.go index e8e7196..b98819f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -374,7 +374,7 @@ const docTemplate = `{ "BearerAuth": [] } ], - "description": "Update the authenticated user's nickname, gender, and birthday. birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.", + "description": "Update the authenticated user's profile and preferences (nickname, gender, birthday, rating, locale, theme, theme_mode). Omitted fields keep their current value; an empty string clears a preference. Rating is 0-5 (G/R21), locale is zh-CN/en-US/ja-JP, theme is emerald/indigo/orange/blue, theme_mode is light/dark/system.", "consumes": [ "application/json" ], @@ -2097,6 +2097,297 @@ const docTemplate = `{ } } } + }, + "/video-categories": { + "get": { + "description": "Public video category tree (max 3 levels, enabled categories whose ancestors are all enabled), ordered by sort ASC then id ASC, with translations.", + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "List video categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.VideoCategory" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin only. Create a video category. parent_id is optional (null or 0 for root); level is limited to 3; slug must be unique and match ^[a-z0-9]+(-[a-z0-9]+)*$; icon and cover accept uploaded file URLs.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Create a video category", + "parameters": [ + { + "description": "Video category payload", + "name": "category", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videocategory.Request" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.VideoCategory" + } + }, + "400": { + "description": "invalid request, parent not found, or level exceeds limit", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "admin permission required or account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "409": { + "description": "slug already exists", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, + "/video-categories/list": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin only. Video category tree including disabled categories, with translations.", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "List all video categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.VideoCategory" + } + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "admin permission required or account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, + "/video-categories/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin only. Update a video category (full update); translations are replaced by the provided list. parent_id omitted or null moves the category to root. Moving checks for cycles and keeps the whole subtree within 3 levels.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Update a video category", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Video category payload", + "name": "category", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videocategory.Request" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.VideoCategory" + } + }, + "400": { + "description": "invalid request, parent not found, cycle, or level exceeds limit", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "admin permission required or account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "404": { + "description": "record not found", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "409": { + "description": "slug already exists", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin only. Delete a leaf video category; categories with children return 409. Icon and cover file references are released.", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Delete a video category", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "400": { + "description": "invalid id", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "admin permission required or account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "404": { + "description": "record not found", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "409": { + "description": "category has children", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } } }, "definitions": { @@ -2184,10 +2475,34 @@ const docTemplate = `{ "type": "string", "example": "male" }, + "locale": { + "type": "string", + "example": "zh-CN" + }, "nickname": { "type": "string", "maxLength": 50, "example": "Alice" + }, + "rating": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "example": 1 + }, + "theme": { + "type": "string", + "example": "emerald" + }, + "theme_mode": { + "type": "string", + "example": "system" } } }, @@ -2372,12 +2687,28 @@ const docTemplate = `{ "id": { "type": "integer" }, + "locale": { + "type": "string", + "example": "zh-CN" + }, "nickname": { "type": "string" }, + "rating": { + "type": "integer", + "example": 0 + }, "status": { "type": "integer" }, + "theme": { + "type": "string", + "example": "emerald" + }, + "theme_mode": { + "type": "string", + "example": "system" + }, "updated_at": { "type": "string" }, @@ -2409,6 +2740,76 @@ const docTemplate = `{ } } }, + "model.VideoCategory": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/model.VideoCategory" + } + }, + "cover": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "effective_rating": { + "type": "integer" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "parent_id": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "rating": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "sort": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "translations": { + "type": "array", + "items": { + "$ref": "#/definitions/model.VideoCategoryTranslation" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "model.VideoCategoryTranslation": { + "type": "object", + "properties": { + "locale": { + "type": "string" + }, + "name": { + "type": "string" + }, + "video_category_id": { + "type": "integer" + } + } + }, "nav.Request": { "type": "object", "required": [ @@ -2721,6 +3122,85 @@ const docTemplate = `{ "example": "Operations" } } + }, + "videocategory.Request": { + "type": "object", + "required": [ + "rating", + "slug", + "translations" + ], + "properties": { + "cover": { + "type": "string", + "maxLength": 500, + "example": "https://example.com/cover.png" + }, + "icon": { + "type": "string", + "maxLength": 500, + "example": "https://example.com/icon.png" + }, + "parent_id": { + "type": "integer", + "example": 0 + }, + "rating": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "example": 1 + }, + "slug": { + "type": "string", + "maxLength": 100, + "example": "anime" + }, + "sort": { + "type": "integer", + "example": 0 + }, + "status": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "example": 1 + }, + "translations": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/videocategory.TranslationRequest" + } + } + } + }, + "videocategory.TranslationRequest": { + "type": "object", + "required": [ + "locale", + "name" + ], + "properties": { + "locale": { + "type": "string", + "maxLength": 10, + "example": "zh-CN" + }, + "name": { + "type": "string", + "maxLength": 100, + "example": "番剧" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.json b/docs/swagger.json index 4573e69..c895276 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -367,7 +367,7 @@ "BearerAuth": [] } ], - "description": "Update the authenticated user's nickname, gender, and birthday. birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps the current value.", + "description": "Update the authenticated user's profile and preferences (nickname, gender, birthday, rating, locale, theme, theme_mode). Omitted fields keep their current value; an empty string clears a preference. Rating is 0-5 (G/R21), locale is zh-CN/en-US/ja-JP, theme is emerald/indigo/orange/blue, theme_mode is light/dark/system.", "consumes": [ "application/json" ], @@ -2090,6 +2090,297 @@ } } } + }, + "/video-categories": { + "get": { + "description": "Public video category tree (max 3 levels, enabled categories whose ancestors are all enabled), ordered by sort ASC then id ASC, with translations.", + "produces": [ + "application/json" + ], + "tags": [ + "public" + ], + "summary": "List video categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.VideoCategory" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin only. Create a video category. parent_id is optional (null or 0 for root); level is limited to 3; slug must be unique and match ^[a-z0-9]+(-[a-z0-9]+)*$; icon and cover accept uploaded file URLs.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Create a video category", + "parameters": [ + { + "description": "Video category payload", + "name": "category", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videocategory.Request" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/model.VideoCategory" + } + }, + "400": { + "description": "invalid request, parent not found, or level exceeds limit", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "admin permission required or account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "409": { + "description": "slug already exists", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, + "/video-categories/list": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin only. Video category tree including disabled categories, with translations.", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "List all video categories", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/model.VideoCategory" + } + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "admin permission required or account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } + }, + "/video-categories/{id}": { + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin only. Update a video category (full update); translations are replaced by the provided list. parent_id omitted or null moves the category to root. Moving checks for cycles and keeps the whole subtree within 3 levels.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Update a video category", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Video category payload", + "name": "category", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/videocategory.Request" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/model.VideoCategory" + } + }, + "400": { + "description": "invalid request, parent not found, cycle, or level exceeds limit", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "admin permission required or account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "404": { + "description": "record not found", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "409": { + "description": "slug already exists", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Admin only. Delete a leaf video category; categories with children return 409. Icon and cover file references are released.", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "Delete a video category", + "parameters": [ + { + "type": "integer", + "example": 1, + "description": "Category ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Deleted" + }, + "400": { + "description": "invalid id", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "401": { + "description": "unauthorized or session expired", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "403": { + "description": "admin permission required or account disabled", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "404": { + "description": "record not found", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "409": { + "description": "category has children", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/httpx.ErrorResponse" + } + } + } + } } }, "definitions": { @@ -2177,10 +2468,34 @@ "type": "string", "example": "male" }, + "locale": { + "type": "string", + "example": "zh-CN" + }, "nickname": { "type": "string", "maxLength": 50, "example": "Alice" + }, + "rating": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "example": 1 + }, + "theme": { + "type": "string", + "example": "emerald" + }, + "theme_mode": { + "type": "string", + "example": "system" } } }, @@ -2365,12 +2680,28 @@ "id": { "type": "integer" }, + "locale": { + "type": "string", + "example": "zh-CN" + }, "nickname": { "type": "string" }, + "rating": { + "type": "integer", + "example": 0 + }, "status": { "type": "integer" }, + "theme": { + "type": "string", + "example": "emerald" + }, + "theme_mode": { + "type": "string", + "example": "system" + }, "updated_at": { "type": "string" }, @@ -2402,6 +2733,76 @@ } } }, + "model.VideoCategory": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/model.VideoCategory" + } + }, + "cover": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "effective_rating": { + "type": "integer" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "level": { + "type": "integer" + }, + "parent_id": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "rating": { + "type": "integer" + }, + "slug": { + "type": "string" + }, + "sort": { + "type": "integer" + }, + "status": { + "type": "integer" + }, + "translations": { + "type": "array", + "items": { + "$ref": "#/definitions/model.VideoCategoryTranslation" + } + }, + "updated_at": { + "type": "string" + } + } + }, + "model.VideoCategoryTranslation": { + "type": "object", + "properties": { + "locale": { + "type": "string" + }, + "name": { + "type": "string" + }, + "video_category_id": { + "type": "integer" + } + } + }, "nav.Request": { "type": "object", "required": [ @@ -2714,6 +3115,85 @@ "example": "Operations" } } + }, + "videocategory.Request": { + "type": "object", + "required": [ + "rating", + "slug", + "translations" + ], + "properties": { + "cover": { + "type": "string", + "maxLength": 500, + "example": "https://example.com/cover.png" + }, + "icon": { + "type": "string", + "maxLength": 500, + "example": "https://example.com/icon.png" + }, + "parent_id": { + "type": "integer", + "example": 0 + }, + "rating": { + "type": "integer", + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "example": 1 + }, + "slug": { + "type": "string", + "maxLength": 100, + "example": "anime" + }, + "sort": { + "type": "integer", + "example": 0 + }, + "status": { + "type": "integer", + "enum": [ + 0, + 1 + ], + "example": 1 + }, + "translations": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/videocategory.TranslationRequest" + } + } + } + }, + "videocategory.TranslationRequest": { + "type": "object", + "required": [ + "locale", + "name" + ], + "properties": { + "locale": { + "type": "string", + "maxLength": 10, + "example": "zh-CN" + }, + "name": { + "type": "string", + "maxLength": 100, + "example": "番剧" + } + } } }, "securityDefinitions": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 1cdd60f..52ff010 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -61,10 +61,29 @@ definitions: gender: example: male type: string + locale: + example: zh-CN + type: string nickname: example: Alice maxLength: 50 type: string + rating: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + example: 1 + type: integer + theme: + example: emerald + type: string + theme_mode: + example: system + type: string type: object httpx.ErrorResponse: properties: @@ -187,10 +206,22 @@ definitions: type: array id: type: integer + locale: + example: zh-CN + type: string nickname: type: string + rating: + example: 0 + type: integer status: type: integer + theme: + example: emerald + type: string + theme_mode: + example: system + type: string updated_at: type: string username: @@ -211,6 +242,52 @@ definitions: updated_at: type: string type: object + model.VideoCategory: + properties: + children: + items: + $ref: '#/definitions/model.VideoCategory' + type: array + cover: + type: string + created_at: + type: string + effective_rating: + type: integer + icon: + type: string + id: + type: integer + level: + type: integer + parent_id: + type: integer + path: + type: string + rating: + type: integer + slug: + type: string + sort: + type: integer + status: + type: integer + translations: + items: + $ref: '#/definitions/model.VideoCategoryTranslation' + type: array + updated_at: + type: string + type: object + model.VideoCategoryTranslation: + properties: + locale: + type: string + name: + type: string + video_category_id: + type: integer + type: object nav.Request: properties: open_in_new_window: @@ -440,6 +517,66 @@ definitions: required: - name type: object + videocategory.Request: + properties: + cover: + example: https://example.com/cover.png + maxLength: 500 + type: string + icon: + example: https://example.com/icon.png + maxLength: 500 + type: string + parent_id: + example: 0 + type: integer + rating: + enum: + - 0 + - 1 + - 2 + - 3 + - 4 + - 5 + example: 1 + type: integer + slug: + example: anime + maxLength: 100 + type: string + sort: + example: 0 + type: integer + status: + enum: + - 0 + - 1 + example: 1 + type: integer + translations: + items: + $ref: '#/definitions/videocategory.TranslationRequest' + minItems: 1 + type: array + required: + - rating + - slug + - translations + type: object + videocategory.TranslationRequest: + properties: + locale: + example: zh-CN + maxLength: 10 + type: string + name: + example: 番剧 + maxLength: 100 + type: string + required: + - locale + - name + type: object info: contact: {} description: |- @@ -691,9 +828,11 @@ paths: put: consumes: - application/json - description: Update the authenticated user's nickname, gender, and birthday. - birthday accepts YYYY-MM-DD, an empty string clears it, and omitting it keeps - the current value. + description: Update the authenticated user's profile and preferences (nickname, + gender, birthday, rating, locale, theme, theme_mode). Omitted fields keep + their current value; an empty string clears a preference. Rating is 0-5 (G/R21), + locale is zh-CN/en-US/ja-JP, theme is emerald/indigo/orange/blue, theme_mode + is light/dark/system. parameters: - description: Fields to update in: body @@ -1833,6 +1972,202 @@ paths: summary: Update a user tags: - admin + /video-categories: + get: + description: Public video category tree (max 3 levels, enabled categories whose + ancestors are all enabled), ordered by sort ASC then id ASC, with translations. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.VideoCategory' + type: array + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + summary: List video categories + tags: + - public + post: + consumes: + - application/json + description: Admin only. Create a video category. parent_id is optional (null + or 0 for root); level is limited to 3; slug must be unique and match ^[a-z0-9]+(-[a-z0-9]+)*$; + icon and cover accept uploaded file URLs. + parameters: + - description: Video category payload + in: body + name: category + required: true + schema: + $ref: '#/definitions/videocategory.Request' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/model.VideoCategory' + "400": + description: invalid request, parent not found, or level exceeds limit + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: unauthorized or session expired + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: admin permission required or account disabled + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "409": + description: slug already exists + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] + summary: Create a video category + tags: + - admin + /video-categories/{id}: + delete: + description: Admin only. Delete a leaf video category; categories with children + return 409. Icon and cover file references are released. + parameters: + - description: Category ID + example: 1 + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "204": + description: Deleted + "400": + description: invalid id + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: unauthorized or session expired + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: admin permission required or account disabled + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "404": + description: record not found + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "409": + description: category has children + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] + summary: Delete a video category + tags: + - admin + put: + consumes: + - application/json + description: Admin only. Update a video category (full update); translations + are replaced by the provided list. parent_id omitted or null moves the category + to root. Moving checks for cycles and keeps the whole subtree within 3 levels. + parameters: + - description: Category ID + example: 1 + in: path + name: id + required: true + type: integer + - description: Video category payload + in: body + name: category + required: true + schema: + $ref: '#/definitions/videocategory.Request' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/model.VideoCategory' + "400": + description: invalid request, parent not found, cycle, or level exceeds + limit + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "401": + description: unauthorized or session expired + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: admin permission required or account disabled + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "404": + description: record not found + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "409": + description: slug already exists + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] + summary: Update a video category + tags: + - admin + /video-categories/list: + get: + description: Admin only. Video category tree including disabled categories, + with translations. + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/model.VideoCategory' + type: array + "401": + description: unauthorized or session expired + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "403": + description: admin permission required or account disabled + schema: + $ref: '#/definitions/httpx.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/httpx.ErrorResponse' + security: + - BearerAuth: [] + summary: List all video categories + tags: + - admin securityDefinitions: BearerAuth: description: 'Bearer JWT, format: Bearer {token}, obtained from /auth/login' diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 003598a..3c0e9ff 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -15,6 +15,10 @@ export interface AuthUser { avatar: string gender: string birthday: string | null + rating: number + locale: string + theme: string + theme_mode: string status: number groups: AuthUserGroup[] } diff --git a/frontend/src/api/file.ts b/frontend/src/api/file.ts new file mode 100644 index 0000000..8e30c5d --- /dev/null +++ b/frontend/src/api/file.ts @@ -0,0 +1,16 @@ +import { request } from './http' + +export interface UploadedFile { + id: number + name: string + url?: string +} + +export function uploadFile(file: File): Promise { + const form = new FormData() + form.append('file', file, file.name) + return request('/files', { + method: 'POST', + body: form, + }) +} diff --git a/frontend/src/api/profile.ts b/frontend/src/api/profile.ts index da03966..7d24d5d 100644 --- a/frontend/src/api/profile.ts +++ b/frontend/src/api/profile.ts @@ -2,9 +2,13 @@ import { request } from './http' import type { AuthUser } from './auth' export interface UpdateProfilePayload { - nickname: string - gender: string - birthday: string + nickname?: string + gender?: string + birthday?: string + rating?: number + locale?: string + theme?: string + theme_mode?: string } export function getProfile(): Promise { diff --git a/frontend/src/api/videocategory.ts b/frontend/src/api/videocategory.ts new file mode 100644 index 0000000..63a329f --- /dev/null +++ b/frontend/src/api/videocategory.ts @@ -0,0 +1,62 @@ +import { request } from './http' + +export interface VideoCategoryTranslation { + locale: string + name: string +} + +export interface VideoCategory { + id: number + parent_id: number | null + level: number + path: string + slug: string + rating: number + effective_rating: number + icon: string + cover: string + sort: number + status: number + translations: VideoCategoryTranslation[] + children: VideoCategory[] +} + +export interface VideoCategoryPayload { + parent_id: number | null + slug: string + rating: number + icon: string + cover: string + sort: number + status: number + translations: VideoCategoryTranslation[] +} + +export function getVideoCategories(): Promise { + return request('/video-categories') +} + +export function getAllVideoCategories(): Promise { + return request('/video-categories/list') +} + +export function createVideoCategory(payload: VideoCategoryPayload): Promise { + return request('/video-categories', { + method: 'POST', + body: JSON.stringify(payload), + }) +} + +export function updateVideoCategory( + id: number, + payload: VideoCategoryPayload, +): Promise { + return request(`/video-categories/${id}`, { + method: 'PUT', + body: JSON.stringify(payload), + }) +} + +export function deleteVideoCategory(id: number): Promise { + return request(`/video-categories/${id}`, { method: 'DELETE' }) +} diff --git a/frontend/src/components/layout/AdminLayout.vue b/frontend/src/components/layout/AdminLayout.vue index 51537f1..039e620 100644 --- a/frontend/src/components/layout/AdminLayout.vue +++ b/frontend/src/components/layout/AdminLayout.vue @@ -8,6 +8,7 @@ const route = useRoute() const menuItems = [ { name: 'admin-site', labelKey: 'admin.siteInfo' }, { name: 'admin-site-nav', labelKey: 'admin.nav.title' }, + { name: 'admin-video-categories', labelKey: 'admin.videoCategory.title' }, ] as const diff --git a/frontend/src/components/layout/LanguageSwitcher.vue b/frontend/src/components/layout/LanguageSwitcher.vue index 2a8f335..5eec68b 100644 --- a/frontend/src/components/layout/LanguageSwitcher.vue +++ b/frontend/src/components/layout/LanguageSwitcher.vue @@ -1,11 +1,19 @@ diff --git a/frontend/src/components/layout/ThemeSwitcher.vue b/frontend/src/components/layout/ThemeSwitcher.vue index 934cec1..cbf82c6 100644 --- a/frontend/src/components/layout/ThemeSwitcher.vue +++ b/frontend/src/components/layout/ThemeSwitcher.vue @@ -1,9 +1,11 @@