feat: add configurable platform appearance (#385)
This commit is contained in:
1 parent
6d0e7a5228
commit
7444789034
31 files changed
+2795
-153
No files matched your search
@@ -20,6 +20,7 @@ import {
|
||||
Mail,
|
||||
MessageSquareText,
|
||||
Moon,
|
||||
Palette,
|
||||
Paintbrush,
|
||||
PlugZap,
|
||||
RadioTower,
|
||||
@@ -36,6 +37,7 @@ import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Link, NavLink, Outlet, useLocation } from "react-router";
|
||||
|
||||
import { AppChangelogButton } from "@/components/layout/app-changelog-modal";
|
||||
import { BrandLogoFrame } from "@/components/brand/brand-logo";
|
||||
import { WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { publishWorkspaceSidebarCollapsed, readWorkspaceSidebarCollapsed, subscribeWorkspaceSidebarCollapsed } from "@/components/layout/workspace-sidebar-state";
|
||||
import { getAdminAntThemeConfig } from "@/lib/app-theme";
|
||||
@@ -43,6 +45,7 @@ import { cn } from "@/lib/utils";
|
||||
import "@/styles/admin-ui.css";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
|
||||
type AdminNavigationItem = {
|
||||
path: string;
|
||||
@@ -81,6 +84,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
|
||||
{
|
||||
label: "系统配置",
|
||||
items: [
|
||||
{ path: "/admin/settings/appearance", label: "外观管理", description: "品牌、Logo 与登录影片", icon: <Palette className="size-4" /> },
|
||||
{ path: "/admin/settings/features", label: "功能开放", description: "工作台、插件与模型能力", icon: <ToggleLeft className="size-4" /> },
|
||||
{ path: "/admin/settings/drawing-engine", label: "绘图工具", description: "画布绘图节点默认引擎", icon: <Paintbrush className="size-4" /> },
|
||||
{ path: "/admin/settings/runtime-policy", label: "资源与策略", description: "配额、并发、频控与超时", icon: <Settings2 className="size-4" /> },
|
||||
@@ -103,6 +107,7 @@ function isAdminNavigationPath(pathname: string, navigationPath: string) {
|
||||
}
|
||||
|
||||
export function AdminShell() {
|
||||
const appearance = useAppearanceStore((state) => state.appearance);
|
||||
const [collapsed, setCollapsed] = useState(readWorkspaceSidebarCollapsed);
|
||||
const dark = useThemeStore((state) => state.theme === "dark");
|
||||
|
||||
@@ -129,12 +134,8 @@ export function AdminShell() {
|
||||
<Tooltip mouseEnterDelay={0.1} title={collapsed ? "查看更新日志" : undefined} placement="right" rootClassName="app-workspace-sidebar-tooltip">
|
||||
<AppChangelogButton
|
||||
className={cn("admin-sidebar-brand-button", collapsed && "is-collapsed")}
|
||||
icon={
|
||||
<span className="admin-sidebar-brand-mark grid shrink-0 place-items-center bg-foreground text-background">
|
||||
<InfinityIcon className="size-4" />
|
||||
</span>
|
||||
}
|
||||
label="影策"
|
||||
icon={<BrandLogoFrame className="admin-sidebar-brand-mark grid shrink-0 place-items-center bg-foreground text-background" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" />} />}
|
||||
label={appearance.brandName}
|
||||
showLabel={!collapsed}
|
||||
showVersion={!collapsed}
|
||||
labelClassName="admin-sidebar-brand-title"
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
import { App, Button, Form, Input, Skeleton, Switch } from "antd";
|
||||
import { Image as ImageIcon, MonitorPlay, Moon, Palette, RefreshCw, RotateCcw, Save, Sun, Type, Undo2, Upload } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { useBlocker } from "react-router";
|
||||
|
||||
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
|
||||
import { AdminStatusBadge, SettingsSectionCard } from "@/pages/admin/components/admin-ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
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";
|
||||
|
||||
type DraftFiles = Record<AppearanceAssetSlot, File | null>;
|
||||
type ResetState = Record<AppearanceAssetSlot, boolean>;
|
||||
|
||||
const EMPTY_FILES: DraftFiles = { logo: null, "logo-dark": null, video: null, poster: null };
|
||||
const EMPTY_RESETS: ResetState = { logo: false, "logo-dark": false, video: false, poster: false };
|
||||
const FILE_RULES: Record<AppearanceAssetSlot, { accept: string; maxBytes: number; label: string }> = {
|
||||
logo: { accept: "image/png,image/jpeg,image/webp", maxBytes: 5 << 20, label: "浅色模式 Logo" },
|
||||
"logo-dark": { accept: "image/png,image/jpeg,image/webp", maxBytes: 5 << 20, label: "深色模式 Logo" },
|
||||
poster: { accept: "image/png,image/jpeg,image/webp", maxBytes: 10 << 20, label: "视频封面" },
|
||||
video: { accept: "video/mp4,video/webm", maxBytes: 256 << 20, label: "品牌视频" },
|
||||
};
|
||||
|
||||
export default function AppearanceSettingsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [setting, setSetting] = useState<AdminAppearance | null>(null);
|
||||
const [brandName, setBrandName] = useState("");
|
||||
const [brandSlug, setBrandSlug] = useState("");
|
||||
const [authHeroTitle, setAuthHeroTitle] = useState("");
|
||||
const [authHeroDescription, setAuthHeroDescription] = useState("");
|
||||
const [logoFrameEnabled, setLogoFrameEnabled] = useState(true);
|
||||
const [files, setFiles] = useState<DraftFiles>(EMPTY_FILES);
|
||||
const [resets, setResets] = useState<ResetState>(EMPTY_RESETS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
const [loadError, setLoadError] = useState("");
|
||||
const requestVersionRef = useRef(0);
|
||||
const inputRefs = {
|
||||
logo: useRef<HTMLInputElement>(null),
|
||||
"logo-dark": useRef<HTMLInputElement>(null),
|
||||
video: useRef<HTMLInputElement>(null),
|
||||
poster: useRef<HTMLInputElement>(null),
|
||||
};
|
||||
|
||||
const dirty =
|
||||
Boolean(setting) &&
|
||||
(brandName.trim() !== setting?.brandName ||
|
||||
brandSlug.trim().toLocaleLowerCase() !== setting?.brandSlug ||
|
||||
normalizeDraftCopy(authHeroTitle) !== setting?.authHeroTitle ||
|
||||
normalizeDraftCopy(authHeroDescription) !== setting?.authHeroDescription ||
|
||||
logoFrameEnabled !== setting?.logoFrameEnabled ||
|
||||
Object.values(files).some(Boolean) ||
|
||||
Object.values(resets).some(Boolean));
|
||||
const blocker = useBlocker(dirty && !saving && !restoring);
|
||||
|
||||
const applySetting = useCallback((value: AdminAppearance) => {
|
||||
setSetting(value);
|
||||
setBrandName(value.brandName);
|
||||
setBrandSlug(value.brandSlug);
|
||||
setAuthHeroTitle(value.authHeroTitle);
|
||||
setAuthHeroDescription(value.authHeroDescription);
|
||||
setLogoFrameEnabled(value.logoFrameEnabled);
|
||||
setFiles(EMPTY_FILES);
|
||||
setResets(EMPTY_RESETS);
|
||||
setLoadError("");
|
||||
}, []);
|
||||
|
||||
const load = useCallback(
|
||||
async (initial = false) => {
|
||||
const requestVersion = ++requestVersionRef.current;
|
||||
if (initial) setLoading(true);
|
||||
else setRefreshing(true);
|
||||
try {
|
||||
const value = await getAdminAppearance();
|
||||
if (requestVersion === requestVersionRef.current) applySetting(value);
|
||||
} catch (error) {
|
||||
if (requestVersion !== requestVersionRef.current) return;
|
||||
const detail = error instanceof Error ? error.message : "读取外观配置失败";
|
||||
setLoadError(detail);
|
||||
if (!initial) message.error(detail);
|
||||
} finally {
|
||||
if (requestVersion === requestVersionRef.current) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[applySetting, message],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void load(true);
|
||||
return () => {
|
||||
requestVersionRef.current += 1;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (blocker.state !== "blocked") return;
|
||||
modal.confirm({
|
||||
title: "放弃外观调整?",
|
||||
content: "当前品牌信息、登录页文案或媒体文件尚未保存,离开后草稿会丢失。线上外观不会改变。",
|
||||
okText: "放弃并离开",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => blocker.proceed(),
|
||||
onCancel: () => blocker.reset(),
|
||||
});
|
||||
}, [blocker, modal]);
|
||||
|
||||
useEffect(() => {
|
||||
const beforeUnload = (event: BeforeUnloadEvent) => {
|
||||
if (!dirty || saving || restoring) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener("beforeunload", beforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", beforeUnload);
|
||||
}, [dirty, restoring, saving]);
|
||||
|
||||
const selectFile = (slot: AppearanceAssetSlot, file?: File) => {
|
||||
if (!file) return;
|
||||
const rule = FILE_RULES[slot];
|
||||
if (!rule.accept.split(",").includes(file.type) || file.size <= 0 || file.size > rule.maxBytes) {
|
||||
message.error(`${rule.label}须使用支持的格式,且不超过 ${rule.maxBytes >> 20}MB`);
|
||||
if (inputRefs[slot].current) inputRefs[slot].current.value = "";
|
||||
return;
|
||||
}
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[slot]: file,
|
||||
...(slot === "video" ? { poster: null } : {}),
|
||||
}));
|
||||
setResets((current) => ({
|
||||
...current,
|
||||
[slot]: false,
|
||||
...(slot === "video" ? { poster: true } : {}),
|
||||
}));
|
||||
if (slot === "video" && inputRefs.poster.current) inputRefs.poster.current.value = "";
|
||||
};
|
||||
|
||||
const resetAsset = (slot: AppearanceAssetSlot) => {
|
||||
setFiles((current) => ({
|
||||
...current,
|
||||
[slot]: null,
|
||||
...(slot === "video" ? { poster: null } : {}),
|
||||
}));
|
||||
setResets((current) => ({
|
||||
...current,
|
||||
[slot]: true,
|
||||
...(slot === "video" ? { poster: true } : {}),
|
||||
}));
|
||||
if (inputRefs[slot].current) inputRefs[slot].current.value = "";
|
||||
if (slot === "video" && inputRefs.poster.current) inputRefs.poster.current.value = "";
|
||||
};
|
||||
|
||||
const discardDraft = () => {
|
||||
if (!setting || saving || restoring) return;
|
||||
setBrandName(setting.brandName);
|
||||
setBrandSlug(setting.brandSlug);
|
||||
setAuthHeroTitle(setting.authHeroTitle);
|
||||
setAuthHeroDescription(setting.authHeroDescription);
|
||||
setLogoFrameEnabled(setting.logoFrameEnabled);
|
||||
setFiles(EMPTY_FILES);
|
||||
setResets(EMPTY_RESETS);
|
||||
Object.values(inputRefs).forEach((ref) => {
|
||||
if (ref.current) ref.current.value = "";
|
||||
});
|
||||
message.info("已撤销未保存的外观调整");
|
||||
};
|
||||
|
||||
const requestRefresh = () => {
|
||||
if (!dirty) {
|
||||
void load(false);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: "放弃调整并重新读取?",
|
||||
content: "重新读取会丢弃当前品牌信息、登录页文案和待上传文件。",
|
||||
okText: "放弃并刷新",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => load(false),
|
||||
});
|
||||
};
|
||||
|
||||
const restoreBuiltInAppearance = () => {
|
||||
if (!setting?.configured || saving || refreshing || restoring) return;
|
||||
modal.confirm({
|
||||
title: "恢复影策默认品牌标识?",
|
||||
content: "品牌名称、英文标识、Logo、登录页文案、视频、封面和皮肤配置会立即恢复为项目内置值。已上传文件仍保留在存储资源中,不会被删除。",
|
||||
okText: "恢复默认",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
setRestoring(true);
|
||||
requestVersionRef.current += 1;
|
||||
try {
|
||||
const restored = await resetAdminAppearance();
|
||||
applySetting(restored);
|
||||
commitPublicAppearance(restored.public);
|
||||
Object.values(inputRefs).forEach((ref) => {
|
||||
if (ref.current) ref.current.value = "";
|
||||
});
|
||||
message.success("已恢复影策默认品牌标识");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "恢复默认外观失败");
|
||||
throw error;
|
||||
} finally {
|
||||
setRestoring(false);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!setting || saving || restoring) return;
|
||||
const nextBrandName = brandName.trim();
|
||||
const nextBrandSlug = brandSlug.trim().toLocaleLowerCase();
|
||||
const nextAuthHeroTitle = normalizeDraftCopy(authHeroTitle);
|
||||
const nextAuthHeroDescription = normalizeDraftCopy(authHeroDescription);
|
||||
if (!nextBrandName || Array.from(nextBrandName).length > 40) {
|
||||
message.error("品牌名称必须为 1 到 40 个字符");
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/.test(nextBrandSlug)) {
|
||||
message.error("英文品牌标识须为 1 到 48 位小写字母、数字或连字符");
|
||||
return;
|
||||
}
|
||||
if (!nextAuthHeroTitle || Array.from(nextAuthHeroTitle).length > 80 || hasUnsupportedControlCharacter(nextAuthHeroTitle)) {
|
||||
message.error("登录页主标题必须为 1 到 80 个字符,可使用换行");
|
||||
return;
|
||||
}
|
||||
if (Array.from(nextAuthHeroDescription).length > 160 || hasUnsupportedControlCharacter(nextAuthHeroDescription)) {
|
||||
message.error("登录页说明文案不能超过 160 个字符,可使用换行");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const uploadedIDs: string[] = [];
|
||||
try {
|
||||
const ids: Record<AppearanceAssetSlot, string> = {
|
||||
logo: resets.logo ? "" : setting.logoResourceId,
|
||||
"logo-dark": resets["logo-dark"] ? "" : setting.darkLogoResourceId,
|
||||
video: resets.video ? "" : setting.authVideoResourceId,
|
||||
poster: resets.poster ? "" : setting.authVideoPosterResourceId,
|
||||
};
|
||||
for (const slot of ["logo", "logo-dark", "video", "poster"] as AppearanceAssetSlot[]) {
|
||||
const file = files[slot];
|
||||
if (!file) continue;
|
||||
const resource = await uploadAppearanceAsset(slot, file);
|
||||
uploadedIDs.push(resource.id);
|
||||
ids[slot] = resource.id;
|
||||
}
|
||||
const updated = await updateAdminAppearance({
|
||||
brandName: nextBrandName,
|
||||
brandSlug: nextBrandSlug,
|
||||
authHeroTitle: nextAuthHeroTitle,
|
||||
authHeroDescription: nextAuthHeroDescription,
|
||||
logoResourceId: ids.logo,
|
||||
darkLogoResourceId: ids["logo-dark"],
|
||||
logoFrameEnabled,
|
||||
authVideoResourceId: ids.video,
|
||||
authVideoPosterResourceId: ids.poster,
|
||||
skinId: setting.skinId,
|
||||
});
|
||||
applySetting(updated);
|
||||
commitPublicAppearance(updated.public);
|
||||
Object.values(inputRefs).forEach((ref) => {
|
||||
if (ref.current) ref.current.value = "";
|
||||
});
|
||||
message.success("外观配置已保存并立即生效");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "保存外观配置失败";
|
||||
if (uploadedIDs.length) {
|
||||
try {
|
||||
await deleteAdminResources(uploadedIDs);
|
||||
} catch {
|
||||
message.warning("保存失败,且临时上传文件未能自动清理;可在存储资源中检查");
|
||||
}
|
||||
}
|
||||
message.error(detail);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previews = useAppearancePreviews(setting, files, resets);
|
||||
const lightLogoSelected = Boolean(files.logo || (!resets.logo && setting?.logoResourceId));
|
||||
const darkLogoSelected = Boolean(files["logo-dark"] || (!resets["logo-dark"] && setting?.darkLogoResourceId));
|
||||
const status = setting?.configured ? <AdminStatusBadge label="已自定义" tone="success" /> : <AdminStatusBadge label="使用原始外观" tone="neutral" />;
|
||||
const copyCustomized = normalizeDraftCopy(authHeroTitle) !== DEFAULT_PUBLIC_APPEARANCE.authHeroTitle || normalizeDraftCopy(authHeroDescription) !== DEFAULT_PUBLIC_APPEARANCE.authHeroDescription;
|
||||
|
||||
return (
|
||||
<AdminPageFrame title="外观管理" description="统一设置平台品牌、登录页内容与媒体,并为后续皮肤库保留扩展入口" scroll>
|
||||
{loading ? (
|
||||
<AppearanceSkeleton />
|
||||
) : loadError || !setting ? (
|
||||
<div className="admin-settings-stack admin-appearance-settings">
|
||||
<div className="admin-appearance-load-error" role="alert">
|
||||
<span className="admin-appearance-load-error-icon">
|
||||
<Palette className="size-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<h2>无法读取外观配置</h2>
|
||||
<p>{loadError || "当前没有可显示的配置,请稍后重试。"}</p>
|
||||
</div>
|
||||
<Button icon={<RefreshCw className="size-4" />} onClick={() => void load(true)}>
|
||||
重新读取
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-settings-stack admin-appearance-settings">
|
||||
<div className={cn("admin-appearance-command-bar", dirty && "is-dirty")}>
|
||||
<div className="admin-appearance-command-copy" aria-live="polite">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<strong>{dirty ? "外观配置有调整待保存" : "外观配置已与服务端同步"}</strong>
|
||||
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
|
||||
</div>
|
||||
<p>{dirty ? "品牌、文案和媒体只在本页预览;保存后才会应用到公开页面。" : "保存后公开页面会在应用渲染前读取新配置,不会先闪现旧品牌。"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-appearance-command-actions">
|
||||
{dirty ? (
|
||||
<Button icon={<Undo2 className="size-4" />} disabled={saving || restoring} onClick={discardDraft}>
|
||||
撤销调整
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<RotateCcw className="size-4" />} loading={restoring} disabled={!setting.configured || saving || refreshing} onClick={restoreBuiltInAppearance}>
|
||||
恢复影策默认
|
||||
</Button>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving || restoring} onClick={requestRefresh}>
|
||||
刷新状态
|
||||
</Button>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || refreshing || restoring} onClick={() => void save()}>
|
||||
保存修改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section admin-appearance-brand-section"
|
||||
icon={<Palette className="size-4" aria-hidden="true" />}
|
||||
title="1. 设置品牌识别"
|
||||
description="中文品牌名用于主要界面,英文品牌标识用于英文角标和可安全品牌化的路径建议;不会改动代码包、数据库或部署标识。"
|
||||
status={status}
|
||||
>
|
||||
<div className="admin-appearance-brand-layout">
|
||||
<Form className="admin-appearance-form" layout="vertical" requiredMark={false} disabled={saving || refreshing || restoring}>
|
||||
<Form.Item label="品牌名称" extra="1–40 个字符。保存后同步到登录页、工作台、管理后台与浏览器标题。">
|
||||
<Input value={brandName} maxLength={40} showCount placeholder="输入品牌名称" onChange={(event) => setBrandName(event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="英文品牌标识" extra="1–48 位小写字母、数字或连字符,例如 hima-studio。对象存储可一键采用该值作为路径前缀。">
|
||||
<Input
|
||||
value={brandSlug}
|
||||
maxLength={48}
|
||||
showCount
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="例如:hima-studio"
|
||||
onChange={(event) => setBrandSlug(event.target.value.toLocaleLowerCase().replace(/[^a-z0-9-]/g, ""))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="admin-appearance-brand-logo admin-appearance-logo-stack">
|
||||
<AssetPicker
|
||||
slot="logo"
|
||||
title="浅色模式 Logo"
|
||||
description="用于浅色界面;建议透明背景 PNG 或 WebP。"
|
||||
configured={Boolean(setting.logoResourceId) && !resets.logo}
|
||||
file={files.logo}
|
||||
inputRef={inputRefs.logo}
|
||||
onSelect={selectFile}
|
||||
onReset={resetAsset}
|
||||
disabled={saving || refreshing || restoring}
|
||||
emptyLabel={darkLogoSelected ? "将自动复用深色模式 Logo" : "未上传时使用项目原始 Logo"}
|
||||
/>
|
||||
<AssetPicker
|
||||
slot="logo-dark"
|
||||
title="深色模式 Logo(可选)"
|
||||
description="用于深色界面;不上传时自动复用浅色模式 Logo。"
|
||||
configured={Boolean(setting.darkLogoResourceId) && !resets["logo-dark"]}
|
||||
file={files["logo-dark"]}
|
||||
inputRef={inputRefs["logo-dark"]}
|
||||
onSelect={selectFile}
|
||||
onReset={resetAsset}
|
||||
disabled={saving || refreshing || restoring}
|
||||
emptyLabel={lightLogoSelected ? "将自动复用浅色模式 Logo" : "未上传时使用项目原始 Logo"}
|
||||
/>
|
||||
<div className="admin-appearance-logo-frame-option">
|
||||
<div className="admin-appearance-logo-frame-copy">
|
||||
<strong>禁用 Logo 后面的圆角矩形外框</strong>
|
||||
<p id="appearance-logo-frame-help">开启后,工作台和管理后台导航只显示 Logo 本身;Logo 会等比放大到原外框的可用区域,导航占位保持不变。</p>
|
||||
</div>
|
||||
<div className="admin-appearance-logo-frame-control">
|
||||
<span>{logoFrameEnabled ? "保留外框" : "已禁用外框"}</span>
|
||||
<Switch
|
||||
checked={!logoFrameEnabled}
|
||||
disabled={saving || refreshing || restoring}
|
||||
aria-label="禁用 Logo 后面的圆角矩形外框"
|
||||
aria-describedby="appearance-logo-frame-help"
|
||||
onChange={(checked) => setLogoFrameEnabled(!checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-appearance-logo-preview-grid" aria-label="深浅模式 Logo 预览">
|
||||
<LogoThemePreview label="浅色界面" icon={<Sun />} src={previews.logoLight} dark={false} frameEnabled={logoFrameEnabled} />
|
||||
<LogoThemePreview label="深色界面" icon={<Moon />} src={previews.logoDark} dark frameEnabled={logoFrameEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section admin-appearance-auth-section"
|
||||
icon={<MonitorPlay className="size-4" aria-hidden="true" />}
|
||||
title="2. 设置登录页内容与媒体"
|
||||
description="登录、注册与找回密码共享左侧品牌文案和影片;更换视频会同时取消旧封面,避免品牌串帧。"
|
||||
status={
|
||||
<AdminStatusBadge
|
||||
label={copyCustomized || setting.authVideoResourceId || setting.authVideoPosterResourceId ? "已配置" : "使用原始内容"}
|
||||
tone={copyCustomized || setting.authVideoResourceId || setting.authVideoPosterResourceId ? "success" : "neutral"}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="admin-appearance-auth-layout">
|
||||
<div className="admin-appearance-auth-controls">
|
||||
<Form className="admin-appearance-form" layout="vertical" requiredMark={false} disabled={saving || refreshing || restoring}>
|
||||
<Form.Item label="登录页主标题" extra="1–80 个字符,可换行;登录、注册和找回密码页面共用。">
|
||||
<Input.TextArea value={authHeroTitle} maxLength={80} showCount autoSize={{ minRows: 2, maxRows: 4 }} placeholder="输入登录页主标题" onChange={(event) => setAuthHeroTitle(event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="登录页说明文案(可选)" extra="最多 160 个字符,可换行;留空时不显示说明。">
|
||||
<Input.TextArea
|
||||
value={authHeroDescription}
|
||||
maxLength={160}
|
||||
showCount
|
||||
autoSize={{ minRows: 2, maxRows: 5 }}
|
||||
placeholder="补充一句品牌介绍或产品定位"
|
||||
onChange={(event) => setAuthHeroDescription(event.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="admin-appearance-media-list">
|
||||
<AssetPicker
|
||||
slot="video"
|
||||
title="品牌视频"
|
||||
description="支持 MP4 或 WebM,最多 256MB;服务器运行时上传配额仍会同时生效。"
|
||||
configured={Boolean(setting.authVideoResourceId) && !resets.video}
|
||||
file={files.video}
|
||||
inputRef={inputRefs.video}
|
||||
onSelect={selectFile}
|
||||
onReset={resetAsset}
|
||||
disabled={saving || refreshing || restoring}
|
||||
/>
|
||||
<AssetPicker
|
||||
slot="poster"
|
||||
title="视频封面(可选)"
|
||||
description="自定义视频未设置封面时保持中性背景,不显示原品牌封面。"
|
||||
configured={Boolean(setting.authVideoPosterResourceId) && !resets.poster}
|
||||
file={files.poster}
|
||||
inputRef={inputRefs.poster}
|
||||
onSelect={selectFile}
|
||||
onReset={resetAsset}
|
||||
disabled={saving || refreshing || restoring}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-appearance-preview" aria-label="登录页实时预览">
|
||||
<div className="admin-appearance-preview-heading">
|
||||
<div>
|
||||
<span>登录页实时预览</span>
|
||||
<strong>{brandName.trim() || "未命名品牌"}</strong>
|
||||
</div>
|
||||
<AdminStatusBadge label={dirty ? "未保存" : "线上版本"} tone={dirty ? "warning" : "success"} />
|
||||
</div>
|
||||
<div className="admin-appearance-preview-stage">
|
||||
<video key={previews.video} src={previews.video} poster={previews.poster || undefined} muted loop playsInline autoPlay preload="metadata" />
|
||||
<span className="admin-appearance-preview-shade" />
|
||||
<span className="admin-appearance-preview-brand">
|
||||
<img src={previews.logoDark} alt="" />
|
||||
<strong>{brandName.trim() || "未命名品牌"}</strong>
|
||||
</span>
|
||||
<span className="admin-appearance-preview-copy">
|
||||
<small>{(brandSlug.trim() || "brand-studio").replace(/-+/g, " ").toLocaleUpperCase()}</small>
|
||||
<strong>{normalizeDraftCopy(authHeroTitle) || "请输入登录页主标题"}</strong>
|
||||
{normalizeDraftCopy(authHeroDescription) ? <span>{normalizeDraftCopy(authHeroDescription)}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
<p>预览与正式登录页使用同一文案和媒体。正式页面会先解析公开配置,再渲染品牌内容。</p>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section"
|
||||
icon={<Type className="size-4" aria-hidden="true" />}
|
||||
title="3. 皮肤方案"
|
||||
description="当前版本先统一使用经典皮肤;后续颜色、字体、圆角和组件密度会作为独立皮肤包管理。"
|
||||
status={<AdminStatusBadge label="经典皮肤" tone="info" />}
|
||||
>
|
||||
<div className="admin-appearance-skin-note">
|
||||
<span>当前皮肤</span>
|
||||
<strong>{setting.skinId === "classic" ? "经典皮肤(Classic)" : setting.skinId}</strong>
|
||||
<small>皮肤方案与品牌内容分离,后续切换皮肤时不会覆盖品牌名、英文标识、Logo、登录页文案或媒体。</small>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
)}
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetPicker({
|
||||
slot,
|
||||
title,
|
||||
description,
|
||||
configured,
|
||||
file,
|
||||
inputRef,
|
||||
onSelect,
|
||||
onReset,
|
||||
disabled,
|
||||
emptyLabel,
|
||||
}: {
|
||||
slot: AppearanceAssetSlot;
|
||||
title: string;
|
||||
description: string;
|
||||
configured: boolean;
|
||||
file: File | null;
|
||||
inputRef: RefObject<HTMLInputElement | null>;
|
||||
onSelect: (slot: AppearanceAssetSlot, file?: File) => void;
|
||||
onReset: (slot: AppearanceAssetSlot) => void;
|
||||
disabled: boolean;
|
||||
emptyLabel?: string;
|
||||
}) {
|
||||
const rule = FILE_RULES[slot];
|
||||
return (
|
||||
<div className="admin-appearance-asset-row">
|
||||
<span className="admin-appearance-asset-icon">{slot === "video" ? <MonitorPlay /> : <ImageIcon />}</span>
|
||||
<span className="admin-appearance-asset-copy">
|
||||
<strong>{title}</strong>
|
||||
<small>{description}</small>
|
||||
<em>{file ? `${file.name} · ${formatBytes(file.size)}` : configured ? "已配置自定义文件" : emptyLabel || "使用项目原始文件"}</em>
|
||||
</span>
|
||||
<span className="admin-appearance-asset-actions">
|
||||
<input ref={inputRef} type="file" accept={rule.accept} onChange={(event) => onSelect(slot, event.target.files?.[0])} />
|
||||
<Button icon={<Upload className="size-3.5" />} disabled={disabled} onClick={() => inputRef.current?.click()}>
|
||||
选择文件
|
||||
</Button>
|
||||
<Button type="text" danger={configured || Boolean(file)} disabled={disabled || (!configured && !file)} onClick={() => onReset(slot)}>
|
||||
恢复原始
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAppearancePreviews(setting: AdminAppearance | null, files: DraftFiles, resets: ResetState) {
|
||||
const logoObjectURL = useObjectURL(files.logo);
|
||||
const darkLogoObjectURL = useObjectURL(files["logo-dark"]);
|
||||
const videoObjectURL = useObjectURL(files.video);
|
||||
const posterObjectURL = useObjectURL(files.poster);
|
||||
return useMemo(() => {
|
||||
if (!setting) return { logoLight: DEFAULT_PUBLIC_APPEARANCE.logoUrl, logoDark: DEFAULT_PUBLIC_APPEARANCE.darkLogoUrl, video: DEFAULT_PUBLIC_APPEARANCE.authVideoUrl, poster: DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl };
|
||||
const customVideo = Boolean(files.video || (!resets.video && setting.authVideoResourceId));
|
||||
const lightLogo = logoObjectURL || (!resets.logo && setting.logoResourceId ? setting.public.logoUrl : "");
|
||||
const darkLogo = darkLogoObjectURL || (!resets["logo-dark"] && setting.darkLogoResourceId ? setting.public.darkLogoUrl : "");
|
||||
return {
|
||||
logoLight: lightLogo || darkLogo || DEFAULT_PUBLIC_APPEARANCE.logoUrl,
|
||||
logoDark: darkLogo || lightLogo || DEFAULT_PUBLIC_APPEARANCE.darkLogoUrl,
|
||||
video: videoObjectURL || (resets.video ? DEFAULT_PUBLIC_APPEARANCE.authVideoUrl : setting.public.authVideoUrl),
|
||||
poster: posterObjectURL || (resets.poster ? (customVideo ? "" : DEFAULT_PUBLIC_APPEARANCE.authVideoPosterUrl) : setting.public.authVideoPosterUrl),
|
||||
};
|
||||
}, [darkLogoObjectURL, files.video, logoObjectURL, posterObjectURL, resets, setting, videoObjectURL]);
|
||||
}
|
||||
|
||||
function LogoThemePreview({ label, icon, src, dark, frameEnabled }: { label: string; icon: ReactNode; src: string; dark: boolean; frameEnabled: boolean }) {
|
||||
return (
|
||||
<div className={cn("admin-appearance-logo-preview", dark ? "is-dark" : "is-light")}>
|
||||
<span className={cn("admin-appearance-logo-preview-mark", !frameEnabled && "is-unframed")}>
|
||||
<img src={src} alt="" />
|
||||
</span>
|
||||
<span className="admin-appearance-logo-preview-label">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useObjectURL(file: File | null) {
|
||||
const url = useMemo(() => (file ? URL.createObjectURL(file) : ""), [file]);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
},
|
||||
[url],
|
||||
);
|
||||
return url;
|
||||
}
|
||||
|
||||
function AppearanceSkeleton() {
|
||||
return (
|
||||
<div className="admin-settings-stack admin-appearance-settings" aria-label="正在读取外观配置" role="status">
|
||||
<div className="admin-appearance-command-bar">
|
||||
<Skeleton active title={{ width: 190 }} paragraph={false} />
|
||||
</div>
|
||||
<div className="admin-appearance-loading-card">
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
</div>
|
||||
<div className="admin-appearance-loading-card">
|
||||
<Skeleton active paragraph={{ rows: 10 }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDraftCopy(value: string) {
|
||||
return value.replace(/\r\n?/g, "\n").trim();
|
||||
}
|
||||
|
||||
function hasUnsupportedControlCharacter(value: string) {
|
||||
return Array.from(value).some((character) => character !== "\n" && /[\u0000-\u001f\u007f]/.test(character));
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useBlocker } from "react-router";
|
||||
import { changesRequireOSSRetest, DEFAULT_OSS_PATH_PREFIX, getS3PresetHints, normalizeOSSConnectionTestInput, S3_PRESET_OPTIONS, type OSSConnectionTestResult, type S3Preset } from "@/lib/oss-settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAdminOSSSetting, testAdminOSSConnection, updateAdminOSSSetting, type AdminOSSSetting } from "@/services/api/auth";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
@@ -38,6 +39,7 @@ const STORAGE_MODES: Array<{ mode: StorageMode; label: string; short: string; de
|
||||
|
||||
export default function StorageSettingsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const brandSlug = useAppearanceStore((state) => state.appearance.brandSlug);
|
||||
const [setting, setSetting] = useState<AdminOSSSetting | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -187,6 +189,15 @@ export default function StorageSettingsPage() {
|
||||
applyMode(nextMode);
|
||||
};
|
||||
|
||||
const useBrandPathPrefix = () => {
|
||||
if (!setting || saving || refreshing) return;
|
||||
const current = form.getFieldsValue(true);
|
||||
form.setFieldValue("pathPrefix", brandSlug);
|
||||
setDirty(hasStorageChanges({ ...current, pathPrefix: brandSlug }, setting));
|
||||
setTestStale(true);
|
||||
setSaveError("");
|
||||
};
|
||||
|
||||
const save = async (values: OSSFormValues) => {
|
||||
if (!setting) return;
|
||||
const expected = normalizeStoragePayload(values, setting);
|
||||
@@ -483,8 +494,15 @@ export default function StorageSettingsPage() {
|
||||
<Form.Item name="bucket" label="Bucket">
|
||||
<Input autoComplete="off" placeholder={draftMode === "qiniu" ? "七牛云存储空间名称" : "对象存储 Bucket"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pathPrefix" label="路径前缀" extra="可留空;保存时自动去除首尾斜杠。">
|
||||
<Input autoComplete="off" placeholder="例如:canvas" />
|
||||
<Form.Item label="路径前缀" extra={`可自行填写;也可采用外观管理中的英文品牌标识 ${brandSlug}。保存时自动去除首尾斜杠。`}>
|
||||
<div className="admin-storage-address-control">
|
||||
<Form.Item name="pathPrefix" noStyle>
|
||||
<Input autoComplete="off" placeholder={`例如:${brandSlug}`} />
|
||||
</Form.Item>
|
||||
<Button disabled={saving || refreshing || form.getFieldValue("pathPrefix") === brandSlug} onClick={useBrandPathPrefix}>
|
||||
使用品牌标识
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { exportAssets, readAssetPackage } from "./asset-transfer";
|
||||
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
|
||||
import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
|
||||
type LibraryAsset = Exclude<Asset, { kind: "entity" }>;
|
||||
|
||||
@@ -1058,6 +1059,7 @@ const assetsEmptyBannerFrames = [
|
||||
];
|
||||
|
||||
function AssetsEmptyState({ onNew, onImport, onGoCanvas }: { onNew: () => void; onImport: () => void; onGoCanvas: () => void }) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
return (
|
||||
<div className="assets-empty">
|
||||
<div className="assets-empty-banner" aria-hidden="true">
|
||||
@@ -1068,7 +1070,7 @@ function AssetsEmptyState({ onNew, onImport, onGoCanvas }: { onNew: () => void;
|
||||
</figure>
|
||||
))}
|
||||
<span className="assets-empty-banner-caption">
|
||||
<span>影策素材库</span>把每次创作的结果,留档成可复用的资产
|
||||
<span>{brandName}素材库</span>把每次创作的结果,留档成可复用的资产
|
||||
</span>
|
||||
</div>
|
||||
<div className="assets-empty-cards">
|
||||
|
||||
@@ -3,11 +3,11 @@ import { ConfigProvider, Tabs } from "antd";
|
||||
import { ArrowLeft, Play } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { getAntThemeConfig } from "@/lib/app-theme";
|
||||
import { brandStudioLabel, useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
|
||||
const AUTH_VIDEO_URL = "https://boss-shjd.biliapi.net/updream/aniforge/video/video_bbcb00bd-650d-4249-9346-5cd21fd2484c_m1hc-u0-1pu13x-3v1s.mp4";
|
||||
const AUTH_VIDEO_POSTER = "https://i0.hdslb.com/bfs/aitool/aniforge/image/02933f26-5f1b-49ff-a811-b7f95ee5e5b8_m1hc-u0-sau.jpg";
|
||||
const AUTH_TABS = [
|
||||
{ key: "login", label: "登录" },
|
||||
{ key: "register", label: "注册" },
|
||||
@@ -45,6 +45,7 @@ export function LinuxDOIcon() {
|
||||
}
|
||||
|
||||
export function AuthScene() {
|
||||
const appearance = useAppearanceStore((state) => state.appearance);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const reducedMotion = useReducedMotion();
|
||||
@@ -55,14 +56,14 @@ export function AuthScene() {
|
||||
return (
|
||||
<main className="h-dvh min-h-0 overflow-y-auto bg-[#08090c] text-white lg:overflow-hidden">
|
||||
<div className="grid min-h-full lg:h-full lg:grid-cols-[minmax(0,1.32fr)_minmax(520px,1fr)]">
|
||||
<section className="relative min-h-[250px] overflow-hidden sm:min-h-[320px] lg:min-h-0" aria-label="影策品牌影片">
|
||||
<video className="absolute inset-0 size-full object-cover" src={AUTH_VIDEO_URL} poster={AUTH_VIDEO_POSTER} autoPlay muted loop playsInline preload="metadata" />
|
||||
<section className="relative min-h-[250px] overflow-hidden sm:min-h-[320px] lg:min-h-0" aria-label={`${appearance.brandName}品牌影片`}>
|
||||
<video className="absolute inset-0 size-full object-cover" src={appearance.authVideoUrl} poster={appearance.authVideoPosterUrl || undefined} autoPlay={!reducedMotion} muted loop playsInline preload="metadata" />
|
||||
<div aria-hidden className="absolute inset-0 bg-[linear-gradient(180deg,rgba(4,5,8,.58),transparent_42%,rgba(4,5,8,.74))]" />
|
||||
<div aria-hidden className="absolute inset-y-0 right-0 hidden w-[clamp(120px,14vw,240px)] bg-[linear-gradient(90deg,transparent_0%,rgba(11,12,16,.68)_58%,#0b0c10_100%)] lg:block" />
|
||||
<div className="absolute inset-x-0 top-0 flex items-center justify-between gap-4 p-5 sm:p-7 lg:p-9">
|
||||
<Link to="/" className="inline-flex items-center gap-2.5 text-sm font-semibold text-white drop-shadow-sm transition-opacity hover:opacity-80">
|
||||
<span className="size-7 bg-current" style={{ mask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat" }} />
|
||||
影策
|
||||
<BrandLogo theme="dark" className="size-7" alt="" fallback={<span className="size-7 bg-current" style={{ mask: "url(/logo.svg) center / contain no-repeat", WebkitMask: "url(/logo.svg) center / contain no-repeat" }} />} />
|
||||
{appearance.brandName}
|
||||
</Link>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/16 bg-black/20 px-3 py-1.5 text-[var(--fs-label)] text-white/76 backdrop-blur-xl">
|
||||
<Play className="size-3 fill-current" />
|
||||
@@ -75,12 +76,9 @@ export function AuthScene() {
|
||||
transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}
|
||||
className="absolute inset-x-0 bottom-0 max-w-2xl p-5 sm:p-7 lg:p-10"
|
||||
>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-white/58">YINGCE STUDIO</p>
|
||||
<h1 className="mt-3 max-w-xl text-3xl font-semibold leading-tight sm:text-4xl lg:text-5xl">
|
||||
让一个故事,
|
||||
<br className="hidden sm:inline" />
|
||||
从文字走向银幕。
|
||||
</h1>
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-white/58">{brandStudioLabel(appearance)}</p>
|
||||
<h1 className="mt-3 max-w-xl whitespace-pre-line text-3xl font-semibold leading-tight sm:text-4xl lg:text-5xl">{appearance.authHeroTitle}</h1>
|
||||
{appearance.authHeroDescription ? <p className="mt-4 max-w-xl whitespace-pre-line text-sm leading-6 text-white/62 sm:text-base sm:leading-7">{appearance.authHeroDescription}</p> : null}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ import { loadCreationConversations, pendingCreationTaskIds, pendingCreationTaskK
|
||||
import { recoverCreationTextTask } from "@/services/creation-text-task-recovery";
|
||||
import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types";
|
||||
import { promptOptimizerPlugin, PROMPT_OPTIMIZER_PLUGIN_ID } from "@/lib/plugins/builtin/prompt-optimizer";
|
||||
@@ -135,6 +136,7 @@ function completedCreationGenerationTask(input: { taskId: string; task?: Generat
|
||||
|
||||
export default function CreatePage() {
|
||||
const { message: toast, modal } = App.useApp();
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const config = useEffectiveConfig();
|
||||
const promptOptimizerInstallation = usePluginStore((state) => state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID));
|
||||
const promptOptimizerEnabled = usePluginStore((state) => state.pluginStates[PROMPT_OPTIMIZER_PLUGIN_ID]?.effectiveEnabled ?? Boolean(state.installations.find((item) => item.manifest.id === PROMPT_OPTIMIZER_PLUGIN_ID)?.enabled));
|
||||
@@ -902,8 +904,8 @@ export default function CreatePage() {
|
||||
<CreationEmptyBanner />
|
||||
<div className="creation-chat-intro">
|
||||
<span className="creation-intro-signal" aria-hidden="true" />
|
||||
<p>影策 · AI 影视创作工作台</p>
|
||||
<h1>把脑海里的画面,<span className="creation-intro-emphasis"><span className="is-pink">交给影策</span><span className="is-blue">拍出来</span></span></h1>
|
||||
<p>{brandName} · AI 影视创作工作台</p>
|
||||
<h1>把脑海里的画面,<span className="creation-intro-emphasis"><span className="is-pink">交给{brandName}</span><span className="is-blue">拍出来</span></span></h1>
|
||||
</div>
|
||||
<div className="creation-empty-composer">
|
||||
<CreationComposer {...composerProps} variant="empty" />
|
||||
@@ -1035,10 +1037,11 @@ function CreationWorkspaceToolbar({ viewMode, onViewModeChange, onNewConversatio
|
||||
}
|
||||
|
||||
function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant }: { item: CreationMessage; modelName: string; onRetryFailure: () => void; onCreateVariant: () => void }) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
if (item.role === "user") return <CreationUserMessage item={item} />;
|
||||
const mode = item.mode || "text";
|
||||
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : "";
|
||||
const heading = <><span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : "影策 AI"}</strong>{mode !== "text" ? <span className="creation-message-progress-copy">{item.status === "pending" ? `影策正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null}</span> : null}{modelName ? <span className="creation-message-model">{modelName}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>;
|
||||
const heading = <><span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : mode === "video" ? "视频生成" : `${brandName} AI`}</strong>{mode !== "text" ? <span className="creation-message-progress-copy">{item.status === "pending" ? `${brandName}正在生成${mode === "video" ? "视频" : "图像"}……` : item.status === "done" ? `你的${mode === "video" ? "视频" : "图像"}已创建` : null}</span> : null}{modelName ? <span className="creation-message-model">{modelName}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>;
|
||||
const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed";
|
||||
return <article className={`creation-assistant-message is-${mode}`}>
|
||||
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span>正在生成…</span>}</div></> : <GenerationToolCard status={toolStatus} isBulk={(item.resultUrls?.length || Number(item.settings?.count) || 1) > 1} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} /></GenerationToolCard>}
|
||||
@@ -1086,7 +1089,8 @@ function MediaResult({ item, onRetryFailure, onCreateVariant }: { item: Creation
|
||||
}
|
||||
|
||||
function CreationMediaPending({ mode, ratio }: { mode: CreationMode; ratio?: string }) {
|
||||
return <div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite"><span className="creation-media-pending-icon"><Sparkles /></span><span className="sr-only">影策正在生成{mode === "video" ? "视频" : "图像"}</span></div>;
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
return <div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite"><span className="creation-media-pending-icon"><Sparkles /></span><span className="sr-only">{brandName}正在生成{mode === "video" ? "视频" : "图像"}</span></div>;
|
||||
}
|
||||
|
||||
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
|
||||
@@ -1533,12 +1537,13 @@ const creationEmptyBannerFrames = [
|
||||
];
|
||||
|
||||
function CreationEmptyBanner() {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
return <div className="creation-empty-art" aria-hidden="true">
|
||||
{creationEmptyBannerFrames.map((frame, index) => <figure key={frame.caption} className={`creation-empty-art-frame ${index === 1 ? "is-main" : index === 0 ? "is-back" : "is-front"}`}>
|
||||
<img src={frame.src} alt="" />
|
||||
<span>{frame.caption}</span>
|
||||
</figure>)}
|
||||
<span className="creation-empty-art-caption"><span>影策</span>把每一帧,交给镜头导演</span>
|
||||
<span className="creation-empty-art-caption"><span>{brandName}</span>把每一帧,交给镜头导演</span>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1563,10 +1568,10 @@ function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt:
|
||||
|
||||
type CreationThinking = { title: string; hint: string; steps: string[] };
|
||||
|
||||
function thinkingFor(mode: CreationMode): CreationThinking {
|
||||
if (mode === "image") return { title: "正在为你画这一镜", hint: "影策正在理解你的构图意图,并把画面交给模型出图。", steps: ["理解构图", "定调画风", "生成画面"] };
|
||||
if (mode === "text") return { title: "正在为你写这段", hint: "影策正在梳理你的创作脉络,组织语言与结构。", steps: ["梳理脉络", "组织语言", "输出段落"] };
|
||||
return { title: "正在为你拍这一镜", hint: "影策正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。", steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"] };
|
||||
function thinkingFor(mode: CreationMode, brandName: string): CreationThinking {
|
||||
if (mode === "image") return { title: "正在为你画这一镜", hint: `${brandName}正在理解你的构图意图,并把画面交给模型出图。`, steps: ["理解构图", "定调画风", "生成画面"] };
|
||||
if (mode === "text") return { title: "正在为你写这段", hint: `${brandName}正在梳理你的创作脉络,组织语言与结构。`, steps: ["梳理脉络", "组织语言", "输出段落"] };
|
||||
return { title: "正在为你拍这一镜", hint: `${brandName}正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。`, steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"] };
|
||||
}
|
||||
|
||||
function directorNoteFor(mode: CreationMode, settings: CreationSettings): string {
|
||||
@@ -1617,6 +1622,7 @@ function StoryboardToolbar({ shots, activeIndex, composing, onSelect, onBeginCom
|
||||
}
|
||||
|
||||
function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure, onCreateVariant }: { shot: CreationShot; shotNumber: number; modelName: string; busy: boolean; onRetryFailure: () => void; onCreateVariant: () => void }) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const user = shot.user;
|
||||
const result = shot.result;
|
||||
const status = result?.status || "queued";
|
||||
@@ -1656,11 +1662,11 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure,
|
||||
</div>
|
||||
</div>
|
||||
</div> : null}
|
||||
{briefVisible && user ? <div className="storyboard-workbench-handoff" aria-hidden="true"><span className="storyboard-workbench-handoff-rail" /><span className="storyboard-workbench-handoff-badge"><ArrowDown />交给影策 AI</span><span className="storyboard-workbench-handoff-rail" /></div> : null}
|
||||
{briefVisible && user ? <div className="storyboard-workbench-handoff" aria-hidden="true"><span className="storyboard-workbench-handoff-rail" /><span className="storyboard-workbench-handoff-badge"><ArrowDown />交给{brandName} AI</span><span className="storyboard-workbench-handoff-rail" /></div> : null}
|
||||
<div className="storyboard-workbench-turn is-ai">
|
||||
<span className="storyboard-workbench-ai-avatar"><Clapperboard /></span>
|
||||
<div className="storyboard-workbench-turn-copy">
|
||||
<div className="storyboard-workbench-turn-meta"><span className="storyboard-workbench-turn-role is-ai"><Sparkles />影策 AI</span>{modelName ? <span className="storyboard-workbench-turn-model">{modelName}</span> : null}{result?.createdAt ? <time className="storyboard-workbench-turn-time" dateTime={result.createdAt}>{formatMessageTime(result.createdAt)}</time> : null}</div>
|
||||
<div className="storyboard-workbench-turn-meta"><span className="storyboard-workbench-turn-role is-ai"><Sparkles />{brandName} AI</span>{modelName ? <span className="storyboard-workbench-turn-model">{modelName}</span> : null}{result?.createdAt ? <time className="storyboard-workbench-turn-time" dateTime={result.createdAt}>{formatMessageTime(result.createdAt)}</time> : null}</div>
|
||||
<div className="storyboard-workbench-turn-bubble">
|
||||
<StoryboardShotResult result={result} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} canvasPath={canvasPath} canvasHandoffAvailable={Boolean(canvasHandoffPath)} />
|
||||
</div>
|
||||
@@ -1672,6 +1678,7 @@ function StoryboardShotCard({ shot, shotNumber, modelName, busy, onRetryFailure,
|
||||
}
|
||||
|
||||
function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number; onCancel: () => void }) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
return <article className="storyboard-workbench-card is-next">
|
||||
<header className="storyboard-workbench-card-head">
|
||||
<div className="storyboard-workbench-card-heading">
|
||||
@@ -1687,7 +1694,7 @@ function StoryboardNextShotCard({ shotNumber, onCancel }: { shotNumber: number;
|
||||
<span className="storyboard-workbench-next-panel-icon"><Clapperboard /></span>
|
||||
<div className="storyboard-workbench-next-panel-copy">
|
||||
<strong>{formatShotOrdinal(shotNumber - 1)} 等待你的脚本</strong>
|
||||
<span>在下方写下这一镜的镜头、画面或故事。影策会拆解脚本、设计运镜并渲染成片,这一镜会作为 {formatShotOrdinal(shotNumber - 1)} 自动加入镜头轨道。</span>
|
||||
<span>在下方写下这一镜的镜头、画面或故事。{brandName}会拆解脚本、设计运镜并渲染成片,这一镜会作为 {formatShotOrdinal(shotNumber - 1)} 自动加入镜头轨道。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1707,6 +1714,7 @@ function StoryboardBriefAttachments({ attachments }: { attachments: CreationAtta
|
||||
}
|
||||
|
||||
function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, canvasPath, canvasHandoffAvailable }: { result?: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; canvasPath: string; canvasHandoffAvailable: boolean }) {
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [previewType, setPreviewType] = useState<"image" | "video">("image");
|
||||
const openPreview = (url: string, type: "image" | "video") => { setPreviewType(type); setPreviewUrl(url); };
|
||||
@@ -1715,7 +1723,7 @@ function StoryboardShotResult({ result, onRetryFailure, onCreateVariant, canvasP
|
||||
const status = result.status || "queued";
|
||||
const resultUrls = result.resultUrls || [];
|
||||
if (status === "pending" || status === "queued") {
|
||||
const thinking = thinkingFor(mode);
|
||||
const thinking = thinkingFor(mode, brandName);
|
||||
return <div className="storyboard-workbench-pending"><div className="storyboard-workbench-thinking">
|
||||
<span className="storyboard-workbench-thinking-copy"><strong>{thinking.title}</strong><span>{thinking.hint}</span></span>
|
||||
<span className="storyboard-workbench-pipeline" aria-hidden="true">{thinking.steps.map((step, index) => <em key={step} style={{ "--step": index } as CSSProperties}><i>{String(index + 1).padStart(2, "0")}</i>{step}</em>)}</span>
|
||||
|
||||
Reference in new issue
Block a user