新增站点信息表与后台管理页面

- 新增 site_settings 单行表(迁移 v7),保存网站名称、Logo 图片地址与页脚版权文案
- 新增 GET /api/site(公开)与 PUT /api/site(仅管理员),空值回退前端默认展示
- 前端头部、登录注册页、浏览器标题与页脚接入站点信息,Pinia store 启动时加载
- 新增 /admin/site 后台管理页面,头像下拉仅管理员显示“后台管理”,路由加 requiresAdmin 守卫
- 补充站点接口、迁移与权限测试,重新生成 Swagger 文档
This commit is contained in:
2026-09-21 16:17:28 +08:00
parent 1fec77e81f
commit 926ad91943
21 files changed
+987 -11

No files matched your search

+130
View File
@@ -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": [
+130
View File
@@ -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": [
+89
View File
@@ -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
View File
@@ -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>
+25
View File
@@ -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),
})
}
+9 -2
View File
@@ -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>
+16 -2
View File
@@ -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"
+24
View File
@@ -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
+24
View File
@@ -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
+24
View File
@@ -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
+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 { 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()
+14 -4
View File
@@ -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
+36
View File
@@ -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 }
})
+187
View File
@@ -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>
+3 -1
View File
@@ -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
View File
@@ -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))
+11
View File
@@ -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 {
+14
View File
@@ -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 记录已应用的迁移版本。
+18
View File
@@ -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" }
+96
View File
@@ -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"}
}
+116
View File
@@ -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)
}
}