新增站点信息表与后台管理页面
- 新增 site_settings 单行表(迁移 v7),保存网站名称、Logo 图片地址与页脚版权文案 - 新增 GET /api/site(公开)与 PUT /api/site(仅管理员),空值回退前端默认展示 - 前端头部、登录注册页、浏览器标题与页脚接入站点信息,Pinia store 启动时加载 - 新增 /admin/site 后台管理页面,头像下拉仅管理员显示“后台管理”,路由加 requiresAdmin 守卫 - 补充站点接口、迁移与权限测试,重新生成 Swagger 文档
This commit is contained in:
21 files changed
+987
-11
No files matched your search
+130
@@ -569,6 +569,93 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/site": {
|
||||
"get": {
|
||||
"description": "Public site settings: site name, logo URL, and footer text. Returns built-in defaults when the settings row is missing.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"site"
|
||||
],
|
||||
"summary": "Get site settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.SiteSetting"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Admin only. Update the site name, logo URL, and footer text; returns the updated settings.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"site"
|
||||
],
|
||||
"summary": "Update site settings",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Site settings",
|
||||
"name": "site",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/site.UpdateRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.SiteSetting"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user-groups": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1355,6 +1442,26 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.SiteSetting": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"footer": {
|
||||
"type": "string",
|
||||
"example": "Copyright © Rill"
|
||||
},
|
||||
"logo": {
|
||||
"type": "string",
|
||||
"example": "https://example.com/logo.png"
|
||||
},
|
||||
"site_name": {
|
||||
"type": "string",
|
||||
"example": "Rill"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.User": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1461,6 +1568,29 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"site.UpdateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"site_name"
|
||||
],
|
||||
"properties": {
|
||||
"footer": {
|
||||
"type": "string",
|
||||
"maxLength": 1000,
|
||||
"example": "Copyright © Rill"
|
||||
},
|
||||
"logo": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"example": "https://example.com/logo.png"
|
||||
},
|
||||
"site_name": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"example": "Rill"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user.CreateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -562,6 +562,93 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/site": {
|
||||
"get": {
|
||||
"description": "Public site settings: site name, logo URL, and footer text. Returns built-in defaults when the settings row is missing.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"site"
|
||||
],
|
||||
"summary": "Get site settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.SiteSetting"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/httpx.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Admin only. Update the site name, logo URL, and footer text; returns the updated settings.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"site"
|
||||
],
|
||||
"summary": "Update site settings",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Site settings",
|
||||
"name": "site",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/site.UpdateRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/model.SiteSetting"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user-groups": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -1348,6 +1435,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.SiteSetting": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"footer": {
|
||||
"type": "string",
|
||||
"example": "Copyright © Rill"
|
||||
},
|
||||
"logo": {
|
||||
"type": "string",
|
||||
"example": "https://example.com/logo.png"
|
||||
},
|
||||
"site_name": {
|
||||
"type": "string",
|
||||
"example": "Rill"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"model.User": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1454,6 +1561,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"site.UpdateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"site_name"
|
||||
],
|
||||
"properties": {
|
||||
"footer": {
|
||||
"type": "string",
|
||||
"maxLength": 1000,
|
||||
"example": "Copyright © Rill"
|
||||
},
|
||||
"logo": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"example": "https://example.com/logo.png"
|
||||
},
|
||||
"site_name": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"example": "Rill"
|
||||
}
|
||||
}
|
||||
},
|
||||
"user.CreateRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -85,6 +85,20 @@ definitions:
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
model.SiteSetting:
|
||||
properties:
|
||||
footer:
|
||||
example: Copyright © Rill
|
||||
type: string
|
||||
logo:
|
||||
example: https://example.com/logo.png
|
||||
type: string
|
||||
site_name:
|
||||
example: Rill
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
model.User:
|
||||
properties:
|
||||
avatar:
|
||||
@@ -157,6 +171,23 @@ definitions:
|
||||
required:
|
||||
- title
|
||||
type: object
|
||||
site.UpdateRequest:
|
||||
properties:
|
||||
footer:
|
||||
example: Copyright © Rill
|
||||
maxLength: 1000
|
||||
type: string
|
||||
logo:
|
||||
example: https://example.com/logo.png
|
||||
maxLength: 500
|
||||
type: string
|
||||
site_name:
|
||||
example: Rill
|
||||
maxLength: 100
|
||||
type: string
|
||||
required:
|
||||
- site_name
|
||||
type: object
|
||||
user.CreateRequest:
|
||||
properties:
|
||||
avatar:
|
||||
@@ -664,6 +695,64 @@ paths:
|
||||
summary: Update a note
|
||||
tags:
|
||||
- notes
|
||||
/site:
|
||||
get:
|
||||
description: 'Public site settings: site name, logo URL, and footer text. Returns
|
||||
built-in defaults when the settings row is missing.'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/model.SiteSetting'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/httpx.ErrorResponse'
|
||||
summary: Get site settings
|
||||
tags:
|
||||
- site
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Admin only. Update the site name, logo URL, and footer text; returns
|
||||
the updated settings.
|
||||
parameters:
|
||||
- description: Site settings
|
||||
in: body
|
||||
name: site
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/site.UpdateRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/model.SiteSetting'
|
||||
"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: Update site settings
|
||||
tags:
|
||||
- site
|
||||
/user-groups:
|
||||
get:
|
||||
description: List user groups ordered by id ASC. page starts at 1; page_size
|
||||
|
||||
+11
-1
@@ -1,10 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
const route = useRoute()
|
||||
const site = useSiteStore()
|
||||
const isBlank = computed(() => route.meta.blank === true)
|
||||
|
||||
watch(
|
||||
() => site.name,
|
||||
(name) => {
|
||||
document.title = name
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { request } from './http'
|
||||
|
||||
export interface SiteInfo {
|
||||
site_name: string
|
||||
logo: string
|
||||
footer: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface UpdateSitePayload {
|
||||
site_name: string
|
||||
logo: string
|
||||
footer: string
|
||||
}
|
||||
|
||||
export function getSiteInfo(): Promise<SiteInfo> {
|
||||
return request<SiteInfo>('/site')
|
||||
}
|
||||
|
||||
export function updateSiteInfo(payload: UpdateSitePayload): Promise<SiteInfo> {
|
||||
return request<SiteInfo>('/site', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
const site = useSiteStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -9,10 +12,14 @@ import { RouterLink } from 'vue-router'
|
||||
:to="{ name: 'home' }"
|
||||
class="inline-flex items-center gap-2 text-xl font-bold text-accent"
|
||||
>
|
||||
<span class="grid h-8 w-8 place-items-center rounded-lg bg-accent text-sm font-bold text-on-primary">
|
||||
<img v-if="site.hasLogo" :src="site.logo" alt="" class="h-8 w-8 rounded-lg object-cover" />
|
||||
<span
|
||||
v-else
|
||||
class="grid h-8 w-8 place-items-center rounded-lg bg-accent text-sm font-bold text-on-primary"
|
||||
>
|
||||
R
|
||||
</span>
|
||||
<span>Rill</span>
|
||||
<span>{{ site.name }}</span>
|
||||
</RouterLink>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -3,11 +3,13 @@ import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const site = useSiteStore()
|
||||
|
||||
const keyword = ref('')
|
||||
const menuOpen = ref(false)
|
||||
@@ -35,10 +37,14 @@ function logout() {
|
||||
<header class="fixed inset-x-0 top-0 z-50 h-14 border-b border-line bg-surface">
|
||||
<div class="mx-auto flex h-full max-w-[1600px] items-center gap-6 px-4">
|
||||
<a href="/" class="flex shrink-0 items-center gap-2 text-xl font-bold text-accent">
|
||||
<span class="grid h-8 w-8 place-items-center rounded-lg bg-accent text-sm font-bold text-on-primary">
|
||||
<img v-if="site.hasLogo" :src="site.logo" alt="" class="h-8 w-8 rounded-lg object-cover" />
|
||||
<span
|
||||
v-else
|
||||
class="grid h-8 w-8 place-items-center rounded-lg bg-accent text-sm font-bold text-on-primary"
|
||||
>
|
||||
R
|
||||
</span>
|
||||
<span>Rill</span>
|
||||
<span>{{ site.name }}</span>
|
||||
</a>
|
||||
|
||||
<nav class="hidden items-center gap-5 text-sm lg:flex">
|
||||
@@ -102,6 +108,14 @@ function logout() {
|
||||
>
|
||||
{{ t('header.profile') }}
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="auth.isAdmin"
|
||||
:to="{ name: 'admin-site' }"
|
||||
class="block w-full px-4 py-2 text-left text-sm text-content-2 transition-colors hover:bg-page hover:text-primary"
|
||||
@click="menuOpen = false"
|
||||
>
|
||||
{{ t('header.admin') }}
|
||||
</RouterLink>
|
||||
<button
|
||||
type="button"
|
||||
class="block w-full px-4 py-2 text-left text-sm text-content-2 transition-colors hover:bg-page hover:text-primary"
|
||||
|
||||
@@ -11,6 +11,7 @@ const enUS: MessageSchema = {
|
||||
login: 'Log in',
|
||||
userMenu: 'User menu',
|
||||
profile: 'Profile',
|
||||
admin: 'Admin',
|
||||
logout: 'Log out',
|
||||
},
|
||||
home: {
|
||||
@@ -118,6 +119,29 @@ const enUS: MessageSchema = {
|
||||
network: 'Network error, please try again later',
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
title: 'Admin',
|
||||
subtitle: 'Manage the site name, logo, and footer text',
|
||||
siteInfo: 'Site information',
|
||||
siteName: 'Site name',
|
||||
siteNamePlaceholder: 'Shown in the browser title and page header',
|
||||
logo: 'Logo image URL',
|
||||
logoPlaceholder: 'https://example.com/logo.png',
|
||||
logoHint: 'Falls back to the default “R” mark when empty',
|
||||
logoPreview: 'Preview',
|
||||
footer: 'Footer copyright text',
|
||||
footerPlaceholder: 'Falls back to the localized default when empty',
|
||||
save: 'Save changes',
|
||||
saveSuccess: 'Site information updated',
|
||||
errors: {
|
||||
siteNameRequired: 'Site name is required',
|
||||
siteNameLength: 'Site name must be at most 100 characters',
|
||||
logoLength: 'Logo URL must be at most 500 characters',
|
||||
footerLength: 'Footer text must be at most 1000 characters',
|
||||
invalid: 'The submitted information is invalid, please check and retry',
|
||||
network: 'Network error, please try again later',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default enUS
|
||||
@@ -11,6 +11,7 @@ const jaJP: MessageSchema = {
|
||||
login: 'ログイン',
|
||||
userMenu: 'ユーザーメニュー',
|
||||
profile: 'マイページ',
|
||||
admin: '管理画面',
|
||||
logout: 'ログアウト',
|
||||
},
|
||||
home: {
|
||||
@@ -118,6 +119,29 @@ const jaJP: MessageSchema = {
|
||||
network: 'ネットワークエラーが発生しました。後でもう一度お試しください',
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
title: '管理画面',
|
||||
subtitle: 'サイト名・ロゴ・フッター文言を管理します',
|
||||
siteInfo: 'サイト情報',
|
||||
siteName: 'サイト名',
|
||||
siteNamePlaceholder: 'ブラウザのタイトルとページヘッダーに表示されます',
|
||||
logo: 'ロゴ画像 URL',
|
||||
logoPlaceholder: 'https://example.com/logo.png',
|
||||
logoHint: '空欄の場合は既定の「R」マークを表示します',
|
||||
logoPreview: 'プレビュー',
|
||||
footer: 'フッターの著作権表示',
|
||||
footerPlaceholder: '空欄の場合は多言語の既定文案を使用します',
|
||||
save: '変更を保存',
|
||||
saveSuccess: 'サイト情報を更新しました',
|
||||
errors: {
|
||||
siteNameRequired: 'サイト名を入力してください',
|
||||
siteNameLength: 'サイト名は 100 文字以内で入力してください',
|
||||
logoLength: 'ロゴ URL は 500 文字以内で入力してください',
|
||||
footerLength: 'フッター文言は 1000 文字以内で入力してください',
|
||||
invalid: '入力内容が無効です。確認してもう一度お試しください',
|
||||
network: 'ネットワークエラーが発生しました。後でもう一度お試しください',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export default jaJP
|
||||
@@ -9,6 +9,7 @@ const zhCN = {
|
||||
login: '登录',
|
||||
userMenu: '用户菜单',
|
||||
profile: '个人中心',
|
||||
admin: '后台管理',
|
||||
logout: '退出登录',
|
||||
},
|
||||
home: {
|
||||
@@ -116,6 +117,29 @@ const zhCN = {
|
||||
network: '网络异常,请稍后重试',
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
title: '后台管理',
|
||||
subtitle: '维护站点名称、Logo 与页脚文案',
|
||||
siteInfo: '站点信息',
|
||||
siteName: '网站名称',
|
||||
siteNamePlaceholder: '显示在浏览器标题与页面头部',
|
||||
logo: 'Logo 图片地址',
|
||||
logoPlaceholder: 'https://example.com/logo.png',
|
||||
logoHint: '留空时显示默认的 “R” 标识',
|
||||
logoPreview: '预览',
|
||||
footer: '页脚版权文案',
|
||||
footerPlaceholder: '留空时使用多语言默认文案',
|
||||
save: '保存修改',
|
||||
saveSuccess: '站点信息已更新',
|
||||
errors: {
|
||||
siteNameRequired: '请填写网站名称',
|
||||
siteNameLength: '网站名称最多 100 个字符',
|
||||
logoLength: 'Logo 地址最多 500 个字符',
|
||||
footerLength: '页脚文案最多 1000 个字符',
|
||||
invalid: '提交的信息无效,请检查后重试',
|
||||
network: '网络异常,请稍后重试',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export type MessageSchema = typeof zhCN
|
||||
|
||||
@@ -8,6 +8,7 @@ import router from './router'
|
||||
import { i18n } from './i18n'
|
||||
import { setUnauthorizedHandler } from './api/http'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { useSiteStore } from './stores/site'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
@@ -19,6 +20,9 @@ app.use(i18n)
|
||||
const auth = useAuthStore()
|
||||
auth.restore()
|
||||
|
||||
const site = useSiteStore()
|
||||
void site.load()
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
const wasAuthenticated = auth.isAuthenticated
|
||||
auth.logout()
|
||||
|
||||
@@ -6,6 +6,7 @@ declare module 'vue-router' {
|
||||
interface RouteMeta {
|
||||
blank?: boolean
|
||||
requiresAuth?: boolean
|
||||
requiresAdmin?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,18 +36,27 @@ const router = createRouter({
|
||||
component: () => import('../views/ProfileView.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/site',
|
||||
name: 'admin-site',
|
||||
component: () => import('../views/AdminSiteView.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (!to.meta.requiresAuth) {
|
||||
if (!to.meta.requiresAuth && !to.meta.requiresAdmin) {
|
||||
return true
|
||||
}
|
||||
const auth = useAuthStore()
|
||||
if (auth.isAuthenticated) {
|
||||
return true
|
||||
if (!auth.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
if (to.meta.requiresAdmin && !auth.isAdmin) {
|
||||
return { name: 'home' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,36 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { getSiteInfo, updateSiteInfo, type SiteInfo, type UpdateSitePayload } from '@/api/site'
|
||||
|
||||
const DEFAULT_SITE_NAME = 'Rill'
|
||||
|
||||
export const useSiteStore = defineStore('site', () => {
|
||||
const siteName = ref('')
|
||||
const logo = ref('')
|
||||
const footer = ref('')
|
||||
|
||||
const name = computed(() => siteName.value || DEFAULT_SITE_NAME)
|
||||
const hasLogo = computed(() => logo.value !== '')
|
||||
|
||||
function apply(info: SiteInfo) {
|
||||
siteName.value = info.site_name.trim()
|
||||
logo.value = info.logo.trim()
|
||||
footer.value = info.footer.trim()
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
apply(await getSiteInfo())
|
||||
} catch {
|
||||
// 站点信息加载失败时保留默认展示
|
||||
}
|
||||
}
|
||||
|
||||
async function save(payload: UpdateSitePayload): Promise<SiteInfo> {
|
||||
const info = await updateSiteInfo(payload)
|
||||
apply(info)
|
||||
return info
|
||||
}
|
||||
|
||||
return { siteName, logo, footer, name, hasLogo, load, save }
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useForm } from 'vee-validate'
|
||||
import { toTypedSchema } from '@vee-validate/zod'
|
||||
import { z } from 'zod'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
interface SiteForm {
|
||||
siteName: string
|
||||
logo: string
|
||||
footer: string
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const site = useSiteStore()
|
||||
|
||||
const validationSchema = computed(() =>
|
||||
toTypedSchema(
|
||||
z.object({
|
||||
siteName: z
|
||||
.string()
|
||||
.min(1, t('admin.errors.siteNameRequired'))
|
||||
.max(100, t('admin.errors.siteNameLength')),
|
||||
logo: z.string().max(500, t('admin.errors.logoLength')),
|
||||
footer: z.string().max(1000, t('admin.errors.footerLength')),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const { defineField, handleSubmit, errors, isSubmitting, setValues } = useForm<SiteForm>({
|
||||
validationSchema,
|
||||
initialValues: {
|
||||
siteName: site.siteName,
|
||||
logo: site.logo,
|
||||
footer: site.footer,
|
||||
},
|
||||
})
|
||||
|
||||
const [siteName, siteNameProps] = defineField('siteName')
|
||||
const [logo, logoProps] = defineField('logo')
|
||||
const [footer, footerProps] = defineField('footer')
|
||||
|
||||
const submitError = ref('')
|
||||
const saved = ref(false)
|
||||
|
||||
const logoPreview = computed(() => logo.value.trim())
|
||||
|
||||
onMounted(async () => {
|
||||
await site.load()
|
||||
setValues({
|
||||
siteName: site.siteName,
|
||||
logo: site.logo,
|
||||
footer: site.footer,
|
||||
})
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
submitError.value = ''
|
||||
saved.value = false
|
||||
try {
|
||||
await site.save({
|
||||
site_name: values.siteName,
|
||||
logo: values.logo,
|
||||
footer: values.footer,
|
||||
})
|
||||
saved.value = true
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status >= 400 && error.status < 500) {
|
||||
submitError.value = t('admin.errors.invalid')
|
||||
} else {
|
||||
submitError.value = t('admin.errors.network')
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-[1600px] px-4 py-6">
|
||||
<div class="mx-auto max-w-2xl space-y-4">
|
||||
<section class="rounded-2xl bg-surface p-6 shadow-sm ring-1 ring-line">
|
||||
<h1 class="text-xl font-semibold">{{ t('admin.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-content-3">{{ t('admin.subtitle') }}</p>
|
||||
</section>
|
||||
|
||||
<form
|
||||
class="rounded-2xl bg-surface p-6 shadow-sm ring-1 ring-line sm:p-8"
|
||||
novalidate
|
||||
@submit="onSubmit"
|
||||
>
|
||||
<h2 class="text-base font-semibold">{{ t('admin.siteInfo') }}</h2>
|
||||
|
||||
<div class="mt-6">
|
||||
<label for="site-name" class="mb-1.5 block text-sm text-content-2">
|
||||
{{ t('admin.siteName') }}
|
||||
</label>
|
||||
<input
|
||||
id="site-name"
|
||||
v-model="siteName"
|
||||
v-bind="siteNameProps"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
:placeholder="t('admin.siteNamePlaceholder')"
|
||||
class="h-10 w-full rounded-lg border bg-page px-3 text-sm outline-none transition-colors placeholder:text-content-3 focus:border-primary"
|
||||
:class="errors.siteName ? 'border-red-500' : 'border-line'"
|
||||
/>
|
||||
<p v-if="errors.siteName" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{{ errors.siteName }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="site-logo" class="mb-1.5 block text-sm text-content-2">
|
||||
{{ t('admin.logo') }}
|
||||
</label>
|
||||
<input
|
||||
id="site-logo"
|
||||
v-model="logo"
|
||||
v-bind="logoProps"
|
||||
type="url"
|
||||
maxlength="500"
|
||||
:placeholder="t('admin.logoPlaceholder')"
|
||||
class="h-10 w-full rounded-lg border bg-page px-3 text-sm outline-none transition-colors placeholder:text-content-3 focus:border-primary"
|
||||
:class="errors.logo ? 'border-red-500' : 'border-line'"
|
||||
/>
|
||||
<p v-if="errors.logo" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{{ errors.logo }}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<span class="text-xs text-content-3">{{ t('admin.logoPreview') }}</span>
|
||||
<img
|
||||
v-if="logoPreview"
|
||||
:src="logoPreview"
|
||||
alt=""
|
||||
class="h-10 w-10 rounded-lg object-cover ring-1 ring-line"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-10 w-10 place-items-center rounded-lg bg-accent text-sm font-bold text-on-primary"
|
||||
>
|
||||
R
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-content-3">{{ t('admin.logoHint') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<label for="site-footer" class="mb-1.5 block text-sm text-content-2">
|
||||
{{ t('admin.footer') }}
|
||||
</label>
|
||||
<textarea
|
||||
id="site-footer"
|
||||
v-model="footer"
|
||||
v-bind="footerProps"
|
||||
rows="3"
|
||||
maxlength="1000"
|
||||
:placeholder="t('admin.footerPlaceholder')"
|
||||
class="w-full rounded-lg border bg-page px-3 py-2 text-sm outline-none transition-colors placeholder:text-content-3 focus:border-primary"
|
||||
:class="errors.footer ? 'border-red-500' : 'border-line'"
|
||||
/>
|
||||
<p v-if="errors.footer" class="mt-1 text-xs text-red-500 dark:text-red-400">
|
||||
{{ errors.footer }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="submitError"
|
||||
class="mt-4 rounded-lg bg-red-500/10 px-3 py-2 text-xs text-red-500 dark:text-red-400"
|
||||
>
|
||||
{{ submitError }}
|
||||
</p>
|
||||
<p v-else-if="saved" class="mt-4 rounded-lg bg-primary/10 px-3 py-2 text-xs text-primary">
|
||||
{{ t('admin.saveSuccess') }}
|
||||
</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubmitting"
|
||||
class="mt-6 h-10 w-full rounded-full bg-primary text-sm font-medium text-on-primary transition-colors hover:bg-primary-hover disabled:cursor-not-allowed disabled:opacity-60 sm:w-auto sm:px-8"
|
||||
>
|
||||
{{ isSubmitting ? t('common.submitting') : t('admin.save') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -4,8 +4,10 @@ import VideoSection from '@/components/home/VideoSection.vue'
|
||||
import LanguageSwitcher from '@/components/layout/LanguageSwitcher.vue'
|
||||
import ThemeSwitcher from '@/components/layout/ThemeSwitcher.vue'
|
||||
import { hotVideos, recommendVideos } from '@/mock/home'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
const { t } = useI18n()
|
||||
const site = useSiteStore()
|
||||
|
||||
const recommendTabs = [
|
||||
'category.all',
|
||||
@@ -43,7 +45,7 @@ const footerLinkKeys = [
|
||||
{{ t(key) }}
|
||||
</a>
|
||||
</nav>
|
||||
<p>{{ t('footer.copyright') }}</p>
|
||||
<p>{{ site.footer || t('footer.copyright') }}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
+6
-1
@@ -14,11 +14,12 @@ import (
|
||||
"rill/internal/config"
|
||||
"rill/internal/database"
|
||||
"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)
|
||||
|
||||
@@ -38,6 +39,8 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
authGroup.POST("/login", auth.Login(db, authn))
|
||||
}
|
||||
|
||||
rg.GET("/site", site.Get(db))
|
||||
|
||||
authed := rg.Group("", authn.RequireAuth(db))
|
||||
{
|
||||
authed.GET("/me", auth.Me())
|
||||
@@ -55,6 +58,8 @@ func RegisterRoutes(rg *gin.RouterGroup, db *gorm.DB, cfg *config.Config) {
|
||||
|
||||
admin := rg.Group("", authn.RequireAuth(db), auth.RequireAdmin())
|
||||
{
|
||||
admin.PUT("/site", site.Update(db))
|
||||
|
||||
users := admin.Group("/users")
|
||||
{
|
||||
users.GET("", user.List(db))
|
||||
|
||||
@@ -76,6 +76,9 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) {
|
||||
if !db.Migrator().HasTable(&model.UserGroup{}) {
|
||||
t.Error("user_groups 表未创建")
|
||||
}
|
||||
if !db.Migrator().HasTable(&model.SiteSetting{}) {
|
||||
t.Error("site_settings 表未创建")
|
||||
}
|
||||
if !db.Migrator().HasTable(&schemaMigration{}) {
|
||||
t.Error("schema_migrations 表未创建")
|
||||
}
|
||||
@@ -122,6 +125,14 @@ func TestMigrateIdempotentAndCRUD(t *testing.T) {
|
||||
t.Errorf("初始管理员未加入 admin 组: count=%d", membership)
|
||||
}
|
||||
|
||||
var setting model.SiteSetting
|
||||
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
|
||||
t.Fatalf("默认站点信息未创建: %v", err)
|
||||
}
|
||||
if setting.SiteName != "Rill" || setting.Logo != "" || setting.Footer != "" {
|
||||
t.Errorf("默认站点信息异常: %+v", setting)
|
||||
}
|
||||
|
||||
passwordFile := adminPasswordPath(db)
|
||||
raw, err := os.ReadFile(passwordFile)
|
||||
if err != nil {
|
||||
|
||||
@@ -90,6 +90,20 @@ var migrations = []Migration{
|
||||
return tx.AutoMigrate(&model.User{})
|
||||
},
|
||||
},
|
||||
{
|
||||
Version: 7,
|
||||
Name: "create_site_settings",
|
||||
Up: func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&model.SiteSetting{}); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
return tx.Exec(
|
||||
"INSERT INTO site_settings (id, site_name, logo, footer, created_at, updated_at) VALUES (?, ?, '', '', ?, ?)",
|
||||
model.SiteSettingID, "Rill", now, now,
|
||||
).Error
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// schemaMigration 记录已应用的迁移版本。
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// SiteSettingID 站点设置的固定单行主键。
|
||||
const SiteSettingID uint = 1
|
||||
|
||||
// SiteSetting 站点信息,全站仅一行,字段为空时前端回退默认展示。
|
||||
type SiteSetting struct {
|
||||
ID uint `gorm:"primaryKey" json:"-"`
|
||||
SiteName string `gorm:"size:100;not null;default:''" json:"site_name" example:"Rill"`
|
||||
Logo string `gorm:"size:500" json:"logo" example:"https://example.com/logo.png"`
|
||||
Footer string `gorm:"size:1000" json:"footer" example:"Copyright © Rill"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (SiteSetting) TableName() string { return "site_settings" }
|
||||
@@ -0,0 +1,96 @@
|
||||
// Package site 提供站点信息(站名、Logo、页脚文案)的读取与管理员更新接口。
|
||||
package site
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"rill/internal/httpx"
|
||||
"rill/internal/model"
|
||||
)
|
||||
|
||||
// UpdateRequest 更新站点信息请求,空 logo/footer 表示回退前端默认展示。
|
||||
type UpdateRequest struct {
|
||||
SiteName string `json:"site_name" binding:"required,max=100" example:"Rill"`
|
||||
Logo string `json:"logo" binding:"omitempty,max=500" example:"https://example.com/logo.png"`
|
||||
Footer string `json:"footer" binding:"omitempty,max=1000" example:"Copyright © Rill"`
|
||||
}
|
||||
|
||||
// @Summary Get site settings
|
||||
// @Description Public site settings: site name, logo URL, and footer text. Returns built-in defaults when the settings row is missing.
|
||||
// @Tags site
|
||||
// @Produce json
|
||||
// @Success 200 {object} model.SiteSetting
|
||||
// @Failure 500 {object} httpx.ErrorResponse
|
||||
// @Router /site [get]
|
||||
func Get(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
var setting model.SiteSetting
|
||||
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusOK, defaultSetting())
|
||||
return
|
||||
}
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, setting)
|
||||
}
|
||||
}
|
||||
|
||||
// @Summary Update site settings
|
||||
// @Description Admin only. Update the site name, logo URL, and footer text; returns the updated settings.
|
||||
// @Tags site
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param site body site.UpdateRequest true "Site settings"
|
||||
// @Success 200 {object} model.SiteSetting
|
||||
// @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 /site [put]
|
||||
func Update(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req UpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
req.SiteName = strings.TrimSpace(req.SiteName)
|
||||
if req.SiteName == "" {
|
||||
c.JSON(http.StatusBadRequest, httpx.ErrorResponse{Error: "invalid request: site_name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
var setting model.SiteSetting
|
||||
if err := db.WithContext(ctx).First(&setting, model.SiteSettingID).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
setting = defaultSetting()
|
||||
}
|
||||
|
||||
setting.SiteName = req.SiteName
|
||||
setting.Logo = req.Logo
|
||||
setting.Footer = req.Footer
|
||||
if err := db.WithContext(ctx).Save(&setting).Error; err != nil {
|
||||
httpx.RespondDBError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, setting)
|
||||
}
|
||||
}
|
||||
|
||||
// defaultSetting 设置行缺失时的内置默认值。
|
||||
func defaultSetting() model.SiteSetting {
|
||||
return model.SiteSetting{ID: model.SiteSettingID, SiteName: "Rill"}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package site_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"rill/internal/model"
|
||||
"rill/internal/testutil"
|
||||
)
|
||||
|
||||
func decodeSetting(t *testing.T, body []byte) model.SiteSetting {
|
||||
t.Helper()
|
||||
var setting model.SiteSetting
|
||||
if err := json.Unmarshal(body, &setting); err != nil {
|
||||
t.Fatalf("解析站点设置响应失败: %v, body=%s", err, body)
|
||||
}
|
||||
return setting
|
||||
}
|
||||
|
||||
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": "siteuser", "email": "siteuser@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 TestSiteSettings(t *testing.T) {
|
||||
env := testutil.Setup(t)
|
||||
public := env.Router("")
|
||||
|
||||
w := testutil.Call(t, public, http.MethodGet, "/api/site", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("读取站点信息状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
seeded := decodeSetting(t, w.Body.Bytes())
|
||||
if seeded.SiteName != "Rill" || seeded.Logo != "" || seeded.Footer != "" {
|
||||
t.Fatalf("默认站点信息异常: %+v", seeded)
|
||||
}
|
||||
|
||||
if w := testutil.Call(t, public, http.MethodPut, "/api/site", map[string]any{"site_name": "Hacked"}); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("匿名更新状态码 = %d, 期望 %d", w.Code, http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
registered := registerUser(t, env)
|
||||
normal := env.Router(env.Sign(registered.ID))
|
||||
if w := testutil.Call(t, normal, http.MethodPut, "/api/site", map[string]any{"site_name": "Hacked"}); w.Code != http.StatusForbidden {
|
||||
t.Errorf("普通用户更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusForbidden, w.Body.String())
|
||||
}
|
||||
|
||||
admin := env.AdminRouter()
|
||||
w = testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{
|
||||
"site_name": "Rill 流媒体",
|
||||
"logo": "https://example.com/logo.png",
|
||||
"footer": "Copyright © 2026 Rill",
|
||||
})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("管理员更新状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
updated := decodeSetting(t, w.Body.Bytes())
|
||||
if updated.SiteName != "Rill 流媒体" || updated.Logo != "https://example.com/logo.png" || updated.Footer != "Copyright © 2026 Rill" {
|
||||
t.Fatalf("更新结果异常: %+v", updated)
|
||||
}
|
||||
|
||||
w = testutil.Call(t, public, http.MethodGet, "/api/site", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("更新后读取状态码 = %d, 期望 %d", w.Code, http.StatusOK)
|
||||
}
|
||||
if got := decodeSetting(t, w.Body.Bytes()); got.SiteName != updated.SiteName || got.Logo != updated.Logo || got.Footer != updated.Footer {
|
||||
t.Errorf("更新后读取结果异常: %+v", got)
|
||||
}
|
||||
|
||||
var stored model.SiteSetting
|
||||
if err := env.DB.First(&stored, model.SiteSettingID).Error; err != nil {
|
||||
t.Fatalf("查询数据库失败: %v", err)
|
||||
}
|
||||
if stored.SiteName != updated.SiteName || stored.Logo != updated.Logo || stored.Footer != updated.Footer {
|
||||
t.Errorf("数据库未更新: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteSettingsValidation(t *testing.T) {
|
||||
env := testutil.Setup(t)
|
||||
admin := env.AdminRouter()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
}{
|
||||
{"缺少站名", map[string]any{"logo": "https://example.com/logo.png"}},
|
||||
{"站名为空白", map[string]any{"site_name": " "}},
|
||||
{"站名超长", map[string]any{"site_name": strings.Repeat("站", 101)}},
|
||||
{"Logo 超长", map[string]any{"site_name": "Rill", "logo": strings.Repeat("a", 501)}},
|
||||
{"Footer 超长", map[string]any{"site_name": "Rill", "footer": strings.Repeat("a", 1001)}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
w := testutil.Call(t, admin, http.MethodPut, "/api/site", tc.body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s 状态码 = %d, 期望 %d, body=%s", tc.name, w.Code, http.StatusBadRequest, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
w := testutil.Call(t, admin, http.MethodPut, "/api/site", map[string]any{"site_name": "Rill", "logo": "", "footer": ""})
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("清空可选字段状态码 = %d, 期望 %d, body=%s", w.Code, http.StatusOK, w.Body.String())
|
||||
}
|
||||
cleared := decodeSetting(t, w.Body.Bytes())
|
||||
if cleared.Logo != "" || cleared.Footer != "" {
|
||||
t.Errorf("清空失败: %+v", cleared)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user