新增多级视频分类、内容分级与用户偏好

- 迁移 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 文档
This commit is contained in:
2026-09-22 11:52:32 +08:00
parent 588b63a15b
commit 55d024ae6c
33 files changed
+3659 -40

No files matched your search

+30 -1
View File
@@ -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`(临时库、鉴权路由、请求辅助),业务/工具测试放在对应包
+481 -1
View File
@@ -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": {
+481 -1
View File
@@ -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": {
+338 -3
View File
@@ -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'
+4
View File
@@ -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[]
}
+16
View File
@@ -0,0 +1,16 @@
import { request } from './http'
export interface UploadedFile {
id: number
name: string
url?: string
}
export function uploadFile(file: File): Promise<UploadedFile> {
const form = new FormData()
form.append('file', file, file.name)
return request<UploadedFile>('/files', {
method: 'POST',
body: form,
})
}
+7 -3
View File
@@ -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<AuthUser> {
+62
View File
@@ -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<VideoCategory[]> {
return request<VideoCategory[]>('/video-categories')
}
export function getAllVideoCategories(): Promise<VideoCategory[]> {
return request<VideoCategory[]>('/video-categories/list')
}
export function createVideoCategory(payload: VideoCategoryPayload): Promise<VideoCategory> {
return request<VideoCategory>('/video-categories', {
method: 'POST',
body: JSON.stringify(payload),
})
}
export function updateVideoCategory(
id: number,
payload: VideoCategoryPayload,
): Promise<VideoCategory> {
return request<VideoCategory>(`/video-categories/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
}
export function deleteVideoCategory(id: number): Promise<void> {
return request<void>(`/video-categories/${id}`, { method: 'DELETE' })
}
@@ -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
</script>
@@ -1,11 +1,19 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { SUPPORTED_LOCALES, setLocale, type Locale } from '@/i18n'
import { useAuthStore } from '@/stores/auth'
const { t, locale } = useI18n()
const auth = useAuthStore()
function onChange(event: Event) {
setLocale((event.target as HTMLSelectElement).value as Locale)
const value = (event.target as HTMLSelectElement).value as Locale
setLocale(value)
if (auth.isAuthenticated) {
void auth.updatePreferences({ locale: value }).catch(() => {
// 偏好同步失败不打断本地切换
})
}
}
</script>
@@ -1,9 +1,11 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { themeOptions, useThemeStore, type ColorMode, type ThemeId } from '@/stores/theme'
const { t } = useI18n()
const themeStore = useThemeStore()
const auth = useAuthStore()
const presetLabelKeys = {
emerald: 'theme.presets.emerald',
@@ -17,6 +19,24 @@ const modeOptions = [
{ value: 'dark', labelKey: 'theme.dark' },
{ value: 'system', labelKey: 'theme.system' },
] as const satisfies { value: ColorMode; labelKey: string }[]
function syncPreference(payload: { theme?: ThemeId; theme_mode?: ColorMode }) {
if (auth.isAuthenticated) {
void auth.updatePreferences(payload).catch(() => {
// 偏好同步失败不打断本地切换
})
}
}
function selectTheme(id: ThemeId) {
themeStore.setTheme(id)
syncPreference({ theme: id })
}
function selectMode(mode: ColorMode) {
themeStore.setMode(mode)
syncPreference({ theme_mode: mode })
}
</script>
<template>
@@ -32,7 +52,7 @@ const modeOptions = [
:aria-pressed="themeStore.theme === item.id"
class="grid h-6 w-6 place-items-center rounded-full border-2 transition-colors"
:class="themeStore.theme === item.id ? 'border-primary' : 'border-transparent'"
@click="themeStore.setTheme(item.id)"
@click="selectTheme(item.id)"
>
<span class="h-4 w-4 rounded-full" :style="{ backgroundColor: item.color }" />
</button>
@@ -49,7 +69,7 @@ const modeOptions = [
? 'bg-primary font-medium text-on-primary'
: 'text-content-2 hover:text-primary'
"
@click="themeStore.setMode(item.value)"
@click="selectMode(item.value)"
>
{{ t(item.labelKey) }}
</button>
+18
View File
@@ -0,0 +1,18 @@
export interface RatingLevel {
value: number
key: string
}
// RATING_LEVELS 视频分级:数值越大限制越高,高等级覆盖其下所有分类。
export const RATING_LEVELS: RatingLevel[] = [
{ value: 0, key: 'G' },
{ value: 1, key: 'PG' },
{ value: 2, key: 'PG13' },
{ value: 3, key: 'NC16' },
{ value: 4, key: 'M18' },
{ value: 5, key: 'R21' },
]
export function ratingKey(value: number): string {
return RATING_LEVELS.find((item) => item.value === value)?.key ?? 'G'
}
+2
View File
@@ -17,6 +17,8 @@ function isLocale(value: string | null): value is Locale {
return SUPPORTED_LOCALES.some((item) => item.code === value)
}
export { isLocale }
function detectLocale(): Locale {
const saved = localStorage.getItem(LOCALE_KEY)
if (isLocale(saved)) {
+58 -1
View File
@@ -38,6 +38,14 @@ const enUS: MessageSchema = {
movie: 'Movies',
video: 'Videos',
},
rating: {
G: 'General audiences, suitable for all viewers',
PG: 'Parental guidance suggested',
PG13: 'Suitable for 13+ with parental guidance',
NC16: 'Suitable for viewers aged 16 and above',
M18: 'Suitable for adults',
R21: 'Restricted to viewers aged 21 and above',
},
footer: {
copyright: 'Rill streaming platform · Page frame placeholder · All images are blank placeholders',
},
@@ -98,6 +106,7 @@ const enUS: MessageSchema = {
other: 'Other',
},
birthday: 'Birthday',
rating: 'Age rating',
save: 'Save changes',
saveSuccess: 'Your profile has been updated',
avatar: {
@@ -130,7 +139,7 @@ const enUS: MessageSchema = {
},
admin: {
title: 'Admin',
subtitle: 'Manage site information and navigation links',
subtitle: 'Manage site information, navigation links, and video categories',
siteInfo: 'Site information',
siteName: 'Site name',
siteNamePlaceholder: 'Shown in the browser title and page header',
@@ -207,6 +216,54 @@ const enUS: MessageSchema = {
invalid: 'The submitted information is invalid, please check and retry',
network: 'Network error, please try again later',
},
videoCategory: {
title: 'Video categories',
subtitle: 'Manage multi-level video categories, localized names, icons, and covers',
add: 'Add category',
addChild: 'Add child',
edit: 'Edit category',
delete: 'Delete',
empty: 'No categories yet. Click “Add category” to get started',
parent: 'Parent category',
parentRoot: 'Top level',
slug: 'Slug',
slugPlaceholder: 'anime',
slugHint: 'Lowercase letters, numbers, and hyphens; must be globally unique',
names: 'Category name',
rating: 'Rating',
ratingHint: 'A higher rating overrides all categories beneath it',
ratingOverridden: 'Lower than the parent effective rating; will take effect as {level}',
icon: 'Icon',
cover: 'Cover',
imagePlaceholder: 'https://example.com/image.png',
upload: 'Upload',
clear: 'Clear',
sort: 'Order',
sortHint: 'Smaller numbers appear first among siblings',
status: 'Status',
enabled: 'Enabled',
disabled: 'Disabled',
save: 'Save',
cancel: 'Cancel',
createSuccess: 'Category created',
updateSuccess: 'Category updated',
deleteSuccess: 'Category deleted',
confirmDelete: 'Delete “{name}”?',
levelBadge: 'Level {level}',
errors: {
slugRequired: 'Slug is required',
slugInvalid: 'Slug may only contain lowercase letters, numbers, and hyphens',
slugDuplicate: 'This slug already exists',
nameRequired: 'Provide a name for at least one language',
nameTooLong: 'Each name must be at most 100 characters',
hasChildren: 'This category has children; remove them first',
imageType: 'Please choose an image file',
imageSize: 'Image size must not exceed {size} MB',
imageUpload: 'Failed to upload the image, please try again later',
invalid: 'The submitted information is invalid, please check and retry',
network: 'Network error, please try again later',
},
},
},
}
+58 -1
View File
@@ -38,6 +38,14 @@ const jaJP: MessageSchema = {
movie: '映画',
video: '動画',
},
rating: {
G: '全年齢向け、すべての方が視聴できます',
PG: '保護者の指導が推奨されます',
PG13: '13歳未満は保護者の同伴が推奨されます',
NC16: '16歳以上の方が視聴できます',
M18: '成人向けです',
R21: '21歳以上の方のみ視聴できます',
},
footer: {
copyright: 'Rill 動画配信サービス · ページフレームのプレースホルダー · 画像はすべて空のプレースホルダーです',
},
@@ -98,6 +106,7 @@ const jaJP: MessageSchema = {
other: 'その他',
},
birthday: '誕生日',
rating: '年齢レーティング',
save: '変更を保存',
saveSuccess: 'プロフィールを更新しました',
avatar: {
@@ -130,7 +139,7 @@ const jaJP: MessageSchema = {
},
admin: {
title: '管理画面',
subtitle: 'サイト情報ナビゲーションリンクを管理します',
subtitle: 'サイト情報ナビゲーション・動画カテゴリを管理します',
siteInfo: 'サイト情報',
siteName: 'サイト名',
siteNamePlaceholder: 'ブラウザのタイトルとページヘッダーに表示されます',
@@ -207,6 +216,54 @@ const jaJP: MessageSchema = {
invalid: '入力内容が無効です。確認してもう一度お試しください',
network: 'ネットワークエラーが発生しました。後でもう一度お試しください',
},
videoCategory: {
title: '動画カテゴリ',
subtitle: '多階層の動画カテゴリ、多言語名、アイコンとカバーを管理します',
add: 'カテゴリを追加',
addChild: '子カテゴリを追加',
edit: 'カテゴリを編集',
delete: '削除',
empty: 'カテゴリがありません。「カテゴリを追加」から設定してください',
parent: '親カテゴリ',
parentRoot: 'トップレベル',
slug: '識別子(slug',
slugPlaceholder: 'anime',
slugHint: '小文字英数字とハイフンのみ、全体で一意',
names: 'カテゴリ名',
rating: 'レーティング',
ratingHint: '高いレーティングは配下のすべてのカテゴリを上書きします',
ratingOverridden: '親の有効レーティングより低いため、実際は {level} として扱われます',
icon: 'アイコン',
cover: 'カバー',
imagePlaceholder: 'https://example.com/image.png',
upload: 'アップロード',
clear: 'クリア',
sort: '並び順',
sortHint: '同じ階層では数字が小さいほど前に表示されます',
status: '状態',
enabled: '有効',
disabled: '無効',
save: '保存',
cancel: 'キャンセル',
createSuccess: 'カテゴリを作成しました',
updateSuccess: 'カテゴリを更新しました',
deleteSuccess: 'カテゴリを削除しました',
confirmDelete: '「{name}」を削除しますか?',
levelBadge: 'レベル {level}',
errors: {
slugRequired: '識別子(slug)を入力してください',
slugInvalid: '識別子は小文字英数字とハイフンのみ利用できます',
slugDuplicate: 'この識別子は既に存在します',
nameRequired: '少なくとも 1 つの言語の名前を入力してください',
nameTooLong: '名前は 1 つあたり 100 文字以内で入力してください',
hasChildren: '子カテゴリが存在します。先に子カテゴリを削除してください',
imageType: '画像ファイルを選択してください',
imageSize: '画像サイズは {size} MB 以内にしてください',
imageUpload: '画像のアップロードに失敗しました。後でもう一度お試しください',
invalid: '入力内容が無効です。確認してもう一度お試しください',
network: 'ネットワークエラーが発生しました。後でもう一度お試しください',
},
},
},
}
+58 -1
View File
@@ -36,6 +36,14 @@ const zhCN = {
movie: '电影',
video: '视频',
},
rating: {
G: '普通观众级,适合所有人观赏',
PG: '家长指导级,建议在家长指导下观赏',
PG13: '特别辅导级,13 岁以下儿童建议在家长陪伴下观赏',
NC16: '适合十六岁以上人士观赏',
M18: '适合成年人观赏',
R21: '适合二十一岁以上成年人观赏',
},
footer: {
copyright: 'Rill 流媒体服务平台 · 页面框架占位 · 所有图片均为空白占位框',
},
@@ -96,6 +104,7 @@ const zhCN = {
other: '其他',
},
birthday: '生日',
rating: '年龄分级',
save: '保存修改',
saveSuccess: '个人资料已更新',
avatar: {
@@ -128,7 +137,7 @@ const zhCN = {
},
admin: {
title: '后台管理',
subtitle: '维护站点信息导航链接',
subtitle: '维护站点信息导航链接与视频分类',
siteInfo: '站点信息',
siteName: '网站名称',
siteNamePlaceholder: '显示在浏览器标题与页面头部',
@@ -205,6 +214,54 @@ const zhCN = {
invalid: '提交的信息无效,请检查后重试',
network: '网络异常,请稍后重试',
},
videoCategory: {
title: '视频分类',
subtitle: '维护多级视频分类、多语言名称与图标封面',
add: '新增分类',
addChild: '新增子级',
edit: '编辑分类',
delete: '删除',
empty: '暂无分类,点击“新增分类”开始配置',
parent: '上级分类',
parentRoot: '顶级分类',
slug: '标识(slug',
slugPlaceholder: 'anime',
slugHint: '小写字母、数字与中划线,全局唯一',
names: '分类名称',
rating: '分级',
ratingHint: '高等级会覆盖其下所有分类',
ratingOverridden: '低于上级生效等级,实际将按 {level} 生效',
icon: '图标',
cover: '封面',
imagePlaceholder: 'https://example.com/image.png',
upload: '上传',
clear: '清空',
sort: '排序',
sortHint: '同级数字越小越靠前',
status: '状态',
enabled: '启用',
disabled: '禁用',
save: '保存',
cancel: '取消',
createSuccess: '分类已创建',
updateSuccess: '分类已更新',
deleteSuccess: '分类已删除',
confirmDelete: '确定删除“{name}”吗?',
levelBadge: '{level} 级',
errors: {
slugRequired: '请填写标识(slug',
slugInvalid: '标识仅支持小写字母、数字与中划线',
slugDuplicate: '标识已存在,请更换',
nameRequired: '至少填写一种语言的名称',
nameTooLong: '名称最多 100 个字符',
hasChildren: '存在子分类,请先删除子分类',
imageType: '请选择图片文件',
imageSize: '图片大小不能超过 {size} MB',
imageUpload: '图片上传失败,请稍后重试',
invalid: '提交的信息无效,请检查后重试',
network: '网络异常,请稍后重试',
},
},
},
}
+5
View File
@@ -60,6 +60,11 @@ const router = createRouter({
name: 'admin-site-nav',
component: () => import('../views/AdminNavLinksView.vue'),
},
{
path: 'video-categories',
name: 'admin-video-categories',
component: () => import('../views/AdminVideoCategoriesView.vue'),
},
],
},
],
+25
View File
@@ -14,6 +14,8 @@ import {
} from '@/api/profile'
import { deleteAvatar as deleteAvatarRequest, uploadAvatar as uploadAvatarRequest } from '@/api/avatar'
import { setAuthToken } from '@/api/http'
import { isLocale, setLocale } from '@/i18n'
import { isColorMode, isThemeId, useThemeStore } from '@/stores/theme'
const TOKEN_KEY = 'rill-token'
const USER_KEY = 'rill-user'
@@ -60,9 +62,23 @@ export const useAuthStore = defineStore('auth', () => {
setAuthToken(null)
}
function applyPreferences(data: AuthUser) {
const themeStore = useThemeStore()
if (isLocale(data.locale)) {
setLocale(data.locale)
}
if (isThemeId(data.theme)) {
themeStore.setTheme(data.theme)
}
if (isColorMode(data.theme_mode)) {
themeStore.setMode(data.theme_mode)
}
}
function applyUser(data: AuthUser) {
user.value = data
localStorage.setItem(USER_KEY, JSON.stringify(data))
applyPreferences(data)
}
function applySession(data: LoginResponse) {
@@ -73,6 +89,7 @@ export const useAuthStore = defineStore('auth', () => {
localStorage.setItem(USER_KEY, JSON.stringify(data.user))
localStorage.setItem(EXPIRES_KEY, data.expires_at)
setAuthToken(data.token)
applyPreferences(data.user)
}
async function login(account: string, password: string): Promise<AuthUser> {
@@ -91,6 +108,12 @@ export const useAuthStore = defineStore('auth', () => {
return data
}
async function updatePreferences(payload: UpdateProfilePayload): Promise<AuthUser> {
const data = await updateProfileRequest(payload)
applyUser(data)
return data
}
async function refreshProfile(): Promise<AuthUser> {
const data = await getProfileRequest()
applyUser(data)
@@ -116,6 +139,7 @@ export const useAuthStore = defineStore('auth', () => {
function restore(): boolean {
if (token.value && user.value && !isExpired.value) {
setAuthToken(token.value)
applyPreferences(user.value)
return true
}
clear()
@@ -132,6 +156,7 @@ export const useAuthStore = defineStore('auth', () => {
login,
register,
updateProfile,
updatePreferences,
refreshProfile,
updateAvatar,
deleteAvatar,
+2
View File
@@ -28,6 +28,8 @@ function isColorMode(value: string | null): value is ColorMode {
return value === 'light' || value === 'dark' || value === 'system'
}
export { isColorMode, isThemeId }
const storedTheme = localStorage.getItem(THEME_KEY)
const storedMode = localStorage.getItem(MODE_KEY)
@@ -0,0 +1,676 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { uploadFile } from '@/api/file'
import { ApiError } from '@/api/http'
import {
createVideoCategory,
deleteVideoCategory,
getAllVideoCategories,
updateVideoCategory,
type VideoCategory,
type VideoCategoryPayload,
} from '@/api/videocategory'
import { SUPPORTED_LOCALES } from '@/i18n'
import { RATING_LEVELS, ratingKey } from '@/constants/rating'
const MAX_LEVEL = 3
const MAX_IMAGE_MB = 10
const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/
const { t, locale } = useI18n()
const categories = ref<VideoCategory[]>([])
const busy = ref(false)
const error = ref('')
const notice = ref('')
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const imageBusy = ref<'icon' | 'cover' | null>(null)
const iconInput = ref<HTMLInputElement | null>(null)
const coverInput = ref<HTMLInputElement | null>(null)
interface Row {
category: VideoCategory
depth: number
}
const rows = computed<Row[]>(() => {
const result: Row[] = []
const walk = (items: VideoCategory[], depth: number) => {
for (const item of items) {
result.push({ category: item, depth })
walk(item.children, depth + 1)
}
}
walk(categories.value, 0)
return result
})
function findCategory(items: VideoCategory[], id: number): VideoCategory | null {
for (const item of items) {
if (item.id === id) {
return item
}
const found = findCategory(item.children, id)
if (found) {
return found
}
}
return null
}
function categoryName(category: VideoCategory): string {
const current = category.translations.find((item) => item.locale === locale.value)?.name
if (current) {
return current
}
const fallback = category.translations.find((item) => item.locale === 'zh-CN')?.name
return fallback ?? category.translations[0]?.name ?? category.slug
}
const editingCategory = computed(() =>
editingId.value === null ? null : findCategory(categories.value, editingId.value),
)
const parentOptions = computed(() =>
rows.value
.filter(({ category }) => {
const editing = editingCategory.value
if (!editing) {
return true
}
return (
category.id !== editing.id &&
!(category.path === editing.path || category.path.startsWith(editing.path + '/'))
)
})
.map(({ category, depth }) => ({
id: category.id,
label: `${'\u3000'.repeat(depth)}${categoryName(category)}`,
disabled: category.level >= MAX_LEVEL,
})),
)
interface FormState {
parentId: number | null
slug: string
names: Record<string, string>
rating: number
icon: string
cover: string
sort: number
status: number
}
function emptyNames(): Record<string, string> {
const names: Record<string, string> = {}
for (const item of SUPPORTED_LOCALES) {
names[item.code] = ''
}
return names
}
const form = reactive<FormState>({
parentId: null,
slug: '',
names: emptyNames(),
rating: 0,
icon: '',
cover: '',
sort: 0,
status: 1,
})
const selectedRatingDescription = computed(() => t(`rating.${ratingKey(form.rating)}`))
const parentEffectiveRating = computed(() => {
if (form.parentId === null) {
return null
}
const parent = findCategory(categories.value, form.parentId)
return parent ? parent.effective_rating : null
})
const ratingOverridden = computed(
() => parentEffectiveRating.value !== null && form.rating < parentEffectiveRating.value,
)
function ratingBadge(category: VideoCategory): string {
if (category.rating < category.effective_rating) {
return `${ratingKey(category.rating)}${ratingKey(category.effective_rating)}`
}
return ratingKey(category.rating)
}
async function loadCategories() {
try {
categories.value = await getAllVideoCategories()
} catch {
error.value = t('admin.videoCategory.errors.network')
}
}
onMounted(loadCategories)
function resetForm() {
form.parentId = null
form.slug = ''
form.names = emptyNames()
form.rating = 0
form.icon = ''
form.cover = ''
form.sort = 0
form.status = 1
editingId.value = null
error.value = ''
notice.value = ''
}
function startCreate(parent?: VideoCategory) {
resetForm()
form.parentId = parent ? parent.id : null
form.rating = parent ? parent.effective_rating : 0
editorOpen.value = true
}
function startEdit(category: VideoCategory) {
resetForm()
editingId.value = category.id
form.parentId = category.parent_id
form.slug = category.slug
for (const item of SUPPORTED_LOCALES) {
form.names[item.code] =
category.translations.find((tr) => tr.locale === item.code)?.name ?? ''
}
form.rating = category.rating
form.icon = category.icon
form.cover = category.cover
form.sort = category.sort
form.status = category.status
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
editingId.value = null
}
function validate(): string | null {
const slug = form.slug.trim().toLowerCase()
if (!slug) {
return t('admin.videoCategory.errors.slugRequired')
}
if (!SLUG_PATTERN.test(slug)) {
return t('admin.videoCategory.errors.slugInvalid')
}
const names = SUPPORTED_LOCALES.map((item) => form.names[item.code]?.trim() ?? '').filter(
(name) => name !== '',
)
if (names.length === 0) {
return t('admin.videoCategory.errors.nameRequired')
}
if (names.some((name) => [...name].length > 100)) {
return t('admin.videoCategory.errors.nameTooLong')
}
return null
}
async function submit() {
error.value = ''
notice.value = ''
const message = validate()
if (message) {
error.value = message
return
}
const payload: VideoCategoryPayload = {
parent_id: form.parentId,
slug: form.slug.trim().toLowerCase(),
rating: form.rating,
icon: form.icon.trim(),
cover: form.cover.trim(),
sort: Number.isFinite(form.sort) ? form.sort : 0,
status: form.status,
translations: SUPPORTED_LOCALES.map((item) => ({
locale: item.code,
name: form.names[item.code]?.trim() ?? '',
})).filter((item) => item.name !== ''),
}
busy.value = true
try {
if (editingId.value !== null) {
await updateVideoCategory(editingId.value, payload)
notice.value = t('admin.videoCategory.updateSuccess')
} else {
await createVideoCategory(payload)
notice.value = t('admin.videoCategory.createSuccess')
}
closeEditor()
await loadCategories()
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
error.value = t('admin.videoCategory.errors.slugDuplicate')
} else if (err instanceof ApiError && err.status === 400) {
error.value = t('admin.videoCategory.errors.invalid')
} else {
error.value = t('admin.videoCategory.errors.network')
}
} finally {
busy.value = false
}
}
async function remove(category: VideoCategory) {
if (!window.confirm(t('admin.videoCategory.confirmDelete', { name: categoryName(category) }))) {
return
}
busy.value = true
error.value = ''
notice.value = ''
try {
await deleteVideoCategory(category.id)
notice.value = t('admin.videoCategory.deleteSuccess')
await loadCategories()
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
error.value = t('admin.videoCategory.errors.hasChildren')
} else {
error.value = t('admin.videoCategory.errors.network')
}
} finally {
busy.value = false
}
}
function pickImage(kind: 'icon' | 'cover') {
if (kind === 'icon') {
iconInput.value?.click()
} else {
coverInput.value?.click()
}
}
async function onImageSelected(kind: 'icon' | 'cover', event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) {
return
}
error.value = ''
if (!file.type.startsWith('image/')) {
error.value = t('admin.videoCategory.errors.imageType')
return
}
if (file.size > MAX_IMAGE_MB * 1024 * 1024) {
error.value = t('admin.videoCategory.errors.imageSize', { size: MAX_IMAGE_MB })
return
}
imageBusy.value = kind
try {
const uploaded = await uploadFile(file)
if (kind === 'icon') {
form.icon = `/api/files/${uploaded.id}`
} else {
form.cover = `/api/files/${uploaded.id}`
}
} catch {
error.value = t('admin.videoCategory.errors.imageUpload')
} finally {
imageBusy.value = null
}
}
</script>
<template>
<section class="rounded-2xl bg-surface p-6 shadow-sm ring-1 ring-line sm:p-8">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="text-base font-semibold">{{ t('admin.videoCategory.title') }}</h2>
<p class="mt-1 text-sm text-content-3">{{ t('admin.videoCategory.subtitle') }}</p>
</div>
<button
type="button"
:disabled="busy"
class="h-9 rounded-full border border-primary px-4 text-sm text-primary transition-colors hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-60"
@click="startCreate()"
>
{{ t('admin.videoCategory.add') }}
</button>
</div>
<p
v-if="error"
class="mt-4 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-500 dark:text-red-400"
>
{{ error }}
</p>
<p v-else-if="notice" class="mt-4 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary">
{{ notice }}
</p>
<ul v-if="rows.length" class="mt-4 divide-y divide-line rounded-xl border border-line">
<li
v-for="{ category, depth } in rows"
:key="category.id"
class="flex flex-wrap items-center gap-x-3 gap-y-2 py-3 pr-4"
:style="{ paddingLeft: `${depth * 20 + 16}px` }"
>
<img
v-if="category.icon"
:src="category.icon"
alt=""
class="h-8 w-8 shrink-0 rounded-lg object-cover ring-1 ring-line"
/>
<span
v-else
class="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-page text-xs text-content-3"
>
{{ category.level }}
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-content-1">{{ categoryName(category) }}</p>
<p class="mt-0.5 truncate text-xs text-content-3">
{{ category.slug }} ·
{{ t('admin.videoCategory.levelBadge', { level: category.level }) }}
</p>
</div>
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="
category.rating < category.effective_rating
? 'bg-amber-500/10 text-amber-600 dark:text-amber-400'
: 'bg-page text-content-3'
"
>
{{ ratingBadge(category) }}
</span>
<span
class="rounded-full px-2 py-0.5 text-xs"
:class="
category.status === 1 ? 'bg-primary/10 text-primary' : 'bg-page text-content-3'
"
>
{{ category.status === 1 ? t('admin.videoCategory.enabled') : t('admin.videoCategory.disabled') }}
</span>
<span class="text-xs text-content-3">#{{ category.sort }}</span>
<div class="flex gap-2">
<button
v-if="category.level < MAX_LEVEL"
type="button"
:disabled="busy"
class="h-8 rounded-full border border-line px-3 text-xs text-content-2 transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
@click="startCreate(category)"
>
{{ t('admin.videoCategory.addChild') }}
</button>
<button
type="button"
:disabled="busy"
class="h-8 rounded-full border border-line px-3 text-xs text-content-2 transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
@click="startEdit(category)"
>
{{ t('admin.videoCategory.edit') }}
</button>
<button
type="button"
:disabled="busy"
class="h-8 rounded-full border border-line px-3 text-xs text-content-2 transition-colors hover:border-red-500 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-60"
@click="remove(category)"
>
{{ t('admin.videoCategory.delete') }}
</button>
</div>
</li>
</ul>
<p
v-else
class="mt-4 rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-content-3"
>
{{ t('admin.videoCategory.empty') }}
</p>
<div v-if="editorOpen" class="mt-4 rounded-xl border border-line bg-page/40 p-4">
<h3 class="text-sm font-semibold">
{{ editingId !== null ? t('admin.videoCategory.edit') : t('admin.videoCategory.add') }}
</h3>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="vc-parent" class="mb-1.5 block text-sm text-content-2">
{{ t('admin.videoCategory.parent') }}
</label>
<select
id="vc-parent"
v-model="form.parentId"
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm outline-none transition-colors focus:border-primary"
>
<option :value="null">{{ t('admin.videoCategory.parentRoot') }}</option>
<option
v-for="option in parentOptions"
:key="option.id"
:value="option.id"
:disabled="option.disabled"
>
{{ option.label }}
</option>
</select>
</div>
<div>
<label for="vc-slug" class="mb-1.5 block text-sm text-content-2">
{{ t('admin.videoCategory.slug') }}
</label>
<input
id="vc-slug"
v-model="form.slug"
type="text"
maxlength="100"
:placeholder="t('admin.videoCategory.slugPlaceholder')"
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm outline-none transition-colors placeholder:text-content-3 focus:border-primary"
/>
<p class="mt-1 text-xs text-content-3">{{ t('admin.videoCategory.slugHint') }}</p>
</div>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-3">
<div v-for="item in SUPPORTED_LOCALES" :key="item.code">
<label :for="`vc-name-${item.code}`" class="mb-1.5 block text-sm text-content-2">
{{ t('admin.videoCategory.names') }} · {{ item.label }}
</label>
<input
:id="`vc-name-${item.code}`"
v-model="form.names[item.code]"
type="text"
maxlength="100"
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm outline-none transition-colors placeholder:text-content-3 focus:border-primary"
/>
</div>
</div>
<div class="mt-4">
<span class="mb-1.5 block text-sm text-content-2">
{{ t('admin.videoCategory.rating') }}
</span>
<div class="flex flex-wrap gap-2">
<label v-for="level in RATING_LEVELS" :key="level.value" class="cursor-pointer">
<input
v-model.number="form.rating"
type="radio"
:value="level.value"
class="peer sr-only"
/>
<span
class="inline-flex h-9 items-center rounded-full border border-line px-4 text-sm text-content-2 transition-colors peer-checked:border-primary peer-checked:bg-primary/10 peer-checked:text-primary"
>
{{ level.key }}
</span>
</label>
</div>
<p class="mt-2 text-xs text-content-3">{{ selectedRatingDescription }}</p>
<p v-if="ratingOverridden" class="mt-1 text-xs text-amber-600 dark:text-amber-400">
{{
t('admin.videoCategory.ratingOverridden', {
level: ratingKey(parentEffectiveRating ?? 0),
})
}}
</p>
<p v-else class="mt-1 text-xs text-content-3">
{{ t('admin.videoCategory.ratingHint') }}
</p>
</div>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label for="vc-icon" class="mb-1.5 block text-sm text-content-2">
{{ t('admin.videoCategory.icon') }}
</label>
<input
id="vc-icon"
v-model="form.icon"
type="text"
maxlength="500"
:placeholder="t('admin.videoCategory.imagePlaceholder')"
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm outline-none transition-colors placeholder:text-content-3 focus:border-primary"
/>
<div class="mt-2 flex flex-wrap items-center gap-2">
<img
v-if="form.icon"
:src="form.icon"
alt=""
class="h-10 w-10 rounded-lg object-cover ring-1 ring-line"
/>
<button
type="button"
:disabled="imageBusy === 'icon'"
class="h-8 rounded-full border border-primary px-3 text-xs text-primary transition-colors hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-60"
@click="pickImage('icon')"
>
{{ imageBusy === 'icon' ? t('common.submitting') : t('admin.videoCategory.upload') }}
</button>
<button
v-if="form.icon"
type="button"
:disabled="imageBusy === 'icon'"
class="h-8 rounded-full border border-line px-3 text-xs text-content-2 transition-colors hover:border-red-500 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-60"
@click="form.icon = ''"
>
{{ t('admin.videoCategory.clear') }}
</button>
<input
ref="iconInput"
type="file"
accept="image/*"
class="hidden"
@change="onImageSelected('icon', $event)"
/>
</div>
</div>
<div>
<label for="vc-cover" class="mb-1.5 block text-sm text-content-2">
{{ t('admin.videoCategory.cover') }}
</label>
<input
id="vc-cover"
v-model="form.cover"
type="text"
maxlength="500"
:placeholder="t('admin.videoCategory.imagePlaceholder')"
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm outline-none transition-colors placeholder:text-content-3 focus:border-primary"
/>
<div class="mt-2 flex flex-wrap items-center gap-2">
<img
v-if="form.cover"
:src="form.cover"
alt=""
class="h-10 w-16 rounded-lg object-cover ring-1 ring-line"
/>
<button
type="button"
:disabled="imageBusy === 'cover'"
class="h-8 rounded-full border border-primary px-3 text-xs text-primary transition-colors hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-60"
@click="pickImage('cover')"
>
{{ imageBusy === 'cover' ? t('common.submitting') : t('admin.videoCategory.upload') }}
</button>
<button
v-if="form.cover"
type="button"
:disabled="imageBusy === 'cover'"
class="h-8 rounded-full border border-line px-3 text-xs text-content-2 transition-colors hover:border-red-500 hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-60"
@click="form.cover = ''"
>
{{ t('admin.videoCategory.clear') }}
</button>
<input
ref="coverInput"
type="file"
accept="image/*"
class="hidden"
@change="onImageSelected('cover', $event)"
/>
</div>
</div>
</div>
<div class="mt-4 flex flex-wrap items-start gap-x-8 gap-y-4">
<div>
<label for="vc-sort" class="mb-1.5 block text-sm text-content-2">
{{ t('admin.videoCategory.sort') }}
</label>
<input
id="vc-sort"
v-model.number="form.sort"
type="number"
class="h-10 w-full rounded-lg border border-line bg-page px-3 text-sm outline-none transition-colors focus:border-primary sm:max-w-32"
/>
<p class="mt-1 text-xs text-content-3">{{ t('admin.videoCategory.sortHint') }}</p>
</div>
<div>
<span class="mb-1.5 block text-sm text-content-2">
{{ t('admin.videoCategory.status') }}
</span>
<div class="flex gap-2">
<label class="cursor-pointer">
<input v-model.number="form.status" type="radio" :value="1" class="peer sr-only" />
<span
class="inline-flex h-9 items-center rounded-full border border-line px-4 text-sm text-content-2 transition-colors peer-checked:border-primary peer-checked:bg-primary/10 peer-checked:text-primary"
>
{{ t('admin.videoCategory.enabled') }}
</span>
</label>
<label class="cursor-pointer">
<input v-model.number="form.status" type="radio" :value="0" class="peer sr-only" />
<span
class="inline-flex h-9 items-center rounded-full border border-line px-4 text-sm text-content-2 transition-colors peer-checked:border-primary peer-checked:bg-primary/10 peer-checked:text-primary"
>
{{ t('admin.videoCategory.disabled') }}
</span>
</label>
</div>
</div>
</div>
<div class="mt-6 flex justify-end gap-2">
<button
type="button"
:disabled="busy"
class="h-9 rounded-full border border-line px-4 text-sm text-content-2 transition-colors hover:border-primary hover:text-primary disabled:cursor-not-allowed disabled:opacity-60"
@click="closeEditor"
>
{{ t('admin.videoCategory.cancel') }}
</button>
<button
type="button"
:disabled="busy"
class="h-9 rounded-full bg-primary px-5 text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-60"
@click="submit"
>
{{ busy ? t('common.submitting') : t('admin.videoCategory.save') }}
</button>
</div>
</div>
</section>
</template>
+33
View File
@@ -6,12 +6,14 @@ import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'
import { ApiError } from '@/api/http'
import AvatarCropDialog from '@/components/profile/AvatarCropDialog.vue'
import { RATING_LEVELS, ratingKey } from '@/constants/rating'
import { useAuthStore } from '@/stores/auth'
interface ProfileForm {
nickname: string
gender: string
birthday: string
rating: number
}
const { t } = useI18n()
@@ -38,6 +40,7 @@ const validationSchema = computed(() =>
t('profile.errors.birthdayInvalid'),
)
.refine((value) => value === '' || value <= today, t('profile.errors.birthdayFuture')),
rating: z.number().int().min(0).max(5),
}),
),
)
@@ -48,12 +51,16 @@ const { defineField, handleSubmit, errors, isSubmitting, setValues } = useForm<P
nickname: auth.user?.nickname ?? '',
gender: auth.user?.gender ?? '',
birthday: auth.user?.birthday ?? '',
rating: auth.user?.rating ?? 0,
},
})
const [nickname, nicknameProps] = defineField('nickname')
const [gender, genderProps] = defineField('gender')
const [birthday, birthdayProps] = defineField('birthday')
const [rating, ratingProps] = defineField('rating')
const ratingDescription = computed(() => t(`rating.${ratingKey(rating.value ?? 0)}`))
const submitError = ref('')
const saved = ref(false)
@@ -152,6 +159,7 @@ onMounted(async () => {
nickname: profile.nickname ?? '',
gender: profile.gender ?? '',
birthday: profile.birthday ?? '',
rating: profile.rating ?? 0,
})
} catch {
// 401 由全局未授权处理,其余错误保留本地缓存的用户信息
@@ -166,6 +174,7 @@ const onSubmit = handleSubmit(async (values) => {
nickname: values.nickname,
gender: values.gender,
birthday: values.birthday,
rating: values.rating,
})
saved.value = true
} catch (error) {
@@ -341,6 +350,30 @@ const onSubmit = handleSubmit(async (values) => {
</p>
</div>
<div class="mt-4">
<span class="mb-1.5 block text-sm text-content-2">{{ t('profile.rating') }}</span>
<div class="flex flex-wrap gap-2">
<label v-for="level in RATING_LEVELS" :key="level.value" class="cursor-pointer">
<input
v-model.number="rating"
v-bind="ratingProps"
type="radio"
:value="level.value"
class="peer sr-only"
/>
<span
class="inline-flex h-9 items-center rounded-full border border-line px-4 text-sm text-content-2 transition-colors peer-checked:border-primary peer-checked:bg-primary/10 peer-checked:text-primary"
>
{{ level.key }}
</span>
</label>
</div>
<p class="mt-2 text-xs text-content-3">{{ ratingDescription }}</p>
<p v-if="errors.rating" class="mt-1 text-xs text-red-500 dark:text-red-400">
{{ errors.rating }}
</p>
</div>
<p
v-if="submitError"
class="mt-4 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-500 dark:text-red-400"
+8 -1
View File
@@ -20,9 +20,10 @@ import (
"rill/internal/site"
"rill/internal/user"
"rill/internal/usergroup"
"rill/internal/videocategory"
)
// RegisterRoutes 注册 API 路由。health、swagger、auth、站点信息、文件查看头部导航读取公开;notes、个人资料与文件上传删除需登录;站点信息更新、导航维护、用户与用户组管理仅限管理员。
// RegisterRoutes 注册 API 路由。health、swagger、auth、站点信息、文件查看头部导航与视频分类读取公开;notes、个人资料与文件上传删除需登录;站点信息更新、导航维护、视频分类维护、用户与用户组管理仅限管理员。
func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
authn := auth.NewAuthenticator(cfg)
@@ -45,6 +46,7 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
rg.GET("/site", site.Get(db))
rg.GET("/files/:id", file.View(db, cfg))
rg.GET("/nav-links", nav.List(db))
rg.GET("/video-categories", videocategory.List(db))
authed := rg.Group("", authn.RequireAuth(db))
{
@@ -78,6 +80,11 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
admin.PUT("/nav-links/:id", nav.Update(db))
admin.DELETE("/nav-links/:id", nav.Delete(db))
admin.GET("/video-categories/list", videocategory.ListAll(db))
admin.POST("/video-categories", videocategory.Create(db, cfg))
admin.PUT("/video-categories/:id", videocategory.Update(db, cfg))
admin.DELETE("/video-categories/:id", videocategory.Delete(db, cfg))
users := admin.Group("/users")
{
users.GET("", user.List(db))
+77
View File
@@ -350,3 +350,80 @@ func TestProfile(t *testing.T) {
t.Errorf("数据库未清空: %+v", clearedStored)
}
}
func TestProfilePreferences(t *testing.T) {
env := testutil.Setup(t)
r := env.Router("")
registered := registerViaAPI(t, r, "alice", "alice@example.com", "secret123", nil)
authed := env.Router(env.Sign(registered.ID))
// 注册默认:年龄分级 G,语言/主题为空(跟随本地或浏览器)。
if registered.Rating != model.RatingG || registered.Locale != "" || registered.Theme != "" || registered.ThemeMode != "" {
t.Fatalf("注册默认偏好异常: rating=%d locale=%q theme=%q mode=%q",
registered.Rating, registered.Locale, registered.Theme, registered.ThemeMode)
}
w := testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{
"rating": 4,
"locale": "ja-JP",
"theme": "blue",
"theme_mode": "dark",
})
if w.Code != http.StatusOK {
t.Fatalf("更新偏好状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
updated := testutil.DecodeUser(t, w)
if updated.Rating != model.RatingM18 || updated.Locale != "ja-JP" ||
updated.Theme != "blue" || updated.ThemeMode != "dark" {
t.Fatalf("更新偏好结果异常: %+v", updated)
}
var stored model.User
if err := env.DB.First(&stored, registered.ID).Error; err != nil {
t.Fatalf("查询数据库失败: %v", err)
}
if stored.Rating != model.RatingM18 || stored.Locale != "ja-JP" ||
stored.Theme != "blue" || stored.ThemeMode != "dark" {
t.Errorf("数据库偏好未更新: %+v", stored)
}
// 部分更新:仅改语言,其余保持不变。
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{"locale": "en-US"})
if w.Code != http.StatusOK {
t.Fatalf("部分更新状态码 = %d, body=%s", w.Code, w.Body.String())
}
partial := testutil.DecodeUser(t, w)
if partial.Locale != "en-US" || partial.Rating != model.RatingM18 ||
partial.Theme != "blue" || partial.ThemeMode != "dark" {
t.Errorf("部分更新异常: %+v", partial)
}
// 空串清空偏好,分级保持。
w = testutil.Call(t, authed, http.MethodPut, "/api/me", map[string]any{
"locale": "", "theme": "", "theme_mode": "",
})
if w.Code != http.StatusOK {
t.Fatalf("清空偏好状态码 = %d, body=%s", w.Code, w.Body.String())
}
cleared := testutil.DecodeUser(t, w)
if cleared.Locale != "" || cleared.Theme != "" || cleared.ThemeMode != "" || cleared.Rating != model.RatingM18 {
t.Errorf("清空偏好异常: %+v", cleared)
}
cases := []struct {
name string
body map[string]any
}{
{"分级越界", map[string]any{"rating": 6}},
{"分级为负", map[string]any{"rating": -1}},
{"语言不支持", map[string]any{"locale": "fr-FR"}},
{"主题不支持", map[string]any{"theme": "red"}},
{"明暗模式不支持", map[string]any{"theme_mode": "auto"}},
}
for _, tc := range cases {
w := testutil.Call(t, authed, http.MethodPut, "/api/me", tc.body)
if w.Code != http.StatusBadRequest {
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
}
}
}
+26 -6
View File
@@ -10,11 +10,15 @@ import (
"rill/internal/user"
)
// UpdateProfileRequest 更新个人资料请求,仅更新请求中提供的字段;gender 空串表示清空。
// UpdateProfileRequest 更新个人资料与偏好请求,仅更新请求中提供的字段;空串表示清空偏好
type UpdateProfileRequest struct {
Nickname *string `json:"nickname" binding:"omitempty,max=50" example:"Alice"`
Gender *string `json:"gender" binding:"omitempty,len=0|oneof=male female other" example:"male"`
Birthday *string `json:"birthday" binding:"omitempty" example:"1995-06-15"`
Nickname *string `json:"nickname" binding:"omitempty,max=50" example:"Alice"`
Gender *string `json:"gender" binding:"omitempty,len=0|oneof=male female other" example:"male"`
Birthday *string `json:"birthday" binding:"omitempty" example:"1995-06-15"`
Rating *int8 `json:"rating" binding:"omitempty,oneof=0 1 2 3 4 5" example:"1"`
Locale *string `json:"locale" binding:"omitempty,len=0|oneof=zh-CN en-US ja-JP" example:"zh-CN"`
Theme *string `json:"theme" binding:"omitempty,len=0|oneof=emerald indigo orange blue" example:"emerald"`
ThemeMode *string `json:"theme_mode" binding:"omitempty,len=0|oneof=light dark system" example:"system"`
}
// @Summary Get current user profile
@@ -38,7 +42,7 @@ func Me() gin.HandlerFunc {
}
// @Summary Update current user profile
// @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.
// @Tags user
// @Accept json
// @Produce json
@@ -64,7 +68,7 @@ func UpdateMe(db *gorm.DB) gin.HandlerFunc {
return
}
updates := make(map[string]any, 3)
updates := make(map[string]any, 7)
if req.Nickname != nil {
updates["nickname"] = *req.Nickname
current.Nickname = *req.Nickname
@@ -86,6 +90,22 @@ func UpdateMe(db *gorm.DB) gin.HandlerFunc {
}
current.Birthday = birthday
}
if req.Rating != nil {
updates["rating"] = *req.Rating
current.Rating = *req.Rating
}
if req.Locale != nil {
updates["locale"] = *req.Locale
current.Locale = *req.Locale
}
if req.Theme != nil {
updates["theme"] = *req.Theme
current.Theme = *req.Theme
}
if req.ThemeMode != nil {
updates["theme_mode"] = *req.ThemeMode
current.ThemeMode = *req.ThemeMode
}
if len(updates) == 0 {
c.JSON(http.StatusOK, current)
return
+10 -1
View File
@@ -86,6 +86,15 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) {
if !db.Migrator().HasTable(&model.NavLinkTranslation{}) {
t.Error("nav_link_translations 表未创建")
}
if !db.Migrator().HasTable(&model.VideoCategory{}) {
t.Error("video_categories 表未创建")
}
if !db.Migrator().HasTable(&model.VideoCategoryTranslation{}) {
t.Error("video_category_translations 表未创建")
}
if !db.Migrator().HasColumn(&model.VideoCategory{}, "rating") {
t.Error("video_categories 表缺少 rating 列")
}
if !db.Migrator().HasColumn(&model.NavLink{}, "position") {
t.Error("nav_links 表缺少 position 列")
}
@@ -114,7 +123,7 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) {
if admin.Nickname != "Administrator" {
t.Errorf("初始管理员昵称应为英文: %q", admin.Nickname)
}
for _, column := range []string{"gender", "birthday"} {
for _, column := range []string{"gender", "birthday", "rating", "locale", "theme", "theme_mode"} {
if !db.Migrator().HasColumn(&model.User{}, column) {
t.Errorf("users 表缺少列 %s", column)
}
+21
View File
@@ -139,6 +139,27 @@ var migrations = []Migration{
return tx.AutoMigrate(&model.SiteSetting{})
},
},
{
Version: 13,
Name: "create_video_categories",
Up: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.VideoCategory{}, &model.VideoCategoryTranslation{})
},
},
{
Version: 14,
Name: "add_video_category_rating",
Up: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.VideoCategory{})
},
},
{
Version: 15,
Name: "add_user_preferences",
Up: func(tx *gorm.DB) error {
return tx.AutoMigrate(&model.User{})
},
},
}
// schemaMigration 记录已应用的迁移版本。
+16
View File
@@ -176,6 +176,22 @@ func Release(ctx context.Context, tx *gorm.DB, id uint) error {
Updates(map[string]any{"ref_count": gorm.Expr("ref_count - 1"), "last_referenced_at": now}).Error
}
// SyncRef 在事务内维护本站文件引用:新值占用、旧值释放,相同地址不重复计数。
// 外部地址与空值不参与计数。
func SyncRef(ctx context.Context, tx *gorm.DB, prefix, oldValue, newValue string) error {
oldID, hasOld := ParseLocalURL(prefix, oldValue)
newID, hasNew := ParseLocalURL(prefix, newValue)
if hasNew && (!hasOld || newID != oldID) {
if err := Acquire(ctx, tx, newID); err != nil {
return err
}
}
if hasOld && (!hasNew || oldID != newID) {
return Release(ctx, tx, oldID)
}
return nil
}
// Open 打开文件记录对应的本地文件。
func Open(root string, f model.File) (*os.File, error) {
full, err := localPath(root, f.Path)
+11
View File
@@ -0,0 +1,11 @@
package model
// 内容分级(用户年龄分级与视频分类共用),数值越大限制越高;高等级覆盖其下所有分类。
const (
RatingG int8 = 0
RatingPG int8 = 1
RatingPG13 int8 = 2
RatingNC16 int8 = 3
RatingM18 int8 = 4
RatingR21 int8 = 5
)
+4
View File
@@ -23,6 +23,10 @@ type User struct {
Avatar string `gorm:"size:255" json:"avatar"`
Gender string `gorm:"size:10;not null;default:''" json:"gender" example:"male"`
Birthday Date `gorm:"type:date" json:"birthday" swaggertype:"string" example:"1995-06-15"`
Rating int8 `gorm:"not null;default:0" json:"rating" example:"0"`
Locale string `gorm:"size:10;not null;default:''" json:"locale" example:"zh-CN"`
Theme string `gorm:"size:20;not null;default:''" json:"theme" example:"emerald"`
ThemeMode string `gorm:"size:10;not null;default:''" json:"theme_mode" example:"system"`
Status int8 `gorm:"not null;default:1" json:"status"`
Groups []UserGroup `gorm:"-" json:"groups"`
CreatedAt time.Time `json:"created_at"`
+41
View File
@@ -0,0 +1,41 @@
package model
import "time"
// 视频分类状态。
const (
VideoCategoryStatusDisabled int8 = 0
VideoCategoryStatusEnabled int8 = 1
)
// VideoCategoryMaxLevel 视频分类最大层级。
const VideoCategoryMaxLevel = 3
// VideoCategory 视频分类,parent_id 邻接表实现多级(最多 3 级),Path 为物化路径(如 1/3/7)。
type VideoCategory struct {
ID uint `gorm:"primaryKey" json:"id"`
ParentID *uint `gorm:"index" json:"parent_id"`
Level int `gorm:"not null;default:1" json:"level"`
Path string `gorm:"size:255;not null;default:''" json:"path"`
Slug string `gorm:"size:100;uniqueIndex;not null" json:"slug"`
Rating int8 `gorm:"not null;default:0" json:"rating"`
EffectiveRating int8 `gorm:"-" json:"effective_rating"`
Icon string `gorm:"size:500" json:"icon"`
Cover string `gorm:"size:500" json:"cover"`
Sort int `gorm:"not null;default:0" json:"sort"`
Status int8 `gorm:"not null" json:"status"`
Translations []VideoCategoryTranslation `gorm:"-" json:"translations"`
Children []VideoCategory `gorm:"-" json:"children"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// VideoCategoryTranslation 视频分类的多语言名称,同一分类同一语言唯一。
type VideoCategoryTranslation struct {
ID uint `gorm:"primaryKey" json:"-"`
VideoCategoryID uint `gorm:"uniqueIndex:idx_video_category_locale;not null" json:"video_category_id"`
Locale string `gorm:"size:10;uniqueIndex:idx_video_category_locale;not null" json:"locale"`
Name string `gorm:"size:100;not null" json:"name"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
}
+2 -17
View File
@@ -270,21 +270,6 @@ func uploadAsset(c *gin.Context, db *gorm.DB, cfg *config.Config, allowSVG bool,
return file.URL(cfg.API.Prefix, saved.ID), true
}
// syncFileRef 在事务内维护本站文件引用:新值占用、旧值释放,相同地址不重复计数。
func syncFileRef(ctx context.Context, tx *gorm.DB, prefix, oldValue, newValue string) error {
oldID, hasOld := file.ParseLocalURL(prefix, oldValue)
newID, hasNew := file.ParseLocalURL(prefix, newValue)
if hasNew && (!hasOld || newID != oldID) {
if err := file.Acquire(ctx, tx, newID); err != nil {
return err
}
}
if hasOld && (!hasNew || oldID != newID) {
return file.Release(ctx, tx, oldID)
}
return nil
}
// loadSetting 读取站点设置,行缺失时返回默认值。
func loadSetting(ctx context.Context, db *gorm.DB) (model.SiteSetting, error) {
var setting model.SiteSetting
@@ -300,10 +285,10 @@ func loadSetting(ctx context.Context, db *gorm.DB) (model.SiteSetting, error) {
// saveSetting 在事务中保存设置并同步 logo/favicon 的文件引用。
func saveSetting(ctx context.Context, db *gorm.DB, cfg *config.Config, setting *model.SiteSetting, oldLogo, oldFavicon string) error {
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := syncFileRef(ctx, tx, cfg.API.Prefix, oldLogo, setting.Logo); err != nil {
if err := file.SyncRef(ctx, tx, cfg.API.Prefix, oldLogo, setting.Logo); err != nil {
return err
}
if err := syncFileRef(ctx, tx, cfg.API.Prefix, oldFavicon, setting.Favicon); err != nil {
if err := file.SyncRef(ctx, tx, cfg.API.Prefix, oldFavicon, setting.Favicon); err != nil {
return err
}
return tx.Save(setting).Error
+593
View File
@@ -0,0 +1,593 @@
// Package videocategory 提供视频分类的公开树读取与管理员维护接口。
package videocategory
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"rill/internal/config"
"rill/internal/file"
"rill/internal/httpx"
"rill/internal/model"
)
// slugPattern 约束 slug 为小写字母数字与中划线。
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
var (
errParentNotFound = errors.New("parent category not found")
errTooDeep = errors.New("category level exceeds limit")
errCycle = errors.New("category cannot be moved under itself or its descendants")
errHasChildren = errors.New("category has children")
errInvalidSlug = errors.New("invalid request: slug must match ^[a-z0-9]+(-[a-z0-9]+)*$")
)
// TranslationRequest 分类的多语言名称。
type TranslationRequest struct {
Locale string `json:"locale" binding:"required,max=10" example:"zh-CN"`
Name string `json:"name" binding:"required,max=100" example:"番剧"`
}
// Request 创建/更新视频分类请求;rating 为必填分级(0-5,高等级覆盖子分类)。
type Request struct {
ParentID *uint `json:"parent_id" example:"0"`
Slug string `json:"slug" binding:"required,max=100" example:"anime"`
Rating *int8 `json:"rating" binding:"required,oneof=0 1 2 3 4 5" example:"1"`
Icon string `json:"icon" binding:"omitempty,max=500" example:"https://example.com/icon.png"`
Cover string `json:"cover" binding:"omitempty,max=500" example:"https://example.com/cover.png"`
Sort int `json:"sort" example:"0"`
Status *int8 `json:"status" binding:"omitempty,oneof=0 1" example:"1"`
Translations []TranslationRequest `json:"translations" binding:"required,min=1,dive"`
}
// @Summary List video categories
// @Description Public video category tree (max 3 levels, enabled categories whose ancestors are all enabled), ordered by sort ASC then id ASC, with translations.
// @Tags public
// @Produce json
// @Success 200 {array} model.VideoCategory
// @Failure 500 {object} httpx.ErrorResponse
// @Router /video-categories [get]
func List(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tree, err := queryTree(c.Request.Context(), db, false)
if err != nil {
httpx.RespondDBError(c, err)
return
}
c.JSON(http.StatusOK, tree)
}
}
// @Summary List all video categories
// @Description Admin only. Video category tree including disabled categories, with translations.
// @Tags admin
// @Produce json
// @Success 200 {array} model.VideoCategory
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
// @Failure 500 {object} httpx.ErrorResponse
// @Router /video-categories/list [get]
func ListAll(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tree, err := queryTree(c.Request.Context(), db, true)
if err != nil {
httpx.RespondDBError(c, err)
return
}
c.JSON(http.StatusOK, tree)
}
}
// @Summary Create a video category
// @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.
// @Tags admin
// @Accept json
// @Produce json
// @Param category body videocategory.Request true "Video category payload"
// @Success 201 {object} model.VideoCategory
// @Failure 400 {object} httpx.ErrorResponse "invalid request, parent not found, or level exceeds limit"
// @Failure 409 {object} httpx.ErrorResponse "slug already exists"
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
// @Failure 500 {object} httpx.ErrorResponse
// @Router /video-categories [post]
func Create(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
req, ok := bindRequest(c)
if !ok {
return
}
status := model.VideoCategoryStatusEnabled
if req.Status != nil {
status = *req.Status
}
ctx := c.Request.Context()
category := model.VideoCategory{
Slug: req.Slug,
Rating: *req.Rating,
Icon: strings.TrimSpace(req.Icon),
Cover: strings.TrimSpace(req.Cover),
Sort: req.Sort,
Status: status,
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
level := 1
parentPath := ""
if req.ParentID != nil {
var parent model.VideoCategory
if err := tx.First(&parent, *req.ParentID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errParentNotFound
}
return err
}
level = parent.Level + 1
if level > model.VideoCategoryMaxLevel {
return errTooDeep
}
parentPath = parent.Path
category.ParentID = &parent.ID
}
category.Level = level
if err := tx.Create(&category).Error; err != nil {
return err
}
category.Path = joinPath(parentPath, category.ID)
if err := tx.Model(&model.VideoCategory{}).Where("id = ?", category.ID).
Update("path", category.Path).Error; err != nil {
return err
}
if err := replaceTranslations(tx, category.ID, req.Translations); err != nil {
return err
}
if err := file.SyncRef(ctx, tx, cfg.API.Prefix, "", category.Icon); err != nil {
return err
}
return file.SyncRef(ctx, tx, cfg.API.Prefix, "", category.Cover)
})
if err != nil {
respondWriteError(c, err, "slug already exists")
return
}
translations, err := translationsFor(ctx, db, category.ID)
if err != nil {
httpx.RespondDBError(c, err)
return
}
effective, err := effectiveRating(ctx, db, category)
if err != nil {
httpx.RespondDBError(c, err)
return
}
category.Translations = translations
category.EffectiveRating = effective
category.Children = make([]model.VideoCategory, 0)
c.JSON(http.StatusCreated, category)
}
}
// @Summary Update a video category
// @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.
// @Tags admin
// @Accept json
// @Produce json
// @Param id path int true "Category ID" example(1)
// @Param category body videocategory.Request true "Video category payload"
// @Success 200 {object} model.VideoCategory
// @Failure 400 {object} httpx.ErrorResponse "invalid request, parent not found, cycle, or level exceeds limit"
// @Failure 404 {object} httpx.ErrorResponse "record not found"
// @Failure 409 {object} httpx.ErrorResponse "slug already exists"
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
// @Failure 500 {object} httpx.ErrorResponse
// @Router /video-categories/{id} [put]
func Update(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
id, ok := httpx.ParseID(c)
if !ok {
return
}
req, ok := bindRequest(c)
if !ok {
return
}
ctx := c.Request.Context()
var category model.VideoCategory
if err := db.WithContext(ctx).First(&category, id).Error; err != nil {
httpx.RespondGetError(c, err)
return
}
if req.ParentID != nil && *req.ParentID == category.ID {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + errCycle.Error()})
return
}
oldLevel, oldPath := category.Level, category.Path
oldIcon, oldCover := category.Icon, category.Cover
newLevel := 1
parentPath := ""
var newParentID *uint
if req.ParentID != nil {
var parent model.VideoCategory
if err := db.WithContext(ctx).First(&parent, *req.ParentID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + errParentNotFound.Error()})
return
}
httpx.RespondDBError(c, err)
return
}
if parent.Path == oldPath || strings.HasPrefix(parent.Path, oldPath+"/") {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + errCycle.Error()})
return
}
newLevel = parent.Level + 1
parentPath = parent.Path
newParentID = &parent.ID
}
var descendants []model.VideoCategory
if err := db.WithContext(ctx).Where("path = ? OR path LIKE ?", oldPath, oldPath+"/%").
Find(&descendants).Error; err != nil {
httpx.RespondDBError(c, err)
return
}
maxLevel := oldLevel
for _, descendant := range descendants {
if descendant.Level > maxLevel {
maxLevel = descendant.Level
}
}
if newLevel+maxLevel-oldLevel > model.VideoCategoryMaxLevel {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + errTooDeep.Error()})
return
}
newPath := joinPath(parentPath, category.ID)
delta := newLevel - oldLevel
category.ParentID = newParentID
category.Level = newLevel
category.Path = newPath
category.Slug = req.Slug
category.Rating = *req.Rating
category.Icon = strings.TrimSpace(req.Icon)
category.Cover = strings.TrimSpace(req.Cover)
category.Sort = req.Sort
if req.Status != nil {
category.Status = *req.Status
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&category).Error; err != nil {
return err
}
for i := range descendants {
updates := map[string]any{
"level": descendants[i].Level + delta,
"path": newPath + strings.TrimPrefix(descendants[i].Path, oldPath),
}
if err := tx.Model(&model.VideoCategory{}).Where("id = ?", descendants[i].ID).
Updates(updates).Error; err != nil {
return err
}
}
if err := replaceTranslations(tx, category.ID, req.Translations); err != nil {
return err
}
if err := file.SyncRef(ctx, tx, cfg.API.Prefix, oldIcon, category.Icon); err != nil {
return err
}
return file.SyncRef(ctx, tx, cfg.API.Prefix, oldCover, category.Cover)
})
if err != nil {
respondWriteError(c, err, "slug already exists")
return
}
translations, err := translationsFor(ctx, db, category.ID)
if err != nil {
httpx.RespondDBError(c, err)
return
}
effective, err := effectiveRating(ctx, db, category)
if err != nil {
httpx.RespondDBError(c, err)
return
}
category.Translations = translations
category.EffectiveRating = effective
category.Children = make([]model.VideoCategory, 0)
c.JSON(http.StatusOK, category)
}
}
// @Summary Delete a video category
// @Description Admin only. Delete a leaf video category; categories with children return 409. Icon and cover file references are released.
// @Tags admin
// @Produce json
// @Param id path int true "Category ID" example(1)
// @Success 204 "Deleted"
// @Failure 400 {object} httpx.ErrorResponse "invalid id"
// @Failure 404 {object} httpx.ErrorResponse "record not found"
// @Failure 409 {object} httpx.ErrorResponse "category has children"
// @Security BearerAuth
// @Failure 401 {object} httpx.ErrorResponse "unauthorized or session expired"
// @Failure 403 {object} httpx.ErrorResponse "admin permission required or account disabled"
// @Failure 500 {object} httpx.ErrorResponse
// @Router /video-categories/{id} [delete]
func Delete(db *gorm.DB, cfg *config.Config) gin.HandlerFunc {
return func(c *gin.Context) {
id, ok := httpx.ParseID(c)
if !ok {
return
}
ctx := c.Request.Context()
var category model.VideoCategory
if err := db.WithContext(ctx).First(&category, id).Error; err != nil {
httpx.RespondGetError(c, err)
return
}
var children int64
if err := db.WithContext(ctx).Model(&model.VideoCategory{}).
Where("parent_id = ?", category.ID).Count(&children).Error; err != nil {
httpx.RespondDBError(c, err)
return
}
if children > 0 {
c.JSON(http.StatusConflict, httpx.ErrorResponse{Error: errHasChildren.Error()})
return
}
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := file.SyncRef(ctx, tx, cfg.API.Prefix, category.Icon, ""); err != nil {
return err
}
if err := file.SyncRef(ctx, tx, cfg.API.Prefix, category.Cover, ""); err != nil {
return err
}
if err := tx.Where("video_category_id = ?", category.ID).
Delete(&model.VideoCategoryTranslation{}).Error; err != nil {
return err
}
return tx.Delete(&category).Error
})
if err != nil {
httpx.RespondDBError(c, err)
return
}
c.Status(http.StatusNoContent)
}
}
// bindRequest 绑定并校验请求,失败时已写入响应。
func bindRequest(c *gin.Context) (Request, bool) {
var req Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()})
return Request{}, false
}
req.Slug = strings.ToLower(strings.TrimSpace(req.Slug))
if !slugPattern.MatchString(req.Slug) {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: errInvalidSlug.Error()})
return Request{}, false
}
if err := validateTranslations(req.Translations); err != nil {
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()})
return Request{}, false
}
if req.ParentID != nil && *req.ParentID == 0 {
req.ParentID = nil
}
return req, true
}
// respondWriteError 将写入错误映射为 HTTP 响应。
func respondWriteError(c *gin.Context, err error, duplicateMessage string) {
switch {
case errors.Is(err, gorm.ErrDuplicatedKey):
c.JSON(http.StatusConflict, httpx.ErrorResponse{Error: duplicateMessage})
case errors.Is(err, errParentNotFound), errors.Is(err, errTooDeep), errors.Is(err, file.ErrFileNotFound):
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()})
default:
httpx.RespondDBError(c, err)
}
}
// validateTranslations 校验翻译项数量与内容。
func validateTranslations(items []TranslationRequest) error {
if len(items) == 0 {
return errors.New("at least one translation is required")
}
seen := make(map[string]bool, len(items))
for _, item := range items {
locale := strings.TrimSpace(item.Locale)
name := strings.TrimSpace(item.Name)
if locale == "" || name == "" {
return errors.New("translation locale and name are required")
}
if seen[locale] {
return errors.New("duplicate translation locale: " + locale)
}
seen[locale] = true
if len([]rune(name)) > 100 {
return errors.New("translation name is too long")
}
}
return nil
}
// replaceTranslations 在事务中整体替换分类翻译。
func replaceTranslations(tx *gorm.DB, categoryID uint, items []TranslationRequest) error {
if err := tx.Where("video_category_id = ?", categoryID).
Delete(&model.VideoCategoryTranslation{}).Error; err != nil {
return err
}
for _, item := range items {
translation := model.VideoCategoryTranslation{
VideoCategoryID: categoryID,
Locale: strings.TrimSpace(item.Locale),
Name: strings.TrimSpace(item.Name),
}
if err := tx.Create(&translation).Error; err != nil {
return err
}
}
return nil
}
// translationsFor 查询单个分类的翻译。
func translationsFor(ctx context.Context, db *gorm.DB, categoryID uint) ([]model.VideoCategoryTranslation, error) {
translations := make([]model.VideoCategoryTranslation, 0)
if err := db.WithContext(ctx).Where("video_category_id = ?", categoryID).
Order("locale ASC").Find(&translations).Error; err != nil {
return nil, err
}
return translations, nil
}
// queryTree 查询分类并组装为树;includeDisabled 为 true 时包含禁用项。
func queryTree(ctx context.Context, db *gorm.DB, includeDisabled bool) ([]model.VideoCategory, error) {
query := db.WithContext(ctx).Order("sort ASC").Order("id ASC")
if !includeDisabled {
query = query.Where("status = ?", model.VideoCategoryStatusEnabled)
}
var items []model.VideoCategory
if err := query.Find(&items).Error; err != nil {
return nil, err
}
if err := attachTranslations(ctx, db, items); err != nil {
return nil, err
}
return buildTree(items, includeDisabled), nil
}
// buildTree 将扁平分类组装为嵌套树;公开模式下父级不可见的节点整棵丢弃。
func buildTree(items []model.VideoCategory, keepOrphans bool) []model.VideoCategory {
index := make(map[uint]int, len(items))
for i := range items {
index[items[i].ID] = i
}
children := make(map[int][]int, len(items))
roots := make([]int, 0, len(items))
for i := range items {
if items[i].ParentID == nil {
roots = append(roots, i)
continue
}
parentIndex, ok := index[*items[i].ParentID]
if !ok {
if keepOrphans {
roots = append(roots, i)
}
continue
}
children[parentIndex] = append(children[parentIndex], i)
}
var build func(i int, parentRating int8) model.VideoCategory
build = func(i int, parentRating int8) model.VideoCategory {
node := items[i]
node.EffectiveRating = node.Rating
if parentRating > node.EffectiveRating {
node.EffectiveRating = parentRating
}
node.Children = make([]model.VideoCategory, 0, len(children[i]))
for _, childIndex := range children[i] {
node.Children = append(node.Children, build(childIndex, node.EffectiveRating))
}
return node
}
tree := make([]model.VideoCategory, 0, len(roots))
for _, rootIndex := range roots {
tree = append(tree, build(rootIndex, model.RatingG))
}
return tree
}
// attachTranslations 批量填充分类翻译。
func attachTranslations(ctx context.Context, db *gorm.DB, categories []model.VideoCategory) error {
for i := range categories {
categories[i].Translations = make([]model.VideoCategoryTranslation, 0)
}
if len(categories) == 0 {
return nil
}
ids := make([]uint, 0, len(categories))
positions := make(map[uint][]int, len(categories))
for i := range categories {
ids = append(ids, categories[i].ID)
positions[categories[i].ID] = append(positions[categories[i].ID], i)
}
var translations []model.VideoCategoryTranslation
if err := db.WithContext(ctx).Where("video_category_id IN ?", ids).
Order("locale ASC").Find(&translations).Error; err != nil {
return err
}
for _, translation := range translations {
for _, i := range positions[translation.VideoCategoryID] {
categories[i].Translations = append(categories[i].Translations, translation)
}
}
return nil
}
// effectiveRating 计算分类的生效分级:自身与所有祖先分级取最大值。
func effectiveRating(ctx context.Context, db *gorm.DB, category model.VideoCategory) (int8, error) {
rating := category.Rating
segments := strings.Split(category.Path, "/")
if len(segments) <= 1 {
return rating, nil
}
ancestorIDs := make([]uint, 0, len(segments)-1)
for _, segment := range segments[:len(segments)-1] {
id, err := strconv.ParseUint(segment, 10, 64)
if err != nil || id == 0 {
continue
}
ancestorIDs = append(ancestorIDs, uint(id))
}
if len(ancestorIDs) == 0 {
return rating, nil
}
var maxRating sql.NullInt64
if err := db.WithContext(ctx).Model(&model.VideoCategory{}).
Where("id IN ?", ancestorIDs).Select("MAX(rating)").Scan(&maxRating).Error; err != nil {
return 0, err
}
if maxRating.Valid && int8(maxRating.Int64) > rating {
rating = int8(maxRating.Int64)
}
return rating, nil
}
// joinPath 拼接物化路径。
func joinPath(parentPath string, id uint) string {
if parentPath == "" {
return fmt.Sprint(id)
}
return parentPath + "/" + fmt.Sprint(id)
}
@@ -0,0 +1,464 @@
package videocategory_test
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"testing"
"rill/internal/model"
"rill/internal/testutil"
)
func registerUser(t *testing.T, env *testutil.Env) model.User {
t.Helper()
w := testutil.Call(t, env.Router(""), http.MethodPost, "/api/auth/register", map[string]string{
"username": "catuser", "email": "catuser@example.com", "password": "secret123",
})
if w.Code != http.StatusCreated {
t.Fatalf("注册普通用户失败: %d, body=%s", w.Code, w.Body.String())
}
return testutil.DecodeUser(t, w)
}
func decodeCategory(t *testing.T, body []byte) model.VideoCategory {
t.Helper()
var category model.VideoCategory
if err := json.Unmarshal(body, &category); err != nil {
t.Fatalf("解析分类响应失败: %v, body=%s", err, body)
}
return category
}
func decodeTree(t *testing.T, body []byte) []model.VideoCategory {
t.Helper()
var tree []model.VideoCategory
if err := json.Unmarshal(body, &tree); err != nil {
t.Fatalf("解析分类树失败: %v, body=%s", err, body)
}
return tree
}
func createCategory(t *testing.T, r http.Handler, payload map[string]any) model.VideoCategory {
t.Helper()
w := testutil.Call(t, r, http.MethodPost, "/api/video-categories", payload)
if w.Code != http.StatusCreated {
t.Fatalf("创建分类状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
}
return decodeCategory(t, w.Body.Bytes())
}
func categoryPayload(slug string, name string, parentID any, sort int) map[string]any {
payload := map[string]any{
"slug": slug,
"rating": 0,
"sort": sort,
"status": 1,
"translations": []map[string]string{{"locale": "zh-CN", "name": name}},
}
if parentID != nil {
payload["parent_id"] = parentID
}
return payload
}
func TestVideoCategoryCRUD(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
public := env.Router("")
if w := testutil.Call(t, public, http.MethodGet, "/api/video-categories", nil); w.Code != http.StatusOK {
t.Fatalf("公开分类树状态码 = %d, 期望 %d", w.Code, http.StatusOK)
} else if tree := decodeTree(t, w.Body.Bytes()); len(tree) != 0 {
t.Fatalf("初始分类应为空: %+v", tree)
}
if w := testutil.Call(t, public, http.MethodPost, "/api/video-categories", map[string]any{}); w.Code != http.StatusUnauthorized {
t.Errorf("匿名创建状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
}
normal := env.Router(env.Sign(registerUser(t, env).ID))
if w := testutil.Call(t, normal, http.MethodPost, "/api/video-categories", map[string]any{}); w.Code != http.StatusForbidden {
t.Errorf("普通用户创建状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
}
if w := testutil.Call(t, normal, http.MethodGet, "/api/video-categories/list", nil); w.Code != http.StatusForbidden {
t.Errorf("普通用户管理树状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
}
anime := createCategory(t, admin, map[string]any{
"slug": "anime", "rating": 4, "sort": 10, "status": 1,
"translations": []map[string]string{
{"locale": "zh-CN", "name": "番剧"},
{"locale": "en-US", "name": "Anime"},
},
})
if anime.Level != 1 || anime.Path != strconv.FormatUint(uint64(anime.ID), 10) || anime.ParentID != nil {
t.Fatalf("根分类字段异常: %+v", anime)
}
if len(anime.Translations) != 2 {
t.Errorf("根分类译文数 = %d, 期望 2", len(anime.Translations))
}
movie := createCategory(t, admin, categoryPayload("movie", "电影", nil, 20))
child := createCategory(t, admin, categoryPayload("anime-jp", "日番", anime.ID, 10))
if child.Level != 2 || child.Path != fmt.Sprintf("%d/%d", anime.ID, child.ID) {
t.Fatalf("二级分类字段异常: %+v", child)
}
grandchild := createCategory(t, admin, categoryPayload("anime-jp-2026", "2026 日番", child.ID, 5))
if grandchild.Level != 3 || grandchild.Path != fmt.Sprintf("%d/%d/%d", anime.ID, child.ID, grandchild.ID) {
t.Fatalf("三级分类字段异常: %+v", grandchild)
}
disabled := createCategory(t, admin, map[string]any{
"slug": "hidden", "rating": 0, "sort": 0, "status": 0,
"translations": []map[string]string{{"locale": "zh-CN", "name": "隐藏"}},
})
// 公开树:按 sort 排序,含三级嵌套,不含禁用项。
w := testutil.Call(t, public, http.MethodGet, "/api/video-categories", nil)
tree := decodeTree(t, w.Body.Bytes())
if len(tree) != 2 || tree[0].ID != anime.ID || tree[1].ID != movie.ID {
t.Fatalf("公开树根节点异常: %+v", tree)
}
if len(tree[0].Children) != 1 || tree[0].Children[0].ID != child.ID {
t.Fatalf("公开树二级异常: %+v", tree[0].Children)
}
if len(tree[0].Children[0].Children) != 1 || tree[0].Children[0].Children[0].ID != grandchild.ID {
t.Fatalf("公开树三级异常: %+v", tree[0].Children[0].Children)
}
// 管理树包含禁用项。
w = testutil.Call(t, admin, http.MethodGet, "/api/video-categories/list", nil)
all := decodeTree(t, w.Body.Bytes())
if len(all) != 3 || all[0].ID != disabled.ID {
t.Fatalf("管理树异常: %+v", all)
}
// 更新:替换译文、调整排序与状态(parent_id 为空表示保持为根/移动到根,此处显式传父级)。
w = testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/video-categories/%d", grandchild.ID), map[string]any{
"slug": "anime-2026", "rating": 0, "sort": 1, "status": 0, "parent_id": child.ID,
"translations": []map[string]string{
{"locale": "zh-CN", "name": "2026 新番"},
{"locale": "ja-JP", "name": "2026年新番"},
},
})
if w.Code != http.StatusOK {
t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
updated := decodeCategory(t, w.Body.Bytes())
if updated.Slug != "anime-2026" || updated.Status != int8(0) || len(updated.Translations) != 2 {
t.Fatalf("更新结果异常: %+v", updated)
}
// 移动:把二级分类移动到根,路径与层级同步更新。
w = testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/video-categories/%d", child.ID), categoryPayload("anime-jp", "日番", nil, 15))
if w.Code != http.StatusOK {
t.Fatalf("移动分类状态码 = %d, body=%s", w.Code, w.Body.String())
}
moved := decodeCategory(t, w.Body.Bytes())
if moved.Level != 1 || moved.Path != strconv.FormatUint(uint64(child.ID), 10) {
t.Fatalf("移动后字段异常: %+v", moved)
}
var movedGrandchild model.VideoCategory
if err := env.DB.First(&movedGrandchild, grandchild.ID).Error; err != nil {
t.Fatalf("查询孙分类失败: %v", err)
}
if movedGrandchild.Level != 2 || movedGrandchild.Path != fmt.Sprintf("%d/%d", child.ID, grandchild.ID) {
t.Errorf("后代层级/路径未同步: %+v", movedGrandchild)
}
// 删除:有子分类拒绝。
if w := testutil.Call(t, admin, http.MethodDelete, fmt.Sprintf("/api/video-categories/%d", child.ID), nil); w.Code != http.StatusConflict {
t.Errorf("删除有子分类状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusConflict, w.Body.String())
}
if w := testutil.Call(t, admin, http.MethodDelete, fmt.Sprintf("/api/video-categories/%d", grandchild.ID), nil); w.Code != http.StatusNoContent {
t.Fatalf("删除叶子状态码 = %d, body=%s", w.Code, w.Body.String())
}
var translationCount int64
if err := env.DB.Model(&model.VideoCategoryTranslation{}).
Where("video_category_id = ?", grandchild.ID).Count(&translationCount).Error; err != nil {
t.Fatalf("统计译文失败: %v", err)
}
if translationCount != 0 {
t.Errorf("删除后译文未清理: %d", translationCount)
}
if w := testutil.Call(t, admin, http.MethodDelete, fmt.Sprintf("/api/video-categories/%d", child.ID), nil); w.Code != http.StatusNoContent {
t.Errorf("删除空子分类状态码 = %d", w.Code)
}
}
func TestVideoCategoryValidation(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
validTranslations := []map[string]string{{"locale": "zh-CN", "name": "综合"}}
cases := []struct {
name string
body map[string]any
}{
{"缺少 slug", map[string]any{"rating": 0, "translations": validTranslations}},
{"非法 slug", map[string]any{"slug": "Bad Slug!", "rating": 0, "translations": validTranslations}},
{"缺少分级", map[string]any{"slug": "valid", "translations": validTranslations}},
{"分级非法", map[string]any{"slug": "valid", "rating": 6, "translations": validTranslations}},
{"缺少译文", map[string]any{"slug": "valid", "rating": 0}},
{"空译文", map[string]any{"slug": "valid", "rating": 0, "translations": []map[string]string{}}},
{"译文空白", map[string]any{"slug": "valid", "rating": 0, "translations": []map[string]string{{"locale": "zh-CN", "name": " "}}}},
{"语言重复", map[string]any{"slug": "valid", "rating": 0, "translations": []map[string]string{validTranslations[0], validTranslations[0]}}},
{"名称超长", map[string]any{"slug": "valid", "rating": 0, "translations": []map[string]string{{"locale": "zh-CN", "name": strings.Repeat("字", 101)}}}},
{"状态非法", map[string]any{"slug": "valid", "rating": 0, "status": 2, "translations": validTranslations}},
{"父分类不存在", map[string]any{"slug": "valid", "rating": 0, "parent_id": 9999, "translations": validTranslations}},
}
for _, tc := range cases {
w := testutil.Call(t, admin, http.MethodPost, "/api/video-categories", tc.body)
if w.Code != http.StatusBadRequest {
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
}
}
// slug 唯一。
createCategory(t, admin, categoryPayload("unique-slug", "唯一", nil, 0))
if w := testutil.Call(t, admin, http.MethodPost, "/api/video-categories", categoryPayload("unique-slug", "重复", nil, 0)); w.Code != http.StatusConflict {
t.Errorf("重复 slug 状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusConflict, w.Body.String())
}
// 超过三级。
level1 := createCategory(t, admin, categoryPayload("lv1", "一级", nil, 0))
level2 := createCategory(t, admin, categoryPayload("lv2", "二级", level1.ID, 0))
level3 := createCategory(t, admin, categoryPayload("lv3", "三级", level2.ID, 0))
if w := testutil.Call(t, admin, http.MethodPost, "/api/video-categories", categoryPayload("lv4", "四级", level3.ID, 0)); w.Code != http.StatusBadRequest {
t.Errorf("四级分类状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
// 循环父级。
if w := testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/video-categories/%d", level1.ID), categoryPayload("lv1", "一级", level3.ID, 0)); w.Code != http.StatusBadRequest {
t.Errorf("循环父级状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
// 移动导致子树超深。
other := createCategory(t, admin, categoryPayload("other", "其他", nil, 0))
otherChild := createCategory(t, admin, categoryPayload("other-child", "其他子级", other.ID, 0))
if w := testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/video-categories/%d", level2.ID), categoryPayload("lv2", "二级", otherChild.ID, 0)); w.Code != http.StatusBadRequest {
t.Errorf("移动超深状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
if w := testutil.Call(t, admin, http.MethodPut, "/api/video-categories/abc", categoryPayload("x", "X", nil, 0)); w.Code != http.StatusBadRequest {
t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
}
if w := testutil.Call(t, admin, http.MethodPut, "/api/video-categories/9999", categoryPayload("x", "X", nil, 0)); w.Code != http.StatusNotFound {
t.Errorf("不存在的分类状态码 = %d, 期望 %d", w.Code, http.StatusNotFound)
}
}
func TestVideoCategoryFileRefs(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
upload := func(name string, content []byte) model.File {
t.Helper()
w := testutil.CallMultipart(t, admin, http.MethodPost, "/api/files", "file", name, content)
if w.Code != http.StatusCreated {
t.Fatalf("上传 %s 状态码 = %d, body=%s", name, w.Code, w.Body.String())
}
var record model.File
if err := json.Unmarshal(w.Body.Bytes(), &record); err != nil {
t.Fatalf("解析文件响应失败: %v", err)
}
return record
}
refCount := func(id uint) int64 {
t.Helper()
var record model.File
if err := env.DB.First(&record, id).Error; err != nil {
t.Fatalf("查询文件失败: %v", err)
}
return record.RefCount
}
icon := upload("icon.png", testutil.PNG(t, 8, 8))
cover := upload("cover.png", testutil.PNG(t, 12, 12))
newIcon := upload("icon2.png", testutil.PNG(t, 10, 10))
category := createCategory(t, admin, map[string]any{
"slug": "media", "rating": 0, "icon": "/api/files/" + strconv.FormatUint(uint64(icon.ID), 10),
"cover": "/api/files/" + strconv.FormatUint(uint64(cover.ID), 10),
"translations": []map[string]string{{"locale": "zh-CN", "name": "影视"}},
})
if got := refCount(icon.ID); got != 1 {
t.Fatalf("图标引用计数 = %d, 期望 1", got)
}
if got := refCount(cover.ID); got != 1 {
t.Fatalf("封面引用计数 = %d, 期望 1", got)
}
// 更换图标释放旧引用,封面保持不变。
w := testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/video-categories/%d", category.ID), map[string]any{
"slug": "media", "rating": 0, "icon": "/api/files/" + strconv.FormatUint(uint64(newIcon.ID), 10),
"cover": "/api/files/" + strconv.FormatUint(uint64(cover.ID), 10),
"translations": []map[string]string{{"locale": "zh-CN", "name": "影视"}},
})
if w.Code != http.StatusOK {
t.Fatalf("更新分类状态码 = %d, body=%s", w.Code, w.Body.String())
}
if got := refCount(icon.ID); got != 0 {
t.Errorf("旧图标引用计数 = %d, 期望 0", got)
}
if got := refCount(newIcon.ID); got != 1 {
t.Errorf("新图标引用计数 = %d, 期望 1", got)
}
if got := refCount(cover.ID); got != 1 {
t.Errorf("封面引用计数 = %d, 期望 1", got)
}
// 删除分类释放全部引用。
if w := testutil.Call(t, admin, http.MethodDelete, fmt.Sprintf("/api/video-categories/%d", category.ID), nil); w.Code != http.StatusNoContent {
t.Fatalf("删除分类状态码 = %d, body=%s", w.Code, w.Body.String())
}
if got := refCount(newIcon.ID); got != 0 {
t.Errorf("删除后图标引用计数 = %d, 期望 0", got)
}
if got := refCount(cover.ID); got != 0 {
t.Errorf("删除后封面引用计数 = %d, 期望 0", got)
}
// 无效本地文件地址被拒绝。
if w := testutil.Call(t, admin, http.MethodPost, "/api/video-categories", map[string]any{
"slug": "bad-icon", "rating": 0, "icon": "/api/files/9999",
"translations": []map[string]string{{"locale": "zh-CN", "name": "坏图标"}},
}); w.Code != http.StatusBadRequest {
t.Errorf("无效图标地址状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusBadRequest, w.Body.String())
}
}
func TestVideoCategoryPublicTree(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
public := env.Router("")
root := createCategory(t, admin, categoryPayload("root", "根", nil, 0))
child := createCategory(t, admin, map[string]any{
"slug": "child", "rating": 0, "parent_id": root.ID, "status": 1,
"translations": []map[string]string{{"locale": "zh-CN", "name": "子级"}},
})
// 禁用子级:公开树中父级保留但无子级。
if w := testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/video-categories/%d", child.ID), map[string]any{
"slug": "child", "rating": 0, "parent_id": root.ID, "status": 0,
"translations": []map[string]string{{"locale": "zh-CN", "name": "子级"}},
}); w.Code != http.StatusOK {
t.Fatalf("禁用子级状态码 = %d, body=%s", w.Code, w.Body.String())
}
w := testutil.Call(t, public, http.MethodGet, "/api/video-categories", nil)
tree := decodeTree(t, w.Body.Bytes())
if len(tree) != 1 || len(tree[0].Children) != 0 {
t.Fatalf("禁用子级应隐藏: %+v", tree)
}
// 禁用父级:整棵子树隐藏。
if w := testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/video-categories/%d", root.ID), map[string]any{
"slug": "root", "rating": 0, "status": 0,
"translations": []map[string]string{{"locale": "zh-CN", "name": "根"}},
}); w.Code != http.StatusOK {
t.Fatalf("禁用父级状态码 = %d, body=%s", w.Code, w.Body.String())
}
if w := testutil.Call(t, public, http.MethodGet, "/api/video-categories", nil); len(decodeTree(t, w.Body.Bytes())) != 0 {
t.Errorf("禁用父级后公开树应为空: %s", w.Body.String())
}
// 管理树仍包含两者。
w = testutil.Call(t, admin, http.MethodGet, "/api/video-categories/list", nil)
all := decodeTree(t, w.Body.Bytes())
if len(all) != 1 || len(all[0].Children) != 1 {
t.Fatalf("管理树应包含禁用项: %+v", all)
}
}
func findInTree(items []model.VideoCategory, id uint) *model.VideoCategory {
for i := range items {
if items[i].ID == id {
return &items[i]
}
if found := findInTree(items[i].Children, id); found != nil {
return found
}
}
return nil
}
func TestVideoCategoryRatings(t *testing.T) {
env := testutil.Setup(t)
admin := env.AdminRouter()
public := env.Router("")
root := createCategory(t, admin, map[string]any{
"slug": "m18-root", "rating": model.RatingM18, "sort": 0,
"translations": []map[string]string{{"locale": "zh-CN", "name": "成人"}},
})
if root.Rating != model.RatingM18 || root.EffectiveRating != model.RatingM18 {
t.Fatalf("根分类分级异常: rating=%d effective=%d", root.Rating, root.EffectiveRating)
}
child := createCategory(t, admin, map[string]any{
"slug": "g-child", "rating": model.RatingG, "parent_id": root.ID,
"translations": []map[string]string{{"locale": "zh-CN", "name": "普通"}},
})
if child.Rating != model.RatingG || child.EffectiveRating != model.RatingM18 {
t.Fatalf("子分类生效分级异常: rating=%d effective=%d", child.Rating, child.EffectiveRating)
}
grandchild := createCategory(t, admin, map[string]any{
"slug": "pg-grandchild", "rating": model.RatingPG, "parent_id": child.ID,
"translations": []map[string]string{{"locale": "zh-CN", "name": "家长指导"}},
})
if grandchild.EffectiveRating != model.RatingM18 {
t.Fatalf("孙分类生效分级 = %d, 期望 %d", grandchild.EffectiveRating, model.RatingM18)
}
other := createCategory(t, admin, categoryPayload("g-root", "普通根", nil, 0))
if other.Rating != model.RatingG || other.EffectiveRating != model.RatingG {
t.Fatalf("独立根分类分级异常: rating=%d effective=%d", other.Rating, other.EffectiveRating)
}
// 公开树生效值:M18 根覆盖子孙,独立根仍为 G。
w := testutil.Call(t, public, http.MethodGet, "/api/video-categories", nil)
tree := decodeTree(t, w.Body.Bytes())
if node := findInTree(tree, root.ID); node == nil || node.EffectiveRating != model.RatingM18 {
t.Fatalf("公开树根生效值异常: %+v", node)
}
if node := findInTree(tree, grandchild.ID); node == nil || node.EffectiveRating != model.RatingM18 {
t.Fatalf("公开树孙分类生效值异常: %+v", node)
}
if node := findInTree(tree, other.ID); node == nil || node.EffectiveRating != model.RatingG {
t.Fatalf("公开树独立根生效值异常: %+v", node)
}
// 父级调低:子树生效值随之释放。
w = testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/video-categories/%d", root.ID), map[string]any{
"slug": "m18-root", "rating": model.RatingG,
"translations": []map[string]string{{"locale": "zh-CN", "name": "成人"}},
})
if w.Code != http.StatusOK {
t.Fatalf("调低根分级状态码 = %d, body=%s", w.Code, w.Body.String())
}
if lowered := decodeCategory(t, w.Body.Bytes()); lowered.EffectiveRating != model.RatingG {
t.Errorf("根调低后生效值 = %d, 期望 0", lowered.EffectiveRating)
}
// 子级提高:自身与孙分类生效值均为 R21。
w = testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/video-categories/%d", child.ID), map[string]any{
"slug": "g-child", "rating": model.RatingR21, "parent_id": root.ID,
"translations": []map[string]string{{"locale": "zh-CN", "name": "普通"}},
})
if w.Code != http.StatusOK {
t.Fatalf("提高子级分级状态码 = %d, body=%s", w.Code, w.Body.String())
}
if raised := decodeCategory(t, w.Body.Bytes()); raised.EffectiveRating != model.RatingR21 {
t.Errorf("子级提高后生效值 = %d, 期望 %d", raised.EffectiveRating, model.RatingR21)
}
w = testutil.Call(t, public, http.MethodGet, "/api/video-categories", nil)
tree = decodeTree(t, w.Body.Bytes())
if node := findInTree(tree, root.ID); node == nil || node.EffectiveRating != model.RatingG {
t.Errorf("根生效值 = %+v, 期望 0", node)
}
if node := findInTree(tree, grandchild.ID); node == nil || node.EffectiveRating != model.RatingR21 {
t.Errorf("孙分类生效值 = %+v, 期望 %d", node, model.RatingR21)
}
}