diff --git a/docs/docs.go b/docs/docs.go index 35b6ad9..029414b 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": [ diff --git a/docs/swagger.json b/docs/swagger.json index 80141d5..f76eef0 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 89216f2..68052ba 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/frontend/src/App.vue b/frontend/src/App.vue index a1180c1..dd28865 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,10 +1,20 @@ diff --git a/frontend/src/api/site.ts b/frontend/src/api/site.ts new file mode 100644 index 0000000..ca3db05 --- /dev/null +++ b/frontend/src/api/site.ts @@ -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 { + return request('/site') +} + +export function updateSiteInfo(payload: UpdateSitePayload): Promise { + return request('/site', { + method: 'PUT', + body: JSON.stringify(payload), + }) +} diff --git a/frontend/src/components/auth/AuthShell.vue b/frontend/src/components/auth/AuthShell.vue index 9c6a5db..e99b6b0 100644 --- a/frontend/src/components/auth/AuthShell.vue +++ b/frontend/src/components/auth/AuthShell.vue @@ -1,5 +1,8 @@ @@ -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" > - + + R - Rill + {{ site.name }} diff --git a/frontend/src/components/layout/AppHeader.vue b/frontend/src/components/layout/AppHeader.vue index 23a1532..3e34583 100644 --- a/frontend/src/components/layout/AppHeader.vue +++ b/frontend/src/components/layout/AppHeader.vue @@ -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() { - + + R - Rill + {{ site.name }} @@ -102,6 +108,14 @@ function logout() { > {{ t('header.profile') }} + + {{ t('header.admin') }} + { const wasAuthenticated = auth.isAuthenticated auth.logout() diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b9478ea..9357ef4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 diff --git a/frontend/src/stores/site.ts b/frontend/src/stores/site.ts new file mode 100644 index 0000000..2bba40c --- /dev/null +++ b/frontend/src/stores/site.ts @@ -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 { + const info = await updateSiteInfo(payload) + apply(info) + return info + } + + return { siteName, logo, footer, name, hasLogo, load, save } +}) diff --git a/frontend/src/views/AdminSiteView.vue b/frontend/src/views/AdminSiteView.vue new file mode 100644 index 0000000..122cc16 --- /dev/null +++ b/frontend/src/views/AdminSiteView.vue @@ -0,0 +1,187 @@ + + + + + + + {{ t('admin.title') }} + {{ t('admin.subtitle') }} + + + + {{ t('admin.siteInfo') }} + + + + {{ t('admin.siteName') }} + + + + {{ errors.siteName }} + + + + + + {{ t('admin.logo') }} + + + + {{ errors.logo }} + + + {{ t('admin.logoPreview') }} + + + R + + + {{ t('admin.logoHint') }} + + + + + {{ t('admin.footer') }} + + + + {{ errors.footer }} + + + + + {{ submitError }} + + + {{ t('admin.saveSuccess') }} + + + + {{ isSubmitting ? t('common.submitting') : t('admin.save') }} + + + + + diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index a2a7616..6ffe76a 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -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) }} - {{ t('footer.copyright') }} + {{ site.footer || t('footer.copyright') }} diff --git a/internal/api/api.go b/internal/api/api.go index 2c2972e..a41850f 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -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)) diff --git a/internal/database/database_test.go b/internal/database/database_test.go index d2349f8..364e923 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -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 { diff --git a/internal/database/migrate.go b/internal/database/migrate.go index 6a447a8..9423a5e 100644 --- a/internal/database/migrate.go +++ b/internal/database/migrate.go @@ -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 记录已应用的迁移版本。 diff --git a/internal/model/site.go b/internal/model/site.go new file mode 100644 index 0000000..deed425 --- /dev/null +++ b/internal/model/site.go @@ -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" } diff --git a/internal/site/site.go b/internal/site/site.go new file mode 100644 index 0000000..5719c99 --- /dev/null +++ b/internal/site/site.go @@ -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"} +} diff --git a/internal/site/site_test.go b/internal/site/site_test.go new file mode 100644 index 0000000..54c157b --- /dev/null +++ b/internal/site/site_test.go @@ -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) + } +}
{{ t('admin.subtitle') }}
+ {{ errors.siteName }} +
+ {{ errors.logo }} +
{{ t('admin.logoHint') }}
+ {{ errors.footer }} +
+ {{ submitError }} +
+ {{ t('admin.saveSuccess') }} +
{{ t('footer.copyright') }}
{{ site.footer || t('footer.copyright') }}