新增头部导航链接配置与多语言支持
- 迁移 v10 新增 nav_links 与 nav_link_translations 两张表,文案按语言存储 - 公开 GET /api/nav-links 返回启用链接(按 sort/id 排序);管理员可增删改查与启停 - URL 仅允许站内路径、http(s) 与 mailto,拒绝 javascript: 等危险协议;译文替换与校验 - 前端删除写死的“主页”按钮,头部按当前语言渲染动态链接,新窗口加 rel=noopener noreferrer - 后台管理页新增“头部导航链接”卡片(三语文案、URL、打开方式、排序、状态),改动即时生效 - 补充 nav 接口与迁移测试、三语文案,重新生成 Swagger 文档
This commit is contained in:
19 files changed
+2198
-13
No files matched your search
@@ -14,6 +14,7 @@ internal/
|
||||
├── usergroup/ 用户组管理
|
||||
├── note/ 便签
|
||||
├── site/ 站点信息
|
||||
├── nav/ 头部导航链接
|
||||
├── file/ 文件上传、删除、查看与本地存储
|
||||
├── avatar/ 当前用户头像
|
||||
├── httpx/ HTTP 公共能力:ErrorResponse、分页、ID 解析
|
||||
|
||||
+375
@@ -538,6 +538,279 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/nav-links": {
|
||||
"get": {
|
||||
"description": "Public header navigation links (status enabled), ordered by sort ASC then id ASC, with translations.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "List nav links",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.NavLink"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Admin only. Create a header navigation link. url accepts site-relative paths (/...), http(s) URLs, and mailto links; translations must include at least one non-empty label.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Create a nav link",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Nav link payload",
|
||||
"name": "link",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/nav.Request"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.NavLink"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid request",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/nav-links/list": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Admin only. List all header navigation links including disabled ones, with translations.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "List all nav links",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.NavLink"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/nav-links/{id}": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Admin only. Update a header navigation link; translations are replaced by the provided list.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Update a nav link",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"example": 1,
|
||||
"description": "Nav link ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Nav link payload",
|
||||
"name": "link",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/nav.Request"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.NavLink"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid request or 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"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Admin only. Delete a header navigation link and its translations.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Delete a nav link",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"example": 1,
|
||||
"description": "Nav link 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"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notes": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1869,6 +2142,52 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.NavLink": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"open_in_new_window": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sort": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer"
|
||||
},
|
||||
"translations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.NavLinkTranslation"
|
||||
}
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.NavLinkTranslation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"locale": {
|
||||
"type": "string"
|
||||
},
|
||||
"nav_link_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.Note": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1975,6 +2294,62 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav.Request": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"translations",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"open_in_new_window": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"sort": {
|
||||
"type": "integer",
|
||||
"example": 0
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"example": 1
|
||||
},
|
||||
"translations": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/definitions/nav.TranslationRequest"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"maxLength": 512,
|
||||
"example": "/profile"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav.TranslationRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label",
|
||||
"locale"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"example": "首页"
|
||||
},
|
||||
"locale": {
|
||||
"type": "string",
|
||||
"maxLength": 10,
|
||||
"example": "zh-CN"
|
||||
}
|
||||
}
|
||||
},
|
||||
"note.ListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -531,6 +531,279 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/nav-links": {
|
||||
"get": {
|
||||
"description": "Public header navigation links (status enabled), ordered by sort ASC then id ASC, with translations.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"public"
|
||||
],
|
||||
"summary": "List nav links",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.NavLink"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Admin only. Create a header navigation link. url accepts site-relative paths (/...), http(s) URLs, and mailto links; translations must include at least one non-empty label.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Create a nav link",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Nav link payload",
|
||||
"name": "link",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/nav.Request"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.NavLink"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid request",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/nav-links/list": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Admin only. List all header navigation links including disabled ones, with translations.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "List all nav links",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.NavLink"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/nav-links/{id}": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Admin only. Update a header navigation link; translations are replaced by the provided list.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Update a nav link",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"example": 1,
|
||||
"description": "Nav link ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Nav link payload",
|
||||
"name": "link",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/nav.Request"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.NavLink"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "invalid request or 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"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Admin only. Delete a header navigation link and its translations.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"admin"
|
||||
],
|
||||
"summary": "Delete a nav link",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"example": 1,
|
||||
"description": "Nav link 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"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/notes": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1862,6 +2135,52 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.NavLink": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"open_in_new_window": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sort": {
|
||||
"type": "integer"
|
||||
},
|
||||
"status": {
|
||||
"type": "integer"
|
||||
},
|
||||
"translations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.NavLinkTranslation"
|
||||
}
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.NavLinkTranslation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"locale": {
|
||||
"type": "string"
|
||||
},
|
||||
"nav_link_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.Note": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1968,6 +2287,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav.Request": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"translations",
|
||||
"url"
|
||||
],
|
||||
"properties": {
|
||||
"open_in_new_window": {
|
||||
"type": "boolean",
|
||||
"example": false
|
||||
},
|
||||
"sort": {
|
||||
"type": "integer",
|
||||
"example": 0
|
||||
},
|
||||
"status": {
|
||||
"type": "integer",
|
||||
"enum": [
|
||||
0,
|
||||
1
|
||||
],
|
||||
"example": 1
|
||||
},
|
||||
"translations": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/definitions/nav.TranslationRequest"
|
||||
}
|
||||
},
|
||||
"url": {
|
||||
"type": "string",
|
||||
"maxLength": 512,
|
||||
"example": "/profile"
|
||||
}
|
||||
}
|
||||
},
|
||||
"nav.TranslationRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"label",
|
||||
"locale"
|
||||
],
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"example": "首页"
|
||||
},
|
||||
"locale": {
|
||||
"type": "string",
|
||||
"maxLength": 10,
|
||||
"example": "zh-CN"
|
||||
}
|
||||
}
|
||||
},
|
||||
"note.ListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -105,6 +105,36 @@ definitions:
|
||||
uploader_id:
|
||||
type: integer
|
||||
type: object
|
||||
model.NavLink:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
open_in_new_window:
|
||||
type: boolean
|
||||
sort:
|
||||
type: integer
|
||||
status:
|
||||
type: integer
|
||||
translations:
|
||||
items:
|
||||
$ref: '#/definitions/model.NavLinkTranslation'
|
||||
type: array
|
||||
updated_at:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
model.NavLinkTranslation:
|
||||
properties:
|
||||
label:
|
||||
type: string
|
||||
locale:
|
||||
type: string
|
||||
nav_link_id:
|
||||
type: integer
|
||||
type: object
|
||||
model.Note:
|
||||
properties:
|
||||
content:
|
||||
@@ -176,6 +206,47 @@ definitions:
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
nav.Request:
|
||||
properties:
|
||||
open_in_new_window:
|
||||
example: false
|
||||
type: boolean
|
||||
sort:
|
||||
example: 0
|
||||
type: integer
|
||||
status:
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
example: 1
|
||||
type: integer
|
||||
translations:
|
||||
items:
|
||||
$ref: '#/definitions/nav.TranslationRequest'
|
||||
minItems: 1
|
||||
type: array
|
||||
url:
|
||||
example: /profile
|
||||
maxLength: 512
|
||||
type: string
|
||||
required:
|
||||
- translations
|
||||
- url
|
||||
type: object
|
||||
nav.TranslationRequest:
|
||||
properties:
|
||||
label:
|
||||
example: 首页
|
||||
maxLength: 100
|
||||
type: string
|
||||
locale:
|
||||
example: zh-CN
|
||||
maxLength: 10
|
||||
type: string
|
||||
required:
|
||||
- label
|
||||
- locale
|
||||
type: object
|
||||
note.ListResponse:
|
||||
properties:
|
||||
items:
|
||||
@@ -715,6 +786,187 @@ paths:
|
||||
summary: Update current user avatar
|
||||
tags:
|
||||
- user
|
||||
/nav-links:
|
||||
get:
|
||||
description: Public header navigation links (status enabled), ordered by sort
|
||||
ASC then id ASC, with translations.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.NavLink'
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
summary: List nav links
|
||||
tags:
|
||||
- public
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Admin only. Create a header navigation link. url accepts site-relative
|
||||
paths (/...), http(s) URLs, and mailto links; translations must include at
|
||||
least one non-empty label.
|
||||
parameters:
|
||||
- description: Nav link payload
|
||||
in: body
|
||||
name: link
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/nav.Request'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/model.NavLink'
|
||||
"400":
|
||||
description: invalid request
|
||||
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'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Create a nav link
|
||||
tags:
|
||||
- admin
|
||||
/nav-links/{id}:
|
||||
delete:
|
||||
description: Admin only. Delete a header navigation link and its translations.
|
||||
parameters:
|
||||
- description: Nav link 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'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete a nav link
|
||||
tags:
|
||||
- admin
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Admin only. Update a header navigation link; translations are replaced
|
||||
by the provided list.
|
||||
parameters:
|
||||
- description: Nav link ID
|
||||
example: 1
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: integer
|
||||
- description: Nav link payload
|
||||
in: body
|
||||
name: link
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/nav.Request'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/model.NavLink'
|
||||
"400":
|
||||
description: invalid request or 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'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Update a nav link
|
||||
tags:
|
||||
- admin
|
||||
/nav-links/list:
|
||||
get:
|
||||
description: Admin only. List all header navigation links including disabled
|
||||
ones, with translations.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/model.NavLink'
|
||||
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 nav links
|
||||
tags:
|
||||
- admin
|
||||
/notes:
|
||||
get:
|
||||
description: List notes ordered by id DESC. page starts at 1; page_size is 1-100,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { request } from './http'
|
||||
|
||||
export interface NavTranslation {
|
||||
locale: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface NavLink {
|
||||
id: number
|
||||
url: string
|
||||
open_in_new_window: boolean
|
||||
sort: number
|
||||
status: number
|
||||
translations: NavTranslation[]
|
||||
}
|
||||
|
||||
export interface NavLinkPayload {
|
||||
url: string
|
||||
open_in_new_window: boolean
|
||||
sort: number
|
||||
status: number
|
||||
translations: NavTranslation[]
|
||||
}
|
||||
|
||||
export function getNavLinks(): Promise<NavLink[]> {
|
||||
return request<NavLink[]>('/nav-links')
|
||||
}
|
||||
|
||||
export function getAllNavLinks(): Promise<NavLink[]> {
|
||||
return request<NavLink[]>('/nav-links/list')
|
||||
}
|
||||
|
||||
export function createNavLink(payload: NavLinkPayload): Promise<NavLink> {
|
||||
return request<NavLink>('/nav-links', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateNavLink(id: number, payload: NavLinkPayload): Promise<NavLink> {
|
||||
return request<NavLink>(`/nav-links/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteNavLink(id: number): Promise<void> {
|
||||
return request<void>(`/nav-links/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ApiError } from '@/api/http'
|
||||
import {
|
||||
createNavLink,
|
||||
deleteNavLink,
|
||||
getAllNavLinks,
|
||||
updateNavLink,
|
||||
type NavLink,
|
||||
type NavLinkPayload,
|
||||
} from '@/api/nav'
|
||||
import { SUPPORTED_LOCALES } from '@/i18n'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
|
||||
const { t } = useI18n()
|
||||
const nav = useNavStore()
|
||||
|
||||
const links = ref<NavLink[]>([])
|
||||
const loading = ref(false)
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
const editorOpen = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
interface FormState {
|
||||
labels: Record<string, string>
|
||||
url: string
|
||||
openInNewWindow: boolean
|
||||
sort: number
|
||||
status: number
|
||||
}
|
||||
|
||||
function emptyLabels(): Record<string, string> {
|
||||
const labels: Record<string, string> = {}
|
||||
for (const item of SUPPORTED_LOCALES) {
|
||||
labels[item.code] = ''
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
const form = reactive<FormState>({
|
||||
labels: emptyLabels(),
|
||||
url: '',
|
||||
openInNewWindow: false,
|
||||
sort: 0,
|
||||
status: 1,
|
||||
})
|
||||
|
||||
const isEditing = computed(() => editingId.value !== null)
|
||||
|
||||
const URL_PATTERN = /^(\/(?!\/)|https?:\/\/|mailto:)/
|
||||
|
||||
async function loadLinks() {
|
||||
loading.value = true
|
||||
try {
|
||||
links.value = await getAllNavLinks()
|
||||
} catch {
|
||||
error.value = t('admin.nav.errors.network')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadLinks)
|
||||
|
||||
function resetForm() {
|
||||
form.labels = emptyLabels()
|
||||
form.url = ''
|
||||
form.openInNewWindow = false
|
||||
form.sort = links.value.length ? Math.max(...links.value.map((item) => item.sort)) + 10 : 10
|
||||
form.status = 1
|
||||
editingId.value = null
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
}
|
||||
|
||||
function startCreate() {
|
||||
resetForm()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function startEdit(link: NavLink) {
|
||||
resetForm()
|
||||
editingId.value = link.id
|
||||
for (const item of SUPPORTED_LOCALES) {
|
||||
form.labels[item.code] = link.translations.find((tr) => tr.locale === item.code)?.label ?? ''
|
||||
}
|
||||
form.url = link.url
|
||||
form.openInNewWindow = link.open_in_new_window
|
||||
form.sort = link.sort
|
||||
form.status = link.status
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editorOpen.value = false
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function validate(): string | null {
|
||||
const url = form.url.trim()
|
||||
if (!url) {
|
||||
return t('admin.nav.errors.urlRequired')
|
||||
}
|
||||
if (!URL_PATTERN.test(url)) {
|
||||
return t('admin.nav.errors.urlInvalid')
|
||||
}
|
||||
const labels = SUPPORTED_LOCALES.map((item) => form.labels[item.code]?.trim() ?? '').filter(
|
||||
(label) => label !== '',
|
||||
)
|
||||
if (labels.length === 0) {
|
||||
return t('admin.nav.errors.labelRequired')
|
||||
}
|
||||
if (labels.some((label) => [...label].length > 100)) {
|
||||
return t('admin.nav.errors.labelTooLong')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
const message = validate()
|
||||
if (message) {
|
||||
error.value = message
|
||||
return
|
||||
}
|
||||
|
||||
const payload: NavLinkPayload = {
|
||||
url: form.url.trim(),
|
||||
open_in_new_window: form.openInNewWindow,
|
||||
sort: Number.isFinite(form.sort) ? form.sort : 0,
|
||||
status: form.status,
|
||||
translations: SUPPORTED_LOCALES.map((item) => ({
|
||||
locale: item.code,
|
||||
label: form.labels[item.code]?.trim() ?? '',
|
||||
})).filter((item) => item.label !== ''),
|
||||
}
|
||||
|
||||
busy.value = true
|
||||
try {
|
||||
if (editingId.value !== null) {
|
||||
await updateNavLink(editingId.value, payload)
|
||||
notice.value = t('admin.nav.updateSuccess')
|
||||
} else {
|
||||
await createNavLink(payload)
|
||||
notice.value = t('admin.nav.createSuccess')
|
||||
}
|
||||
closeEditor()
|
||||
await loadLinks()
|
||||
await nav.load()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.status >= 400 && err.status < 500) {
|
||||
error.value = t('admin.nav.errors.invalid')
|
||||
} else {
|
||||
error.value = t('admin.nav.errors.network')
|
||||
}
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(link: NavLink) {
|
||||
const name = link.translations[0]?.label ?? link.url
|
||||
if (!window.confirm(t('admin.nav.confirmDelete', { name }))) {
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
notice.value = ''
|
||||
try {
|
||||
await deleteNavLink(link.id)
|
||||
notice.value = t('admin.nav.deleteSuccess')
|
||||
await loadLinks()
|
||||
await nav.load()
|
||||
} catch {
|
||||
error.value = t('admin.nav.errors.network')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function linkTitle(link: NavLink): string {
|
||||
return link.translations.map((item) => item.label).join(' / ')
|
||||
}
|
||||
</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.nav.title') }}</h2>
|
||||
<p class="mt-1 text-sm text-content-3">{{ t('admin.nav.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.nav.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="links.length" class="mt-4 divide-y divide-line rounded-xl border border-line">
|
||||
<li
|
||||
v-for="link in links"
|
||||
:key="link.id"
|
||||
class="flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm text-content-1">{{ linkTitle(link) }}</p>
|
||||
<p class="mt-0.5 truncate text-xs text-content-3">{{ link.url }}</p>
|
||||
</div>
|
||||
<span class="rounded-full bg-page px-2 py-0.5 text-xs text-content-3">
|
||||
{{ link.open_in_new_window ? t('admin.nav.targetBlank') : t('admin.nav.targetSelf') }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs"
|
||||
:class="link.status === 1 ? 'bg-primary/10 text-primary' : 'bg-page text-content-3'"
|
||||
>
|
||||
{{ link.status === 1 ? t('admin.nav.enabled') : t('admin.nav.disabled') }}
|
||||
</span>
|
||||
<span class="text-xs text-content-3">#{{ link.sort }}</span>
|
||||
<div class="flex gap-2">
|
||||
<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(link)"
|
||||
>
|
||||
{{ t('admin.nav.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(link)"
|
||||
>
|
||||
{{ t('admin.nav.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else-if="!loading"
|
||||
class="mt-4 rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-content-3"
|
||||
>
|
||||
{{ t('admin.nav.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">
|
||||
{{ isEditing ? t('admin.nav.edit') : t('admin.nav.add') }}
|
||||
</h3>
|
||||
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<div v-for="item in SUPPORTED_LOCALES" :key="item.code">
|
||||
<label
|
||||
:for="`nav-label-${item.code}`"
|
||||
class="mb-1.5 block text-sm text-content-2"
|
||||
>
|
||||
{{ t('admin.nav.labels') }} · {{ item.label }}
|
||||
</label>
|
||||
<input
|
||||
:id="`nav-label-${item.code}`"
|
||||
v-model="form.labels[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 grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="nav-url" class="mb-1.5 block text-sm text-content-2">
|
||||
{{ t('admin.nav.url') }}
|
||||
</label>
|
||||
<input
|
||||
id="nav-url"
|
||||
v-model="form.url"
|
||||
type="text"
|
||||
maxlength="512"
|
||||
:placeholder="t('admin.nav.urlPlaceholder')"
|
||||
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>
|
||||
<label for="nav-sort" class="mb-1.5 block text-sm text-content-2">
|
||||
{{ t('admin.nav.sort') }}
|
||||
</label>
|
||||
<input
|
||||
id="nav-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-40"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-content-3">{{ t('admin.nav.sortHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-start gap-x-8 gap-y-4">
|
||||
<div>
|
||||
<span class="mb-1.5 block text-sm text-content-2">{{ t('admin.nav.target') }}</span>
|
||||
<div class="flex gap-2">
|
||||
<label class="cursor-pointer">
|
||||
<input v-model="form.openInNewWindow" type="radio" :value="false" 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.nav.targetSelf') }}
|
||||
</span>
|
||||
</label>
|
||||
<label class="cursor-pointer">
|
||||
<input v-model="form.openInNewWindow" type="radio" :value="true" 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.nav.targetBlank') }}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="mb-1.5 block text-sm text-content-2">{{ t('admin.nav.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.nav.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.nav.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.nav.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.nav.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -2,19 +2,35 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import type { NavLink } from '@/api/nav'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useNavStore } from '@/stores/nav'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const nav = useNavStore()
|
||||
const site = useSiteStore()
|
||||
|
||||
const keyword = ref('')
|
||||
const menuOpen = ref(false)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function isInternal(url: string): boolean {
|
||||
return url.startsWith('/')
|
||||
}
|
||||
|
||||
function navLabel(link: NavLink): string {
|
||||
const current = link.translations.find((item) => item.locale === locale.value)?.label
|
||||
if (current) {
|
||||
return current
|
||||
}
|
||||
const fallback = link.translations.find((item) => item.locale === 'zh-CN')?.label
|
||||
return fallback ?? link.translations[0]?.label ?? ''
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
|
||||
menuOpen.value = false
|
||||
@@ -47,8 +63,27 @@ function logout() {
|
||||
<span>{{ site.name }}</span>
|
||||
</a>
|
||||
|
||||
<nav class="hidden items-center gap-5 text-sm lg:flex">
|
||||
<a href="#" class="font-medium text-content-1">{{ t('nav.home') }}</a>
|
||||
<nav v-if="nav.hasLinks" class="hidden items-center gap-5 text-sm lg:flex">
|
||||
<template v-for="link in nav.links" :key="link.id">
|
||||
<RouterLink
|
||||
v-if="isInternal(link.url)"
|
||||
:to="link.url"
|
||||
:target="link.open_in_new_window ? '_blank' : undefined"
|
||||
:rel="link.open_in_new_window ? 'noopener noreferrer' : undefined"
|
||||
class="font-medium text-content-1 transition-colors hover:text-primary"
|
||||
>
|
||||
{{ navLabel(link) }}
|
||||
</RouterLink>
|
||||
<a
|
||||
v-else
|
||||
:href="link.url"
|
||||
:target="link.open_in_new_window ? '_blank' : undefined"
|
||||
:rel="link.open_in_new_window ? 'noopener noreferrer' : undefined"
|
||||
class="font-medium text-content-1 transition-colors hover:text-primary"
|
||||
>
|
||||
{{ navLabel(link) }}
|
||||
</a>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { MessageSchema } from './zh-CN'
|
||||
|
||||
const enUS: MessageSchema = {
|
||||
nav: {
|
||||
home: 'Home',
|
||||
},
|
||||
header: {
|
||||
searchPlaceholder: 'Search videos and creators',
|
||||
search: 'Search',
|
||||
@@ -157,6 +154,39 @@ const enUS: MessageSchema = {
|
||||
footerPlaceholder: 'Falls back to the localized default when empty',
|
||||
save: 'Save changes',
|
||||
saveSuccess: 'Site information updated',
|
||||
nav: {
|
||||
title: 'Header links',
|
||||
subtitle: 'Configure the links shown in the site header and their localized labels',
|
||||
add: 'Add link',
|
||||
edit: 'Edit link',
|
||||
delete: 'Delete',
|
||||
empty: 'No links yet. Click “Add link” to get started',
|
||||
labels: 'Label',
|
||||
url: 'URL',
|
||||
urlPlaceholder: '/path or https://example.com',
|
||||
target: 'Open in',
|
||||
targetSelf: 'Same window',
|
||||
targetBlank: 'New window',
|
||||
sort: 'Order',
|
||||
sortHint: 'Smaller numbers appear first',
|
||||
status: 'Status',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
save: 'Save',
|
||||
cancel: 'Cancel',
|
||||
createSuccess: 'Link created',
|
||||
updateSuccess: 'Link updated',
|
||||
deleteSuccess: 'Link deleted',
|
||||
confirmDelete: 'Delete “{name}”?',
|
||||
errors: {
|
||||
urlRequired: 'URL is required',
|
||||
urlInvalid: 'URL must start with /, http(s):// or mailto:',
|
||||
labelRequired: 'Provide a label for at least one language',
|
||||
labelTooLong: 'Each label must be at most 100 characters',
|
||||
invalid: 'The submitted information is invalid, please check and retry',
|
||||
network: 'Network error, please try again later',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
siteNameRequired: 'Site name is required',
|
||||
siteNameLength: 'Site name must be at most 100 characters',
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import type { MessageSchema } from './zh-CN'
|
||||
|
||||
const jaJP: MessageSchema = {
|
||||
nav: {
|
||||
home: 'ホーム',
|
||||
},
|
||||
header: {
|
||||
searchPlaceholder: '動画・クリエイターを検索',
|
||||
search: '検索',
|
||||
@@ -157,6 +154,39 @@ const jaJP: MessageSchema = {
|
||||
footerPlaceholder: '空欄の場合は多言語の既定文案を使用します',
|
||||
save: '変更を保存',
|
||||
saveSuccess: 'サイト情報を更新しました',
|
||||
nav: {
|
||||
title: 'ヘッダーリンク',
|
||||
subtitle: 'ヘッダーに表示するリンクと多言語の文言を設定します',
|
||||
add: 'リンクを追加',
|
||||
edit: 'リンクを編集',
|
||||
delete: '削除',
|
||||
empty: 'リンクがありません。「リンクを追加」から設定してください',
|
||||
labels: 'リンク文言',
|
||||
url: 'リンク URL',
|
||||
urlPlaceholder: '/ のパスまたは https://example.com',
|
||||
target: '開き方',
|
||||
targetSelf: '同じウィンドウ',
|
||||
targetBlank: '新しいウィンドウ',
|
||||
sort: '並び順',
|
||||
sortHint: '数字が小さいほど前に表示されます',
|
||||
status: '状態',
|
||||
enabled: '有効',
|
||||
disabled: '無効',
|
||||
save: '保存',
|
||||
cancel: 'キャンセル',
|
||||
createSuccess: 'リンクを作成しました',
|
||||
updateSuccess: 'リンクを更新しました',
|
||||
deleteSuccess: 'リンクを削除しました',
|
||||
confirmDelete: '「{name}」を削除しますか?',
|
||||
errors: {
|
||||
urlRequired: 'リンク URL を入力してください',
|
||||
urlInvalid: 'URL は / で始まるパス、http(s)://、mailto: のみ利用できます',
|
||||
labelRequired: '少なくとも 1 つの言語の文言を入力してください',
|
||||
labelTooLong: '文言は 1 つあたり 100 文字以内で入力してください',
|
||||
invalid: '入力内容が無効です。確認してもう一度お試しください',
|
||||
network: 'ネットワークエラーが発生しました。後でもう一度お試しください',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
siteNameRequired: 'サイト名を入力してください',
|
||||
siteNameLength: 'サイト名は 100 文字以内で入力してください',
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
const zhCN = {
|
||||
nav: {
|
||||
home: '首页',
|
||||
},
|
||||
header: {
|
||||
searchPlaceholder: '搜索视频、UP主',
|
||||
search: '搜索',
|
||||
@@ -155,6 +152,39 @@ const zhCN = {
|
||||
footerPlaceholder: '留空时使用多语言默认文案',
|
||||
save: '保存修改',
|
||||
saveSuccess: '站点信息已更新',
|
||||
nav: {
|
||||
title: '头部导航链接',
|
||||
subtitle: '配置显示在页面头部的链接与多语言文案',
|
||||
add: '新增链接',
|
||||
edit: '编辑链接',
|
||||
delete: '删除',
|
||||
empty: '暂无链接,点击“新增链接”开始配置',
|
||||
labels: '链接文案',
|
||||
url: '链接地址',
|
||||
urlPlaceholder: '/ 站内路径或 https://example.com',
|
||||
target: '打开方式',
|
||||
targetSelf: '当前窗口',
|
||||
targetBlank: '新窗口',
|
||||
sort: '排序',
|
||||
sortHint: '数字越小越靠前',
|
||||
status: '状态',
|
||||
enabled: '启用',
|
||||
disabled: '禁用',
|
||||
save: '保存',
|
||||
cancel: '取消',
|
||||
createSuccess: '链接已创建',
|
||||
updateSuccess: '链接已更新',
|
||||
deleteSuccess: '链接已删除',
|
||||
confirmDelete: '确定删除“{name}”吗?',
|
||||
errors: {
|
||||
urlRequired: '请填写链接地址',
|
||||
urlInvalid: '链接地址仅支持 / 开头、http(s):// 或 mailto:',
|
||||
labelRequired: '至少填写一种语言的文案',
|
||||
labelTooLong: '单条文案最多 100 个字符',
|
||||
invalid: '提交的信息无效,请检查后重试',
|
||||
network: '网络异常,请稍后重试',
|
||||
},
|
||||
},
|
||||
errors: {
|
||||
siteNameRequired: '请填写网站名称',
|
||||
siteNameLength: '网站名称最多 100 个字符',
|
||||
|
||||
@@ -8,6 +8,7 @@ import router from './router'
|
||||
import { i18n } from './i18n'
|
||||
import { setUnauthorizedHandler } from './api/http'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useNavStore } from './stores/nav'
|
||||
import { useSiteStore } from './stores/site'
|
||||
|
||||
const app = createApp(App)
|
||||
@@ -23,6 +24,9 @@ auth.restore()
|
||||
const site = useSiteStore()
|
||||
void site.load()
|
||||
|
||||
const nav = useNavStore()
|
||||
void nav.load()
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
const wasAuthenticated = auth.isAuthenticated
|
||||
auth.logout()
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { getNavLinks, type NavLink } from '@/api/nav'
|
||||
|
||||
export const useNavStore = defineStore('nav', () => {
|
||||
const links = ref<NavLink[]>([])
|
||||
|
||||
const hasLinks = computed(() => links.value.length > 0)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
links.value = await getNavLinks()
|
||||
} catch {
|
||||
// 加载失败时保持空导航
|
||||
}
|
||||
}
|
||||
|
||||
return { links, hasLinks, load }
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { z } from 'zod'
|
||||
import { ApiError } from '@/api/http'
|
||||
import NavLinksCard from '@/components/admin/NavLinksCard.vue'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
interface SiteForm {
|
||||
@@ -276,6 +277,8 @@ const onSubmit = handleSubmit(async (values) => {
|
||||
{{ isSubmitting ? t('common.submitting') : t('admin.save') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<NavLinksCard />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+8
-1
@@ -15,13 +15,14 @@ import (
|
||||
"rill/internal/config"
|
||||
"rill/internal/database"
|
||||
"rill/internal/file"
|
||||
"rill/internal/nav"
|
||||
"rill/internal/note"
|
||||
"rill/internal/site"
|
||||
"rill/internal/user"
|
||||
"rill/internal/usergroup"
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -43,6 +44,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))
|
||||
|
||||
authed := rg.Group("", authn.RequireAuth(db))
|
||||
{
|
||||
@@ -69,6 +71,11 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
admin.PUT("/site/logo", site.UploadLogo(db, cfg))
|
||||
admin.DELETE("/site/logo", site.DeleteLogo(db, cfg))
|
||||
|
||||
admin.GET("/nav-links/list", nav.ListAll(db))
|
||||
admin.POST("/nav-links", nav.Create(db))
|
||||
admin.PUT("/nav-links/:id", nav.Update(db))
|
||||
admin.DELETE("/nav-links/:id", nav.Delete(db))
|
||||
|
||||
users := admin.Group("/users")
|
||||
{
|
||||
users.GET("", user.List(db))
|
||||
|
||||
@@ -80,6 +80,12 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) {
|
||||
if !db.Migrator().HasTable(&model.SiteSetting{}) {
|
||||
t.Error("site_settings 表未创建")
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.NavLink{}) {
|
||||
t.Error("nav_links 表未创建")
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.NavLinkTranslation{}) {
|
||||
t.Error("nav_link_translations 表未创建")
|
||||
}
|
||||
if !db.Migrator().HasTable(&schemaMigration{}) {
|
||||
t.Error("schema_migrations 表未创建")
|
||||
}
|
||||
|
||||
@@ -118,6 +118,13 @@ var migrations = []Migration{
|
||||
return tx.AutoMigrate(&model.FileOperation{})
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: 10,
|
||||
Name: "create_nav_links",
|
||||
Up: func(tx *gorm.DB) error {
|
||||
return tx.AutoMigrate(&model.NavLink{}, &model.NavLinkTranslation{})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// schemaMigration 记录已应用的迁移版本。
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// 导航链接状态。
|
||||
const (
|
||||
NavLinkStatusDisabled int8 = 0
|
||||
NavLinkStatusEnabled int8 = 1
|
||||
)
|
||||
|
||||
// NavLink 头部导航链接,文案按语言存放在 NavLinkTranslation。
|
||||
type NavLink struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
URL string `gorm:"size:512;not null" json:"url"`
|
||||
OpenInNewWindow bool `gorm:"not null;default:false" json:"open_in_new_window"`
|
||||
Sort int `gorm:"not null;default:0" json:"sort"`
|
||||
Status int8 `gorm:"not null" json:"status"`
|
||||
Translations []NavLinkTranslation `gorm:"-" json:"translations"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// NavLinkTranslation 导航链接的多语言文案,同一链接同一语言唯一。
|
||||
type NavLinkTranslation struct {
|
||||
ID uint `gorm:"primaryKey" json:"-"`
|
||||
NavLinkID uint `gorm:"uniqueIndex:idx_nav_link_locale;not null" json:"nav_link_id"`
|
||||
Locale string `gorm:"size:10;uniqueIndex:idx_nav_link_locale;not null" json:"locale"`
|
||||
Label string `gorm:"size:100;not null" json:"label"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
// Package nav 提供头部导航链接的公开读取与管理员维护接口。
|
||||
package nav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rill/internal/httpx"
|
||||
"rill/internal/model"
|
||||
)
|
||||
|
||||
// TranslationRequest 导航链接的多语言文案。
|
||||
type TranslationRequest struct {
|
||||
Locale string `json:"locale" binding:"required,max=10" example:"zh-CN"`
|
||||
Label string `json:"label" binding:"required,max=100" example:"首页"`
|
||||
}
|
||||
|
||||
// Request 创建/更新导航链接请求。
|
||||
type Request struct {
|
||||
URL string `json:"url" binding:"required,max=512" example:"/profile"`
|
||||
OpenInNewWindow bool `json:"open_in_new_window" example:"false"`
|
||||
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 nav links
|
||||
// @Description Public header navigation links (status enabled), ordered by sort ASC then id ASC, with translations.
|
||||
// @Tags public
|
||||
// @Produce json
|
||||
// @Success 200 {array} model.NavLink
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Router /nav-links [get]
|
||||
func List(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
links, err := queryLinks(c.Request.Context(), db, false)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, links)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary List all nav links
|
||||
// @Description Admin only. List all header navigation links including disabled ones, with translations.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Success 200 {array} model.NavLink
|
||||
// @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 /nav-links/list [get]
|
||||
func ListAll(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
links, err := queryLinks(c.Request.Context(), db, true)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, links)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Create a nav link
|
||||
// @Description Admin only. Create a header navigation link. url accepts site-relative paths (/...), http(s) URLs, and mailto links; translations must include at least one non-empty label.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param link body nav.Request true "Nav link payload"
|
||||
// @Success 201 {object} model.NavLink
|
||||
// @Failure 400 {object} httpx.ErrorResponse "invalid request"
|
||||
// @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 /nav-links [post]
|
||||
func Create(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
req, ok := bindRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
status := int8(1)
|
||||
if req.Status != nil {
|
||||
status = *req.Status
|
||||
}
|
||||
|
||||
link := model.NavLink{
|
||||
URL: strings.TrimSpace(req.URL),
|
||||
OpenInNewWindow: req.OpenInNewWindow,
|
||||
Sort: req.Sort,
|
||||
Status: status,
|
||||
}
|
||||
err := db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(&link).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceTranslations(tx, link.ID, req.Translations)
|
||||
})
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
translations, err := translationsFor(c.Request.Context(), db, link.ID)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
link.Translations = translations
|
||||
c.JSON(http.StatusCreated, link)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Update a nav link
|
||||
// @Description Admin only. Update a header navigation link; translations are replaced by the provided list.
|
||||
// @Tags admin
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path int true "Nav link ID" example(1)
|
||||
// @Param link body nav.Request true "Nav link payload"
|
||||
// @Success 200 {object} model.NavLink
|
||||
// @Failure 400 {object} httpx.ErrorResponse "invalid request or id"
|
||||
// @Failure 404 {object} httpx.ErrorResponse "record not found"
|
||||
// @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 /nav-links/{id} [put]
|
||||
func Update(db *gorm.DB) 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 link model.NavLink
|
||||
if err := db.WithContext(ctx).First(&link, id).Error; err != nil {
|
||||
httpx.RespondGetError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
link.URL = strings.TrimSpace(req.URL)
|
||||
link.OpenInNewWindow = req.OpenInNewWindow
|
||||
link.Sort = req.Sort
|
||||
if req.Status != nil {
|
||||
link.Status = *req.Status
|
||||
}
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(&link).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return replaceTranslations(tx, link.ID, req.Translations)
|
||||
})
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
translations, err := translationsFor(ctx, db, link.ID)
|
||||
if err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
link.Translations = translations
|
||||
c.JSON(http.StatusOK, link)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Delete a nav link
|
||||
// @Description Admin only. Delete a header navigation link and its translations.
|
||||
// @Tags admin
|
||||
// @Produce json
|
||||
// @Param id path int true "Nav link ID" example(1)
|
||||
// @Success 204 "Deleted"
|
||||
// @Failure 400 {object} httpx.ErrorResponse "invalid id"
|
||||
// @Failure 404 {object} httpx.ErrorResponse "record not found"
|
||||
// @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 /nav-links/{id} [delete]
|
||||
func Delete(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
id, ok := httpx.ParseID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var link model.NavLink
|
||||
if err := db.WithContext(ctx).First(&link, id).Error; err != nil {
|
||||
httpx.RespondGetError(c, err)
|
||||
return
|
||||
}
|
||||
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("nav_link_id = ?", link.ID).Delete(&model.NavLinkTranslation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&link).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
|
||||
}
|
||||
if !validURL(strings.TrimSpace(req.URL)) {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: url must start with /, http://, https:// or mailto:"})
|
||||
return Request{}, false
|
||||
}
|
||||
if err := validateTranslations(req.Translations); err != nil {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()})
|
||||
return Request{}, false
|
||||
}
|
||||
return req, true
|
||||
}
|
||||
|
||||
// validURL 仅允许站内路径、http(s) 与 mailto,拒绝 javascript: 等危险协议。
|
||||
func validURL(value string) bool {
|
||||
if strings.HasPrefix(value, "/") && !strings.HasPrefix(value, "//") {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range []string{"http://", "https://", "mailto:"} {
|
||||
if strings.HasPrefix(value, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 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)
|
||||
label := strings.TrimSpace(item.Label)
|
||||
if locale == "" || label == "" {
|
||||
return errors.New("translation locale and label are required")
|
||||
}
|
||||
if seen[locale] {
|
||||
return errors.New("duplicate translation locale: " + locale)
|
||||
}
|
||||
seen[locale] = true
|
||||
if len([]rune(label)) > 100 {
|
||||
return errors.New("translation label is too long")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceTranslations 在事务中整体替换链接的翻译。
|
||||
func replaceTranslations(tx *gorm.DB, linkID uint, items []TranslationRequest) error {
|
||||
if err := tx.Where("nav_link_id = ?", linkID).Delete(&model.NavLinkTranslation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
translation := model.NavLinkTranslation{
|
||||
NavLinkID: linkID,
|
||||
Locale: strings.TrimSpace(item.Locale),
|
||||
Label: strings.TrimSpace(item.Label),
|
||||
}
|
||||
if err := tx.Create(&translation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// translationsFor 查询单个链接的多语言文案。
|
||||
func translationsFor(ctx context.Context, db *gorm.DB, linkID uint) ([]model.NavLinkTranslation, error) {
|
||||
translations := make([]model.NavLinkTranslation, 0)
|
||||
if err := db.WithContext(ctx).Where("nav_link_id = ?", linkID).
|
||||
Order("locale ASC").Find(&translations).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return translations, nil
|
||||
}
|
||||
|
||||
// queryLinks 查询链接并按需附带翻译;includeDisabled 为 true 时包含禁用项。
|
||||
func queryLinks(ctx context.Context, db *gorm.DB, includeDisabled bool) ([]model.NavLink, error) {
|
||||
query := db.WithContext(ctx).Order("sort ASC").Order("id ASC")
|
||||
if !includeDisabled {
|
||||
query = query.Where("status = ?", model.NavLinkStatusEnabled)
|
||||
}
|
||||
var links []model.NavLink
|
||||
if err := query.Find(&links).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := attachTranslations(ctx, db, links); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
// attachTranslations 批量填充链接的多语言文案。
|
||||
func attachTranslations(ctx context.Context, db *gorm.DB, links []model.NavLink) error {
|
||||
for i := range links {
|
||||
links[i].Translations = make([]model.NavLinkTranslation, 0)
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]uint, 0, len(links))
|
||||
positions := make(map[uint][]int, len(links))
|
||||
for i := range links {
|
||||
ids = append(ids, links[i].ID)
|
||||
positions[links[i].ID] = append(positions[links[i].ID], i)
|
||||
}
|
||||
|
||||
var translations []model.NavLinkTranslation
|
||||
if err := db.WithContext(ctx).Where("nav_link_id IN ?", ids).
|
||||
Order("locale ASC").Find(&translations).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, translation := range translations {
|
||||
for _, i := range positions[translation.NavLinkID] {
|
||||
links[i].Translations = append(links[i].Translations, translation)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package nav_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"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": "navuser", "email": "navuser@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 decodeLinks(t *testing.T, body []byte) []model.NavLink {
|
||||
t.Helper()
|
||||
var links []model.NavLink
|
||||
if err := json.Unmarshal(body, &links); err != nil {
|
||||
t.Fatalf("解析链接响应失败: %v, body=%s", err, body)
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func createLink(t *testing.T, r http.Handler, payload map[string]any) model.NavLink {
|
||||
t.Helper()
|
||||
w := testutil.Call(t, r, http.MethodPost, "/api/nav-links", payload)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("创建链接状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusCreated, w.Body.String())
|
||||
}
|
||||
var link model.NavLink
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &link); err != nil {
|
||||
t.Fatalf("解析链接失败: %v, body=%s", err, w.Body.String())
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
func labelsOf(link model.NavLink) map[string]string {
|
||||
labels := make(map[string]string, len(link.Translations))
|
||||
for _, translation := range link.Translations {
|
||||
labels[translation.Locale] = translation.Label
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func TestNavLinkCRUD(t *testing.T) {
|
||||
env := testutil.Setup(t)
|
||||
admin := env.AdminRouter()
|
||||
public := env.Router("")
|
||||
|
||||
if w := testutil.Call(t, public, http.MethodGet, "/api/nav-links", nil); w.Code != http.StatusOK {
|
||||
t.Fatalf("公开列表状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
||||
} else if links := decodeLinks(t, w.Body.Bytes()); len(links) != 0 {
|
||||
t.Fatalf("初始链接应为空: %+v", links)
|
||||
}
|
||||
if w := testutil.Call(t, public, http.MethodPost, "/api/nav-links", 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/nav-links", map[string]any{}); w.Code != http.StatusForbidden {
|
||||
t.Errorf("普通用户创建状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
if w := testutil.Call(t, normal, http.MethodGet, "/api/nav-links/list", nil); w.Code != http.StatusForbidden {
|
||||
t.Errorf("普通用户管理列表状态码 = %d, 期望 %d", w.Code, http.StatusForbidden)
|
||||
}
|
||||
|
||||
home := createLink(t, admin, map[string]any{
|
||||
"url": "/", "sort": 20, "status": 1,
|
||||
"translations": []map[string]string{
|
||||
{"locale": "zh-CN", "label": "首页"},
|
||||
{"locale": "en-US", "label": "Home"},
|
||||
},
|
||||
})
|
||||
if labels := labelsOf(home); labels["zh-CN"] != "首页" || labels["en-US"] != "Home" {
|
||||
t.Errorf("创建译文异常: %+v", home.Translations)
|
||||
}
|
||||
if home.OpenInNewWindow || home.Status != int8(1) {
|
||||
t.Errorf("创建字段异常: %+v", home)
|
||||
}
|
||||
|
||||
external := createLink(t, admin, map[string]any{
|
||||
"url": "https://example.com", "sort": 10, "open_in_new_window": true,
|
||||
"translations": []map[string]string{{"locale": "zh-CN", "label": "关于"}},
|
||||
})
|
||||
disabled := createLink(t, admin, map[string]any{
|
||||
"url": "/hidden", "sort": 0, "status": 0,
|
||||
"translations": []map[string]string{{"locale": "zh-CN", "label": "隐藏"}},
|
||||
})
|
||||
|
||||
// 公开列表只返回启用项,按 sort 升序。
|
||||
w := testutil.Call(t, public, http.MethodGet, "/api/nav-links", nil)
|
||||
links := decodeLinks(t, w.Body.Bytes())
|
||||
if len(links) != 2 {
|
||||
t.Fatalf("公开链接数 = %d, 期望 2: %+v", len(links), links)
|
||||
}
|
||||
if links[0].ID != external.ID || links[1].ID != home.ID {
|
||||
t.Errorf("公开排序异常: %d, %d", links[0].ID, links[1].ID)
|
||||
}
|
||||
if !links[0].OpenInNewWindow {
|
||||
t.Errorf("外链应标记新窗口: %+v", links[0])
|
||||
}
|
||||
|
||||
// 管理列表包含禁用项并按 sort 升序。
|
||||
w = testutil.Call(t, admin, http.MethodGet, "/api/nav-links/list", nil)
|
||||
all := decodeLinks(t, w.Body.Bytes())
|
||||
if len(all) != 3 || all[0].ID != disabled.ID || all[1].ID != external.ID || all[2].ID != home.ID {
|
||||
t.Fatalf("管理列表异常: %+v", all)
|
||||
}
|
||||
|
||||
// 更新:整体替换译文并修改字段。
|
||||
w = testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/nav-links/%d", home.ID), map[string]any{
|
||||
"url": "/profile", "sort": 5, "status": 0, "open_in_new_window": true,
|
||||
"translations": []map[string]string{
|
||||
{"locale": "zh-CN", "label": "我的"},
|
||||
{"locale": "en-US", "label": "Profile"},
|
||||
{"locale": "ja-JP", "label": "マイページ"},
|
||||
},
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
updated := model.NavLink{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &updated); err != nil {
|
||||
t.Fatalf("解析更新响应失败: %v", err)
|
||||
}
|
||||
if labels := labelsOf(updated); labels["zh-CN"] != "我的" || labels["ja-JP"] != "マイページ" || len(labels) != 3 {
|
||||
t.Errorf("更新译文异常: %+v", updated.Translations)
|
||||
}
|
||||
if updated.URL != "/profile" || !updated.OpenInNewWindow || updated.Status != int8(0) {
|
||||
t.Errorf("更新字段异常: %+v", updated)
|
||||
}
|
||||
|
||||
var translationCount int64
|
||||
if err := env.DB.Model(&model.NavLinkTranslation{}).Where("nav_link_id = ?", home.ID).Count(&translationCount).Error; err != nil {
|
||||
t.Fatalf("统计译文失败: %v", err)
|
||||
}
|
||||
if translationCount != 3 {
|
||||
t.Errorf("更新后译文数 = %d, 期望 3(旧译文应被替换)", translationCount)
|
||||
}
|
||||
|
||||
// 删除:链接与译文一并删除。
|
||||
if w := testutil.Call(t, admin, http.MethodDelete, fmt.Sprintf("/api/nav-links/%d", external.ID), nil); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("删除状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusNoContent, w.Body.String())
|
||||
}
|
||||
if err := env.DB.First(&model.NavLink{}, external.ID).Error; err == nil {
|
||||
t.Error("链接未删除")
|
||||
}
|
||||
if err := env.DB.Model(&model.NavLinkTranslation{}).Where("nav_link_id = ?", external.ID).Count(&translationCount).Error; err != nil {
|
||||
t.Fatalf("统计译文失败: %v", err)
|
||||
}
|
||||
if translationCount != 0 {
|
||||
t.Errorf("译文未删除: count=%d", translationCount)
|
||||
}
|
||||
if w := testutil.Call(t, admin, http.MethodPut, fmt.Sprintf("/api/nav-links/%d", external.ID), map[string]any{
|
||||
"url": "/", "translations": []map[string]string{{"locale": "zh-CN", "label": "首页"}},
|
||||
}); w.Code != http.StatusNotFound {
|
||||
t.Errorf("删除后更新状态码 = %d, 期望 %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavLinkValidation(t *testing.T) {
|
||||
env := testutil.Setup(t)
|
||||
admin := env.AdminRouter()
|
||||
|
||||
valid := map[string]string{"locale": "zh-CN", "label": "首页"}
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"缺少地址", map[string]any{"translations": []map[string]string{valid}}},
|
||||
{"缺少译文", map[string]any{"url": "/"}},
|
||||
{"空译文数组", map[string]any{"url": "/", "translations": []map[string]string{}}},
|
||||
{"危险协议", map[string]any{"url": "javascript:alert(1)", "translations": []map[string]string{valid}}},
|
||||
{"协议相对地址", map[string]any{"url": "//evil.com", "translations": []map[string]string{valid}}},
|
||||
{"重复语言", map[string]any{"url": "/", "translations": []map[string]string{valid, valid}}},
|
||||
{"空白文案", map[string]any{"url": "/", "translations": []map[string]string{{"locale": "zh-CN", "label": " "}}}},
|
||||
{"文案超长", map[string]any{"url": "/", "translations": []map[string]string{{"locale": "zh-CN", "label": strings.Repeat("字", 101)}}}},
|
||||
{"语言超长", map[string]any{"url": "/", "translations": []map[string]string{{"locale": strings.Repeat("a", 11), "label": "x"}}}},
|
||||
{"状态非法", map[string]any{"url": "/", "status": 2, "translations": []map[string]string{valid}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
w := testutil.Call(t, admin, http.MethodPost, "/api/nav-links", tc.body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
if w := testutil.Call(t, admin, http.MethodPut, "/api/nav-links/abc", map[string]any{
|
||||
"url": "/", "translations": []map[string]string{valid},
|
||||
}); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("非法 id 状态码 = %d, 期望 %d", w.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user