新增头部导航链接配置与多语言支持

- 迁移 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:
2026-09-21 21:26:30 +08:00
parent 067ade546f
commit 4449250e97
19 files changed
+2198 -13

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ internal/
├── usergroup/ 用户组管理
├── note/ 便签
├── site/ 站点信息
├── nav/ 头部导航链接
├── file/ 文件上传、删除、查看与本地存储
├── avatar/ 当前用户头像
├── httpx/ HTTP 公共能力:ErrorResponse、分页、ID 解析
+375
View File
@@ -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": {
+375
View File
@@ -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": {
+252
View File
@@ -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,
+49
View File
@@ -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>
+38 -3
View File
@@ -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">
+33 -3
View File
@@ -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',
+33 -3
View File
@@ -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 文字以内で入力してください',
+33 -3
View File
@@ -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 个字符',
+4
View File
@@ -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()
+19
View File
@@ -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 }
})
+3
View File
@@ -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
View File
@@ -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))
+6
View File
@@ -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 表未创建")
}
+7
View File
@@ -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 记录已应用的迁移版本。
+31
View File
@@ -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:"-"`
}
+348
View File
@@ -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
}
+203
View File
@@ -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)
}
}