feat(welcome): 站点外观 - 合并欢迎页开放开关与入口分包修复
# Conflicts: # docs/content/docs/progress/pending-test.mdx
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
56
backend/internal/handler/welcome_routes_test.go
Normal file
56
backend/internal/handler/welcome_routes_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 驱动,浏览器断开不会停止运行。
|
||||
|
||||
@@ -5,6 +5,14 @@ description: 当前项目已实现功能的手工验证清单与未确认边界
|
||||
|
||||
# 待测试
|
||||
|
||||
## 欢迎页开放开关
|
||||
|
||||
- 管理员在 `/admin/settings/appearance` 的英文品牌标识下方切换欢迎页,立即保存,沿用功能配置保存及审计流程,默认开启;功能开放页不再编辑该字段。
|
||||
- 欢迎页入口直接导入展示组件,Babel runtime 辅助代码固定独立分包(不按最小体积合并回路由),避免 AntD 反向加载工作台入口触发循环初始化错误。
|
||||
- 已通过前端类型检查和生产构建,并用构建产物在浏览器验证启用展示、关闭跳转、接口失败重试入口;外观页开关位置及真实管理员保存待部署验收。
|
||||
- 待浏览器验收:关闭后访客及已登录用户访问 `/welcome`、`/welcome/` 均跳转首页;重新开启后恢复展示;配置请求失败时显示重试入口,不展示欢迎页内容。
|
||||
- 开关在进入页面时检查,已经打开的欢迎页不主动退出;静态图片、视频资源不受此页面开关限制。
|
||||
|
||||
## 思考模型 Agent 工具选择兼容
|
||||
|
||||
- 当前 Cloud Agent 和首页创造规划入口均使用自动工具选择,不固定发送 `tool_choice=required`。后端仍防御旧任务、旧客户端和其他结构化调用携带的强制选择。
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
65
web/src/pages/admin/settings/components/welcome-setting.tsx
Normal file
65
web/src/pages/admin/settings/components/welcome-setting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
5
web/src/services/api/welcome.ts
Normal file
5
web/src/services/api/welcome.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { http } from "./request";
|
||||
|
||||
export function getWelcomeAvailability() {
|
||||
return http.get<{ welcomeEnabled: boolean }>("/public/welcome");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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))[\\/]/,
|
||||
|
||||
Reference in New Issue
Block a user