diff --git a/Makefile b/Makefile index 89884372..cdcb91d9 100644 --- a/Makefile +++ b/Makefile @@ -14,3 +14,6 @@ build-embedded: rm -rf internal/router/dist cp -R frontend/out internal/router/dist go build -tags embed_frontend -o bin/wavelet main.go + +pre-commit: + cd frontend && npx eslint . --max-warnings 0 diff --git a/frontend/app/favicon.ico b/frontend/app/favicon.ico index df85a1c1..ee46155f 100644 Binary files a/frontend/app/favicon.ico and b/frontend/app/favicon.ico differ diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b7f7d774..d36262c5 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -6,6 +6,7 @@ import {BellRingProvider} from "@/contexts/bell-ring-context"; import {NotificationSettingsProvider} from "@/contexts/notification-settings-context"; import {UserProvider} from "@/contexts/user-context"; import {AppQueryProvider} from "@/components/providers/query-provider"; +import {RobotsMeta} from "@/components/layout/robots-meta"; import "./globals.css"; export const metadata: Metadata = { @@ -35,6 +36,7 @@ export default function RootLayout({ > + diff --git a/frontend/components/common/settings/operation-tab.tsx b/frontend/components/common/settings/operation-tab.tsx new file mode 100644 index 00000000..6397d3c6 --- /dev/null +++ b/frontend/components/common/settings/operation-tab.tsx @@ -0,0 +1,84 @@ +"use client" + +import {useMutation, useQueryClient, type UseQueryResult} from "@tanstack/react-query" +import {Globe, Search} from "lucide-react" +import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" +import {Switch} from "@/components/ui/switch" +import {AdminService} from "@/lib/services" +import type {SystemConfig} from "@/lib/services/admin" +import {TemplatesManager} from "./templates" +import {toast} from "sonner" + +interface OperationTabProps { + configs: Record + systemConfigsQuery: UseQueryResult +} + +export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps) { + const queryClient = useQueryClient() + + const updateConfigMutation = useMutation({ + mutationFn: async ({ key, value }: { key: string; value: boolean }) => { + const config = configs[key] + if (!config) { + throw new Error(`缺少配置项: ${key}`) + } + await AdminService.updateSystemConfig(key, { + value: value ? "true" : "false", + description: config.description, + }) + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }) + await queryClient.invalidateQueries({ queryKey: ["public-config"] }) + toast.success("运营配置已更新") + }, + onError: (error: Error) => { + toast.error(error.message || "更新配置失败") + }, + }) + + const indexingEnabled = configs["search_engine_indexing_enabled"]?.value === "true" + + return ( +
+ {/* 搜索引擎检索设置 */} + + +
+
+ +
+
+ SEO 与搜索引擎检索 + 配置站点是否允许被搜索引擎抓取和检索 +
+
+
+ +
+
+
+ + 允许被搜索引擎检索 +
+

+ 默认关闭。关闭后系统将自动下发 noindex 指令并限制 robots.txt,禁止搜索引擎爬虫抓取本站页面。 +

+
+ + updateConfigMutation.mutate({ key: "search_engine_indexing_enabled", value: checked }) + } + /> +
+
+
+ + {/* 通知模板管理 */} + +
+ ) +} diff --git a/frontend/components/common/settings/system-settings.tsx b/frontend/components/common/settings/system-settings.tsx index 4a1c018e..02f946b4 100644 --- a/frontend/components/common/settings/system-settings.tsx +++ b/frontend/components/common/settings/system-settings.tsx @@ -8,7 +8,7 @@ import {motion} from "motion/react" import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs" import {useAuth} from "@/components/providers/auth-provider" -import {TemplatesManager} from "@/components/common/settings/templates" +import {OperationTab} from "./operation-tab" import {AdminService} from "@/lib/services" import type {SystemConfig} from "@/lib/services/admin" import {SystemStatusManager} from "@/components/common/admin/status" @@ -93,7 +93,7 @@ export function SystemSettingsMain() { - + diff --git a/frontend/components/layout/robots-meta.tsx b/frontend/components/layout/robots-meta.tsx new file mode 100644 index 00000000..d1821b3c --- /dev/null +++ b/frontend/components/layout/robots-meta.tsx @@ -0,0 +1,17 @@ +"use client" + +import {usePublicConfig} from "@/hooks/use-public-config" + +export function RobotsMeta() { + const { config } = usePublicConfig() + + // Default to noindex, nofollow if config is loading or if it's explicitly disabled + const enabled = config?.search_engine_indexing_enabled === true + + return ( + + ) +} diff --git a/frontend/lib/services/config/types.ts b/frontend/lib/services/config/types.ts index 504c949c..49c3f52e 100644 --- a/frontend/lib/services/config/types.ts +++ b/frontend/lib/services/config/types.ts @@ -26,4 +26,6 @@ export interface PublicConfigResponse { email_register_verification_enabled: boolean; /** 目录显示配置 */ menu_display_config?: Record; + /** 是否允许搜索引擎检索 */ + search_engine_indexing_enabled?: boolean; } diff --git a/frontend/lib/services/core/config.ts b/frontend/lib/services/core/config.ts index da3c907d..f510edec 100644 --- a/frontend/lib/services/core/config.ts +++ b/frontend/lib/services/core/config.ts @@ -7,6 +7,9 @@ * @returns API 基础 URL */ export function getApiBaseUrl(): string { + if (typeof window === 'undefined') { + return process.env.WAVELET_BACKEND_URL || process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || 'http://localhost:8000'; + } return process.env.NEXT_PUBLIC_WAVELET_BACKEND_URL || ''; } diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 2c2d3e0e..f9b014fc 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -30,6 +30,11 @@ const nextConfig: NextConfig = { source: '/f/:id', destination: `${ backendUrl }/f/:id`, }, + // robots.txt 路由代理到后端动态接口 + { + source: '/robots.txt', + destination: `${ backendUrl }/robots.txt`, + }, // 标准 RESTful API 接口 { source: '/api/:path*', diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index 4040f4ad..67132747 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -146,9 +146,9 @@ func TestListSystemConfigs(t *testing.T) { var configs []model.SystemConfig json.Unmarshal(dataBytes, &configs) - // Defaults seed 22 configurations - if len(configs) != 22 { - t.Errorf("expected 22 default configs, got %d", len(configs)) + // Defaults seed 23 configurations + if len(configs) != 23 { + t.Errorf("expected 23 default configs, got %d", len(configs)) } }) diff --git a/internal/apps/config/routers.go b/internal/apps/config/routers.go index a807911b..582d67ee 100644 --- a/internal/apps/config/routers.go +++ b/internal/apps/config/routers.go @@ -39,6 +39,7 @@ type PublicConfigResponse struct { EmailLoginVerificationEnabled bool `json:"email_login_verification_enabled"` // 是否启用邮箱登录验证 EmailRegisterVerificationEnabled bool `json:"email_register_verification_enabled"` // 是否启用邮箱注册验证 MenuDisplayConfig map[string]bool `json:"menu_display_config"` // 目录显示配置 + SearchEngineIndexingEnabled bool `json:"search_engine_indexing_enabled"` // 是否允许搜索引擎检索 } // GetPublicConfig 获取公共配置 @@ -117,6 +118,11 @@ func GetPublicConfig(c *gin.Context) { emailRegisterVerificationEnabled = val } + var searchEngineIndexingEnabled bool + if val, err := model.GetBoolByKey(ctx, model.ConfigKeySearchEngineIndexingEnabled); err == nil { + searchEngineIndexingEnabled = val + } + menuDisplayConfig, err := model.GetMenuDisplayConfig(ctx) if err != nil { menuDisplayConfig = make(map[string]bool) @@ -135,7 +141,25 @@ func GetPublicConfig(c *gin.Context) { EmailLoginVerificationEnabled: emailLoginVerificationEnabled, EmailRegisterVerificationEnabled: emailRegisterVerificationEnabled, MenuDisplayConfig: menuDisplayConfig, + SearchEngineIndexingEnabled: searchEngineIndexingEnabled, } c.JSON(http.StatusOK, util.OK(response)) } + +// GetRobotsTXT 动态生成 robots.txt +// @Summary 获取 robots.txt +// @Description 根据系统配置决定是否允许搜索引擎检索,并返回相应的 robots.txt 文件内容 +// @Tags config +// @Produce text/plain +// @Success 200 {string} string "robots.txt 内容" +// @Router /robots.txt [get] +func GetRobotsTXT(c *gin.Context) { + ctx := c.Request.Context() + enabled, err := model.GetBoolByKey(ctx, model.ConfigKeySearchEngineIndexingEnabled) + content := "User-Agent: *\nDisallow: /\n" + if err == nil && enabled { + content = "User-Agent: *\nAllow: /\n" + } + c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(content)) +} diff --git a/internal/config/config.go b/internal/config/config.go index e87d523a..53e6a8b2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -100,6 +100,7 @@ func init() { // Disable standard DB/Redis initializations during tests to prevent connection attempts. if isTest() { c.Database.Enabled = false + c.Database.SQLitePath = ":memory:" c.Redis.Enabled = false c.ClickHouse.Enabled = false } diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index cca14bc2..f19d1e98 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -105,6 +105,7 @@ func initSystemConfigs() { ensureConfigKeyExists(model.ConfigKeyEmailLoginVerificationEnabled, "false", "system", "是否开启邮箱登录验证(true/false)") ensureConfigKeyExists(model.ConfigKeyEmailRegisterVerificationEnabled, "false", "system", "是否开启邮箱注册验证(true/false)") ensureConfigKeyExists(model.ConfigKeyMenuDisplayConfig, "{}", "system", "目录显示配置(JSON 字符串,格式为 {url: enabled})") + ensureConfigKeyExists(model.ConfigKeySearchEngineIndexingEnabled, "false", "system", "是否允许搜索引擎爬取/检索该站点(true/false)") return } @@ -241,6 +242,12 @@ func initSystemConfigs() { Type: "system", Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})", }, + { + Key: model.ConfigKeySearchEngineIndexingEnabled, + Value: "false", + Type: "system", + Description: "是否允许搜索引擎爬取/检索该站点(true/false)", + }, } if err := tx.Create(&defaultConfigs).Error; err != nil { diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index eebea932..ba754173 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -55,6 +55,7 @@ const ( ConfigKeyEmailLoginVerificationEnabled = "email_login_verification_enabled" // 是否启用邮箱登录验证 ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证 ConfigKeyMenuDisplayConfig = "menu_display_config" // 目录显示配置 (JSON 字符串) + ConfigKeySearchEngineIndexingEnabled = "search_engine_indexing_enabled" // 是否允许搜索引擎检索 ) const ( diff --git a/internal/router/router.go b/internal/router/router.go index 5797d732..bab4410c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -105,6 +105,9 @@ func Serve() { // Serve files by ID r.GET("/f/:id", upload.ServeFileByID) + // Dynamic robots.txt serving + r.GET("/robots.txt", publicconfig.GetRobotsTXT) + apiGroup := r.Group(config.Config.App.APIPrefix) { if !config.Config.App.IsProduction() { diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go index 15d10750..2e67fd05 100644 --- a/internal/testhelper/test_helper.go +++ b/internal/testhelper/test_helper.go @@ -218,6 +218,12 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) { Type: "system", Description: "目录显示配置(JSON 字符串,格式为 {url: enabled})", }, + { + Key: model.ConfigKeySearchEngineIndexingEnabled, + Value: "false", + Type: "system", + Description: "是否允许搜索引擎检索", + }, } if err := tx.Create(&defaultConfigs).Error; err != nil {