feat(welcome): 站点外观 - 合并欢迎页开放开关与入口分包修复

# Conflicts:
#	docs/content/docs/progress/pending-test.mdx
This commit is contained in:
ddCat
2026-09-15 15:19:38 +08:00
16 changed files with 225 additions and 3 deletions

View File

@@ -5,6 +5,7 @@ import (
"testing"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/platform"
"infinite-canvas/backend/internal/protocol"
"infinite-canvas/backend/internal/repository"
@@ -19,6 +20,9 @@ func TestFeatureAvailabilityDefaultsToDisableFrontendModels(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !setting.WelcomeEnabled {
t.Fatal("welcome should be enabled by default")
}
if setting.Configured || !setting.ShortDramaEnabled || !setting.TaskCenterEnabled || !setting.CreditsEnabled || !setting.CustomChannelsEnabled || setting.FrontendModelsEnabled || !setting.PluginCenterEnabled || !setting.SystemPluginsVisibleToUsers {
t.Fatalf("FeatureAvailability() = %#v", setting)
}
@@ -38,6 +42,28 @@ func TestFeatureAvailabilityLegacySettingKeepsCustomChannelsEnabled(t *testing.T
if setting.ShortDramaEnabled || !setting.CustomChannelsEnabled || setting.FrontendModelsEnabled || !setting.PluginCenterEnabled || !setting.SystemPluginsVisibleToUsers {
t.Fatalf("FeatureAvailability() = %#v", setting)
}
if !setting.WelcomeEnabled {
t.Fatal("existing settings without welcomeEnabled should keep welcome enabled")
}
}
func TestWelcomeAvailabilityCanBeDisabledAndReenabled(t *testing.T) {
svc, _ := newFeatureAvailabilityTestService(t)
actor := &model.User{ID: "admin-welcome", Role: model.UserRoleAdmin}
for _, enabled := range []bool{false, true} {
value := platform.DefaultFeatureAvailability()
value.WelcomeEnabled = enabled
if _, err := svc.UpdateFeatureAvailability(actor, value); err != nil {
t.Fatal(err)
}
setting, err := svc.FeatureAvailability()
if err != nil {
t.Fatal(err)
}
if setting.WelcomeEnabled != enabled {
t.Fatalf("welcomeEnabled = %v, want %v", setting.WelcomeEnabled, enabled)
}
}
}
func TestUpdateFeatureAvailabilityPersistsAndAudits(t *testing.T) {

View File

@@ -9,6 +9,16 @@ import (
)
func RegisterFeatureAvailabilityRoutes(r *gin.RouterGroup, svc *service.Service) {
r.GET("/public/welcome", func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
setting, err := svc.FeatureAvailability()
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"welcomeEnabled": setting.WelcomeEnabled})
})
r.GET("/features", func(c *gin.Context) {
if _, err := currentUser(c, svc); err != nil {
failService(c, err)

View File

@@ -24,6 +24,16 @@ tags:
- name: agent
- name: admin
paths:
/public/welcome:
get:
tags: [meta]
security: []
summary: 读取欢迎页开放状态
responses:
"200":
description: 信封 data 仅包含 welcomeEnabled 布尔值,默认 true;Cache-Control 为 no-store
"500":
description: 配置读取失败
/agent/capabilities:
get:
tags: [agent]

View File

@@ -0,0 +1,56 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/repository"
"infinite-canvas/backend/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestWelcomeAvailabilityPublicRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&model.SystemSetting{}); err != nil {
t.Fatal(err)
}
router := gin.New()
RegisterFeatureAvailabilityRoutes(router.Group("/api"), service.New(repository.New(db), t.TempDir()))
for _, enabled := range []bool{true, false, true} {
payload, _ := json.Marshal(map[string]bool{"welcomeEnabled": enabled})
if err := db.Save(&model.SystemSetting{Key: "feature_availability", ValueJSON: string(payload)}).Error; err != nil {
t.Fatal(err)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/public/welcome", nil))
if response.Code != http.StatusOK || response.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("unexpected public response: %d %s", response.Code, response.Body.String())
}
var body struct {
Code int `json:"code"`
Data map[string]bool `json:"data"`
}
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != 0 || len(body.Data) != 1 || body.Data["welcomeEnabled"] != enabled {
t.Fatalf("unexpected public payload: %s", response.Body.String())
}
}
}

View File

@@ -26,6 +26,7 @@ const (
)
type FeatureAvailability struct {
WelcomeEnabled bool `json:"welcomeEnabled"`
ShortDramaEnabled bool `json:"shortDramaEnabled"`
TaskCenterEnabled bool `json:"taskCenterEnabled"`
CreditsEnabled bool `json:"creditsEnabled"`
@@ -46,6 +47,7 @@ type PublicFeatureAvailability struct {
func DefaultFeatureAvailability() FeatureAvailability {
// 缺少配置代表尚未由运维接管;前台模型需要明确配置后才开放。
return FeatureAvailability{
WelcomeEnabled: true,
ShortDramaEnabled: true,
TaskCenterEnabled: true,
CreditsEnabled: true,

View File

@@ -9,6 +9,12 @@ description: 信封、错误码与 OpenAPI
机器可读规范:运行中的 `GET /api/openapi.yaml`,源文件 `backend/internal/handler/openapi.yaml`。前端 TypeScript 仍走 `web/src/services/api` 的 `http`,不从 OpenAPI 生成客户端。
## 欢迎页开放配置
`GET /public/welcome` 无需登录,信封 `data` 仅包含布尔值 `welcomeEnabled`,响应禁用缓存。读取失败按标准错误信封返回。
管理员通过 `GET/PATCH /admin/settings/features` 读取和保存 `welcomeEnabled`,默认 `true`,与其他功能开关一起存入 `system_settings` 的 `feature_availability` JSON,并记录管理员审计。关闭后新访问 `/welcome` 的浏览器跳转到 `/`。
## 云端 Agent
所有 `/agent` 路径都需要登录 Cookie。`GET /agent/capabilities` 返回当前版本(2)、权限模式、上下文范围、技能和工具清单。Agent 运行由服务端任务 Worker 驱动,浏览器断开不会停止运行。

View File

@@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
# 待测试
## 欢迎页开放开关
- 管理员在 `/admin/settings/appearance` 的英文品牌标识下方切换欢迎页,立即保存,沿用功能配置保存及审计流程,默认开启;功能开放页不再编辑该字段。
- 欢迎页入口直接导入展示组件,Babel runtime 辅助代码固定独立分包(不按最小体积合并回路由),避免 AntD 反向加载工作台入口触发循环初始化错误。
- 已通过前端类型检查和生产构建,并用构建产物在浏览器验证启用展示、关闭跳转、接口失败重试入口;外观页开关位置及真实管理员保存待部署验收。
- 待浏览器验收:关闭后访客及已登录用户访问 `/welcome`、`/welcome/` 均跳转首页;重新开启后恢复展示;配置请求失败时显示重试入口,不展示欢迎页内容。
- 开关在进入页面时检查,已经打开的欢迎页不主动退出;静态图片、视频资源不受此页面开关限制。
## 思考模型 Agent 工具选择兼容
- 当前 Cloud Agent 和首页创造规划入口均使用自动工具选择,不固定发送 `tool_choice=required`。后端仍防御旧任务、旧客户端和其他结构化调用携带的强制选择。

View File

@@ -2,6 +2,6 @@ import "@fontsource-variable/inter";
import "@fontsource-variable/jetbrains-mono";
import { bootstrapAppearance } from "@/services/appearance-bootstrap";
// Keep the public film page independent of workspace and appearance requests.
// The public film entry checks its availability independently of workspace bootstrap.
if (/^\/welcome\/?$/.test(window.location.pathname)) void import("./welcome-application");
else void bootstrapAppearance().finally(() => import("./application"));

View File

@@ -368,6 +368,7 @@ function parseFeatureAvailability(value: unknown): FeatureAvailability {
if (typeof record[key] !== "boolean") throw new Error("功能开放配置响应缺少有效开关状态");
}
return {
welcomeEnabled: record.welcomeEnabled as boolean,
shortDramaEnabled: record.shortDramaEnabled as boolean,
taskCenterEnabled: record.taskCenterEnabled as boolean,
creditsEnabled: record.creditsEnabled as boolean,

View File

@@ -9,6 +9,7 @@ import { AdminStatusBadge, SettingsSectionCard } from "@/pages/admin/components/
import { cn } from "@/lib/utils";
import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, normalizeSkinDefinition, type SkinDefinition } from "@/lib/skin-themes";
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting";
import { deleteAdminResources } from "@/services/api/admin-storage";
import { getAdminAppearance, resetAdminAppearance, updateAdminAppearance, uploadAppearanceAsset, type AdminAppearance, type AppearanceAssetSlot } from "@/services/api/appearance";
import { commitPublicAppearance, DEFAULT_PUBLIC_APPEARANCE } from "@/stores/use-appearance-store";
@@ -473,6 +474,7 @@ export default function AppearanceSettingsPage() {
onChange={(event) => setBrandSlug(event.target.value.toLocaleLowerCase().replace(/[^a-z0-9-]/g, ""))}
/>
</Form.Item>
<WelcomeSetting />
</Form>
<div className="admin-appearance-brand-logo admin-appearance-logo-stack">
<AssetPicker

View File

@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";
import { App, Button } from "antd";
import { Switch } from "@/components/ui/base/switch";
import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/services/api/auth";
import { useUserStore } from "@/stores/use-user-store";
export function WelcomeSetting() {
const { message } = App.useApp();
const [enabled, setEnabled] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const [revision, setRevision] = useState(0);
const setFeatures = useUserStore((state) => state.setFeatures);
useEffect(() => {
let active = true;
setError("");
getAdminFeatureAvailability()
.then(({ features }) => {
if (typeof features.welcomeEnabled !== "boolean") throw new Error("欢迎页配置无效");
if (active) setEnabled(features.welcomeEnabled);
})
.catch(() => {
if (active) setError("读取欢迎页状态失败,请重试。");
});
return () => {
active = false;
};
}, [revision]);
async function changeWelcome(value: boolean) {
if (saving) return;
setSaving(true);
try {
const { features } = await updateAdminFeatureAvailability({ welcomeEnabled: value });
if (features.welcomeEnabled !== value) throw new Error("欢迎页状态未保存,请重试");
setEnabled(features.welcomeEnabled);
setFeatures(features);
message.success(value ? "欢迎页已启用" : "欢迎页已关闭");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存欢迎页状态失败");
} finally {
setSaving(false);
}
}
return (
<div className="admin-appearance-logo-frame-option">
<div className="admin-appearance-logo-frame-copy">
<strong>启用欢迎页</strong>
<p>切换后立即保存。关闭后访问 /welcome 将跳转到首页。</p>
{error ? (
<div role="alert">
{error}
<Button onClick={() => setRevision((value) => value + 1)}>重试</Button>
</div>
) : null}
</div>
<div className="admin-appearance-logo-frame-control">
<span>{enabled === null ? "读取中" : saving ? "保存中" : enabled ? "已启用" : "已关闭"}</span>
<Switch aria-label="启用欢迎页" checked={enabled === true} disabled={enabled === null || saving || Boolean(error)} onChange={(value) => void changeWelcome(value)} />
</div>
</div>
);
}

View File

@@ -396,7 +396,7 @@ export function getAdminFeatureAvailability() {
return http.get<{ features: FeatureAvailability }>("/admin/settings/features");
}
export function updateAdminFeatureAvailability(features: Pick<FeatureAvailability, "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers">) {
export function updateAdminFeatureAvailability(features: Partial<Pick<FeatureAvailability, "welcomeEnabled" | "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers">>) {
return http.patch<{ features: FeatureAvailability }>("/admin/settings/features", features);
}

View File

@@ -0,0 +1,5 @@
import { http } from "./request";
export function getWelcomeAvailability() {
return http.get<{ welcomeEnabled: boolean }>("/public/welcome");
}

View File

@@ -25,6 +25,7 @@ export type RuntimeLimits = {
};
export type FeatureAvailability = {
welcomeEnabled: boolean;
shortDramaEnabled: boolean;
taskCenterEnabled: boolean;
creditsEnabled: boolean;
@@ -38,6 +39,7 @@ export type FeatureAvailability = {
};
export const defaultFeatureAvailability: FeatureAvailability = {
welcomeEnabled: true,
shortDramaEnabled: true,
taskCenterEnabled: true,
creditsEnabled: true,

View File

@@ -1,5 +1,26 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { getWelcomeAvailability } from "@/services/api/welcome";
import WelcomePage from "@/pages/welcome";
createRoot(document.getElementById("root")!).render(<StrictMode><WelcomePage /></StrictMode>);
async function renderWelcome() {
try {
const { welcomeEnabled } = await getWelcomeAvailability();
if (welcomeEnabled !== true) {
window.location.replace("/");
return;
}
createRoot(document.getElementById("root")!).render(<StrictMode><WelcomePage /></StrictMode>);
} catch (error) {
console.error("Welcome page initialization failed", error);
createRoot(document.getElementById("root")!).render(
<main role="alert">
<p>暂时无法打开欢迎页,请稍后重试。</p>
<button onClick={() => window.location.reload()}>重试</button>
<a href="/">返回首页</a>
</main>,
);
}
}
void renderWelcome();

View File

@@ -45,6 +45,14 @@ export default defineConfig({
includeDependenciesRecursively: false,
minSize: 40 * 1024,
groups: [
{
// Shared interop helpers must not be emitted into a route entry:
// AntD would import that entry back and execute its bootstrap early.
name: "vendor-babel-runtime",
minSize: 0,
test: /node_modules[\\/]@babel[\\/]runtime[\\/]/,
priority: 40,
},
{
name: "vendor-react",
test: /node_modules[\\/](?:react(?:-dom|-router|-router-dom)?|scheduler|zustand|use-sync-external-store|@tanstack[\\/](?:query-core|react-query))[\\/]/,