新增多级视频分类、内容分级与用户偏好
- 迁移 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:
33 files changed
+3659
-40
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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: 'ネットワークエラーが発生しました。後でもう一度お試しください',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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: '网络异常,请稍后重试',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 记录已应用的迁移版本。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user