feat(themes): 添加可编辑的站点外观和皮肤主题(#408)
This commit is contained in:
1 parent
c768fccbc2
commit
5e66cb7c45
70 files changed
+3654
-327
No files matched your search
@@ -84,7 +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/appearance", label: "站点及外观", description: "品牌、SEO、备案与皮肤", 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" /> },
|
||||
@@ -127,7 +127,7 @@ export function AdminShell() {
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={getAdminAntThemeConfig(dark)}>
|
||||
<ConfigProvider theme={getAdminAntThemeConfig(dark, appearance.activeSkin)}>
|
||||
<main className="admin-shell app-user-workspace flex h-full min-h-0 overflow-hidden text-foreground">
|
||||
<aside className={cn("app-workspace-sidebar admin-sidebar hidden shrink-0 flex-col overflow-hidden lg:flex", collapsed && "is-collapsed")}>
|
||||
<div className="admin-sidebar-identity shrink-0">
|
||||
|
||||
@@ -5,12 +5,14 @@ import { useBlocker } from "react-router";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAdminEmailSetting, updateAdminEmailSetting, type EmailSetting } from "@/services/api/wallet";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "./admin-ui";
|
||||
|
||||
type EmailFormValues = Pick<EmailSetting, "enabled" | "host" | "port" | "username" | "password" | "encryption" | "fromEmail" | "fromName">;
|
||||
|
||||
export default function EmailSettingsPanel() {
|
||||
const { message, modal } = App.useApp();
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const [setting, setSetting] = useState<EmailSetting | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@@ -394,9 +396,9 @@ export default function EmailSettingsPanel() {
|
||||
validator: (_, value: string | undefined) => (!draftEnabled || !value || !/[\r\n]/.test(value) ? Promise.resolve() : Promise.reject(new Error("发件人名称不能包含换行"))),
|
||||
},
|
||||
]}
|
||||
extra="留空时服务端使用“影策”。"
|
||||
extra={`留空时自动使用当前站点名称“${brandName}”;之后修改站点名称会同步更新。`}
|
||||
>
|
||||
<Input placeholder="影策" />
|
||||
<Input placeholder={brandName} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,7 +431,7 @@ function toEmailFormValues(setting: EmailSetting): EmailFormValues {
|
||||
password: "",
|
||||
encryption: setting.encryption,
|
||||
fromEmail: setting.fromEmail,
|
||||
fromName: setting.fromName,
|
||||
fromName: setting.fromNameInherited ? "" : setting.fromName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -442,7 +444,7 @@ function normalizeEmailFormValues(values: Partial<EmailFormValues>): EmailFormVa
|
||||
password: values.password?.trim() || "",
|
||||
encryption: values.encryption === "tls" || values.encryption === "none" ? values.encryption : "starttls",
|
||||
fromEmail: values.fromEmail?.trim().toLowerCase() || "",
|
||||
fromName: values.fromName?.trim() || "影策",
|
||||
fromName: values.fromName?.trim() || "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -482,6 +484,7 @@ function isEmailSetting(value: unknown): value is EmailSetting {
|
||||
["starttls", "tls", "none"].includes(setting.encryption || "") &&
|
||||
typeof setting.fromEmail === "string" &&
|
||||
typeof setting.fromName === "string" &&
|
||||
typeof setting.fromNameInherited === "boolean" &&
|
||||
typeof setting.hasPassword === "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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 { Copyright, Globe2, Image as ImageIcon, MonitorPlay, Moon, Palette, RefreshCw, RotateCcw, Save, Search, 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 { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, normalizeSkinDefinition, type SkinDefinition } from "@/lib/skin-themes";
|
||||
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
|
||||
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";
|
||||
@@ -30,6 +32,14 @@ export default function AppearanceSettingsPage() {
|
||||
const [authHeroTitle, setAuthHeroTitle] = useState("");
|
||||
const [authHeroDescription, setAuthHeroDescription] = useState("");
|
||||
const [logoFrameEnabled, setLogoFrameEnabled] = useState(true);
|
||||
const [skinId, setSkinId] = useState("classic");
|
||||
const [skinThemes, setSkinThemes] = useState<SkinDefinition[]>([DEFAULT_CLASSIC_SKIN]);
|
||||
const [seoTitle, setSeoTitle] = useState("");
|
||||
const [seoDescription, setSeoDescription] = useState("");
|
||||
const [seoKeywords, setSeoKeywords] = useState("");
|
||||
const [footerCopyright, setFooterCopyright] = useState("");
|
||||
const [icpFilingEnabled, setIcpFilingEnabled] = useState(false);
|
||||
const [icpFilingNumber, setIcpFilingNumber] = useState("");
|
||||
const [files, setFiles] = useState<DraftFiles>(EMPTY_FILES);
|
||||
const [resets, setResets] = useState<ResetState>(EMPTY_RESETS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -52,6 +62,14 @@ export default function AppearanceSettingsPage() {
|
||||
normalizeDraftCopy(authHeroTitle) !== setting?.authHeroTitle ||
|
||||
normalizeDraftCopy(authHeroDescription) !== setting?.authHeroDescription ||
|
||||
logoFrameEnabled !== setting?.logoFrameEnabled ||
|
||||
skinId !== setting?.skinId ||
|
||||
JSON.stringify(skinThemes) !== JSON.stringify(setting?.skinThemes) ||
|
||||
normalizeSingleLine(seoTitle) !== setting?.seoTitle ||
|
||||
normalizeDraftCopy(seoDescription) !== setting?.seoDescription ||
|
||||
normalizeSingleLine(seoKeywords) !== setting?.seoKeywords ||
|
||||
normalizeSingleLine(footerCopyright) !== setting?.footerCopyright ||
|
||||
icpFilingEnabled !== setting?.icpFilingEnabled ||
|
||||
normalizeSingleLine(icpFilingNumber) !== setting?.icpFilingNumber ||
|
||||
Object.values(files).some(Boolean) ||
|
||||
Object.values(resets).some(Boolean));
|
||||
const blocker = useBlocker(dirty && !saving && !restoring);
|
||||
@@ -63,6 +81,15 @@ export default function AppearanceSettingsPage() {
|
||||
setAuthHeroTitle(value.authHeroTitle);
|
||||
setAuthHeroDescription(value.authHeroDescription);
|
||||
setLogoFrameEnabled(value.logoFrameEnabled);
|
||||
const themes = value.skinThemes.map((theme) => normalizeSkinDefinition(theme));
|
||||
setSkinThemes(themes.length ? themes : [cloneSkinDefinition(DEFAULT_CLASSIC_SKIN)]);
|
||||
setSkinId(themes.some((theme) => theme.id === value.skinId) ? value.skinId : "classic");
|
||||
setSeoTitle(value.seoTitle);
|
||||
setSeoDescription(value.seoDescription);
|
||||
setSeoKeywords(value.seoKeywords);
|
||||
setFooterCopyright(value.footerCopyright);
|
||||
setIcpFilingEnabled(value.icpFilingEnabled);
|
||||
setIcpFilingNumber(value.icpFilingNumber);
|
||||
setFiles(EMPTY_FILES);
|
||||
setResets(EMPTY_RESETS);
|
||||
setLoadError("");
|
||||
@@ -101,8 +128,8 @@ export default function AppearanceSettingsPage() {
|
||||
useEffect(() => {
|
||||
if (blocker.state !== "blocked") return;
|
||||
modal.confirm({
|
||||
title: "放弃外观调整?",
|
||||
content: "当前品牌信息、登录页文案或媒体文件尚未保存,离开后草稿会丢失。线上外观不会改变。",
|
||||
title: "放弃站点及外观调整?",
|
||||
content: "当前品牌、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。",
|
||||
okText: "放弃并离开",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
@@ -163,6 +190,14 @@ export default function AppearanceSettingsPage() {
|
||||
setAuthHeroTitle(setting.authHeroTitle);
|
||||
setAuthHeroDescription(setting.authHeroDescription);
|
||||
setLogoFrameEnabled(setting.logoFrameEnabled);
|
||||
setSkinId(setting.skinId);
|
||||
setSkinThemes(setting.skinThemes.map((theme) => cloneSkinDefinition(theme)));
|
||||
setSeoTitle(setting.seoTitle);
|
||||
setSeoDescription(setting.seoDescription);
|
||||
setSeoKeywords(setting.seoKeywords);
|
||||
setFooterCopyright(setting.footerCopyright);
|
||||
setIcpFilingEnabled(setting.icpFilingEnabled);
|
||||
setIcpFilingNumber(setting.icpFilingNumber);
|
||||
setFiles(EMPTY_FILES);
|
||||
setResets(EMPTY_RESETS);
|
||||
Object.values(inputRefs).forEach((ref) => {
|
||||
@@ -178,7 +213,7 @@ export default function AppearanceSettingsPage() {
|
||||
}
|
||||
modal.confirm({
|
||||
title: "放弃调整并重新读取?",
|
||||
content: "重新读取会丢弃当前品牌信息、登录页文案和待上传文件。",
|
||||
content: "重新读取会丢弃当前品牌、SEO、备案、皮肤和待上传文件。",
|
||||
okText: "放弃并刷新",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
@@ -190,7 +225,7 @@ export default function AppearanceSettingsPage() {
|
||||
if (!setting?.configured || saving || refreshing || restoring) return;
|
||||
modal.confirm({
|
||||
title: "恢复影策默认品牌标识?",
|
||||
content: "品牌名称、英文标识、Logo、登录页文案、视频、封面和皮肤配置会立即恢复为项目内置值。已上传文件仍保留在存储资源中,不会被删除。",
|
||||
content: "品牌名称、英文标识、Logo、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值。已上传文件仍保留在存储资源中,不会被删除。",
|
||||
okText: "恢复默认",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
@@ -221,6 +256,11 @@ export default function AppearanceSettingsPage() {
|
||||
const nextBrandSlug = brandSlug.trim().toLocaleLowerCase();
|
||||
const nextAuthHeroTitle = normalizeDraftCopy(authHeroTitle);
|
||||
const nextAuthHeroDescription = normalizeDraftCopy(authHeroDescription);
|
||||
const nextSeoTitle = normalizeSingleLine(seoTitle);
|
||||
const nextSeoDescription = normalizeDraftCopy(seoDescription);
|
||||
const nextSeoKeywords = normalizeSingleLine(seoKeywords);
|
||||
const nextFooterCopyright = normalizeSingleLine(footerCopyright);
|
||||
const nextIcpFilingNumber = normalizeSingleLine(icpFilingNumber);
|
||||
if (!nextBrandName || Array.from(nextBrandName).length > 40) {
|
||||
message.error("品牌名称必须为 1 到 40 个字符");
|
||||
return;
|
||||
@@ -237,6 +277,27 @@ export default function AppearanceSettingsPage() {
|
||||
message.error("登录页说明文案不能超过 160 个字符,可使用换行");
|
||||
return;
|
||||
}
|
||||
for (const [value, label, max] of [
|
||||
[nextSeoTitle, "SEO 标题", 70],
|
||||
[nextSeoDescription, "SEO 描述", 200],
|
||||
[nextSeoKeywords, "SEO 关键词", 300],
|
||||
[nextFooterCopyright, "版权信息", 160],
|
||||
[nextIcpFilingNumber, "备案号", 64],
|
||||
] as const) {
|
||||
if (Array.from(value).length > max || hasUnsupportedControlCharacter(value)) {
|
||||
message.error(`${label}不能超过 ${max} 个字符,且不能包含控制字符`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (icpFilingEnabled && !nextIcpFilingNumber) {
|
||||
message.error("显示备案号前请先填写备案号");
|
||||
return;
|
||||
}
|
||||
const skinError = validateSkinDrafts(skinThemes, skinId);
|
||||
if (skinError) {
|
||||
message.error(skinError);
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const uploadedIDs: string[] = [];
|
||||
try {
|
||||
@@ -263,14 +324,21 @@ export default function AppearanceSettingsPage() {
|
||||
logoFrameEnabled,
|
||||
authVideoResourceId: ids.video,
|
||||
authVideoPosterResourceId: ids.poster,
|
||||
skinId: setting.skinId,
|
||||
skinId,
|
||||
skinThemes,
|
||||
seoTitle: nextSeoTitle,
|
||||
seoDescription: nextSeoDescription,
|
||||
seoKeywords: nextSeoKeywords,
|
||||
footerCopyright: nextFooterCopyright,
|
||||
icpFilingEnabled,
|
||||
icpFilingNumber: nextIcpFilingNumber,
|
||||
});
|
||||
applySetting(updated);
|
||||
commitPublicAppearance(updated.public);
|
||||
Object.values(inputRefs).forEach((ref) => {
|
||||
if (ref.current) ref.current.value = "";
|
||||
});
|
||||
message.success("外观配置已保存并立即生效");
|
||||
message.success("站点及外观配置已保存并立即生效");
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : "保存外观配置失败";
|
||||
if (uploadedIDs.length) {
|
||||
@@ -291,9 +359,43 @@ export default function AppearanceSettingsPage() {
|
||||
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;
|
||||
const draftBrandName = brandName.trim() || "站点名称";
|
||||
const selectedSkin = skinThemes.find((skin) => skin.id === skinId) || skinThemes[0] || DEFAULT_CLASSIC_SKIN;
|
||||
|
||||
const changeSkin = (next: SkinDefinition) => {
|
||||
if (next.locked) return;
|
||||
setSkinThemes((current) => current.map((theme) => (theme.id === next.id ? next : theme)));
|
||||
};
|
||||
|
||||
const duplicateSkin = (sourceID: string) => {
|
||||
if (skinThemes.length >= 16) return;
|
||||
const source = skinThemes.find((theme) => theme.id === sourceID) || DEFAULT_CLASSIC_SKIN;
|
||||
const copy = duplicateSkinDefinition(
|
||||
source,
|
||||
skinThemes.map((theme) => theme.id),
|
||||
);
|
||||
setSkinThemes((current) => [...current, copy]);
|
||||
setSkinId(copy.id);
|
||||
};
|
||||
|
||||
const deleteSkin = (targetID: string) => {
|
||||
const target = skinThemes.find((theme) => theme.id === targetID);
|
||||
if (!target || target.locked) return;
|
||||
modal.confirm({
|
||||
title: `删除主题“${target.name}”?`,
|
||||
content: "删除会随本页其他调整一起保存;保存前仍可点击“撤销调整”恢复。若它当前启用,将自动切回经典黑白。",
|
||||
okText: "删除主题",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
setSkinThemes((current) => current.filter((theme) => theme.id !== targetID));
|
||||
if (skinId === targetID) setSkinId("classic");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame title="外观管理" description="统一设置平台品牌、登录页内容与媒体,并为后续皮肤库保留扩展入口" scroll>
|
||||
<AdminPageFrame title="站点及外观" description="统一管理品牌身份、登录页、搜索信息、备案展示与全站皮肤主题" scroll>
|
||||
{loading ? (
|
||||
<AppearanceSkeleton />
|
||||
) : loadError || !setting ? (
|
||||
@@ -317,10 +419,10 @@ export default function AppearanceSettingsPage() {
|
||||
<div className="admin-appearance-command-copy" aria-live="polite">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<strong>{dirty ? "外观配置有调整待保存" : "外观配置已与服务端同步"}</strong>
|
||||
<strong>{dirty ? "站点配置有调整待保存" : "站点及外观已与服务端同步"}</strong>
|
||||
<AdminStatusBadge label={dirty ? "尚未生效" : "服务端当前值"} tone={dirty ? "warning" : "neutral"} />
|
||||
</div>
|
||||
<p>{dirty ? "品牌、文案和媒体只在本页预览;保存后才会应用到公开页面。" : "保存后公开页面会在应用渲染前读取新配置,不会先闪现旧品牌。"}</p>
|
||||
<p>{dirty ? "品牌、SEO、备案、皮肤和媒体只在本页预览;保存后才会应用。" : "公开页面会在应用渲染前读取品牌与皮肤,不会先闪现旧站点身份。"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-appearance-command-actions">
|
||||
@@ -497,17 +599,87 @@ export default function AppearanceSettingsPage() {
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section"
|
||||
icon={<Type className="size-4" aria-hidden="true" />}
|
||||
title="3. 皮肤方案"
|
||||
description="当前版本先统一使用经典皮肤;后续颜色、字体、圆角和组件密度会作为独立皮肤包管理。"
|
||||
status={<AdminStatusBadge label="经典皮肤" tone="info" />}
|
||||
icon={<Search className="size-4" aria-hidden="true" />}
|
||||
title="3. SEO 信息"
|
||||
description="配置浏览器标题、搜索摘要和关键词;留空时标题与描述会自动跟随当前站点名称。"
|
||||
status={<AdminStatusBadge label={seoTitle || seoDescription || seoKeywords ? "已自定义" : "自动跟随品牌"} tone={seoTitle || seoDescription || seoKeywords ? "success" : "neutral"} />}
|
||||
>
|
||||
<div className="admin-appearance-skin-note">
|
||||
<span>当前皮肤</span>
|
||||
<strong>{setting.skinId === "classic" ? "经典皮肤(Classic)" : setting.skinId}</strong>
|
||||
<small>皮肤方案与品牌内容分离,后续切换皮肤时不会覆盖品牌名、英文标识、Logo、登录页文案或媒体。</small>
|
||||
<Form className="admin-appearance-form admin-appearance-section-form" layout="vertical" requiredMark={false} disabled={saving || refreshing || restoring}>
|
||||
<div className="admin-appearance-form-grid">
|
||||
<Form.Item label="SEO 标题" extra="建议简洁描述站点用途;留空时使用站点名称。">
|
||||
<Input value={seoTitle} maxLength={70} showCount placeholder={draftBrandName} onChange={(event) => setSeoTitle(event.target.value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="SEO 关键词" extra="以逗号分隔;Google 不使用该标签排名,仍可供其他工具读取。">
|
||||
<Input value={seoKeywords} maxLength={300} showCount placeholder="AI 影视, AI 短剧, 创作工作台" onChange={(event) => setSeoKeywords(event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="SEO 描述" extra="建议用一到两句话准确概括站点,避免重复堆砌关键词;留空时自动生成品牌描述。">
|
||||
<Input.TextArea
|
||||
value={seoDescription}
|
||||
maxLength={200}
|
||||
showCount
|
||||
autoSize={{ minRows: 3, maxRows: 5 }}
|
||||
placeholder={`${draftBrandName},面向 AI 影视与短剧创作的工作台。`}
|
||||
onChange={(event) => setSeoDescription(event.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section"
|
||||
icon={<Globe2 className="size-4" aria-hidden="true" />}
|
||||
title="4. 首页页尾与备案"
|
||||
description="配置公开登录首页底部的版权和备案信息;备案号启用后固定链接工信部备案管理系统。"
|
||||
status={<AdminStatusBadge label={icpFilingEnabled ? "展示备案号" : "未展示备案号"} tone={icpFilingEnabled ? "success" : "neutral"} />}
|
||||
>
|
||||
<div className="admin-appearance-section-form admin-appearance-footer-settings">
|
||||
<Form className="admin-appearance-form" layout="vertical" requiredMark={false} disabled={saving || refreshing || restoring}>
|
||||
<Form.Item label="版权信息" extra="留空时自动使用当前年份和站点名称。">
|
||||
<Input
|
||||
prefix={<Copyright className="size-3.5" aria-hidden="true" />}
|
||||
value={footerCopyright}
|
||||
maxLength={160}
|
||||
showCount
|
||||
placeholder={`© ${new Date().getFullYear()} ${draftBrandName}. All rights reserved.`}
|
||||
onChange={(event) => setFooterCopyright(event.target.value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="备案号" extra="请填写真实备案号,例如“蜀ICP备XXXXXXXX号”;系统不会替你申请或核验备案。">
|
||||
<Input value={icpFilingNumber} maxLength={64} showCount placeholder="例如:蜀ICP备XXXXXXXX号" onChange={(event) => setIcpFilingNumber(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div className="admin-appearance-logo-frame-option">
|
||||
<div className="admin-appearance-logo-frame-copy">
|
||||
<strong>在首页底部显示备案号</strong>
|
||||
<p id="appearance-icp-help">启用后,备案号会显示在登录、注册和找回密码页底部,并链接至 https://beian.miit.gov.cn/ 供公众查询。</p>
|
||||
</div>
|
||||
<div className="admin-appearance-logo-frame-control">
|
||||
<span>{icpFilingEnabled ? "已显示" : "未显示"}</span>
|
||||
<Switch checked={icpFilingEnabled} disabled={saving || refreshing || restoring} aria-label="在首页底部显示备案号" aria-describedby="appearance-icp-help" onChange={setIcpFilingEnabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
|
||||
<SettingsSectionCard
|
||||
className="admin-appearance-section"
|
||||
icon={<Type className="size-4" aria-hidden="true" />}
|
||||
title="5. 皮肤主题"
|
||||
description="默认主题保持项目原始样式且不可更改;其他主题可新建、复制、改名、删除,并分别定义浅色、深色、控件样式与交互反馈。"
|
||||
status={<AdminStatusBadge label={selectedSkin.name} tone="info" />}
|
||||
>
|
||||
<SkinThemeEditor
|
||||
themes={skinThemes}
|
||||
selectedID={skinId}
|
||||
disabled={saving || refreshing || restoring}
|
||||
onSelect={setSkinId}
|
||||
onCreate={() => duplicateSkin("classic")}
|
||||
onDuplicate={duplicateSkin}
|
||||
onDelete={deleteSkin}
|
||||
onChange={changeSkin}
|
||||
/>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
)}
|
||||
</AdminPageFrame>
|
||||
@@ -623,10 +795,31 @@ function normalizeDraftCopy(value: string) {
|
||||
return value.replace(/\r\n?/g, "\n").trim();
|
||||
}
|
||||
|
||||
function normalizeSingleLine(value: string) {
|
||||
return value.replace(/\r\n?/g, " ").trim();
|
||||
}
|
||||
|
||||
function hasUnsupportedControlCharacter(value: string) {
|
||||
return Array.from(value).some((character) => character !== "\n" && /[\u0000-\u001f\u007f]/.test(character));
|
||||
}
|
||||
|
||||
function validateSkinDrafts(themes: SkinDefinition[], selectedID: string) {
|
||||
if (!themes.length || themes.length > 16) return "皮肤主题数量必须为 1 到 16 套";
|
||||
const ids = new Set<string>();
|
||||
for (const theme of themes) {
|
||||
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(theme.id) || ids.has(theme.id)) return "皮肤主题 ID 无效或重复";
|
||||
ids.add(theme.id);
|
||||
if (!theme.name.trim() || Array.from(theme.name.trim()).length > 40) return "皮肤主题名称必须为 1 到 40 个字符";
|
||||
if (Array.from(theme.description.trim()).length > 100) return "皮肤主题说明不能超过 100 个字符";
|
||||
const invalidColor = [...Object.values(theme.tokens.light), ...Object.values(theme.tokens.dark)].some((color) => !/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(color));
|
||||
if (invalidColor) return `主题“${theme.name}”存在无效颜色,请使用 6 或 8 位十六进制色值`;
|
||||
if (theme.tokens.components.controlHeightSmall > theme.tokens.components.controlHeight || theme.tokens.components.controlHeight > theme.tokens.components.controlHeightLarge) return `主题“${theme.name}”的控件高度顺序无效`;
|
||||
if (theme.tokens.components.motionFast > theme.tokens.components.motionNormal) return `主题“${theme.name}”的快速动效不能慢于常规动效`;
|
||||
}
|
||||
if (!ids.has("classic") || !ids.has(selectedID)) return "默认主题或当前启用主题不存在";
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))}KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Button, Form, Input, InputNumber, Segmented, Select, Tooltip } from "antd";
|
||||
import { Check, Copy, LockKeyhole, Moon, Plus, Sparkles, Sun, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { SKIN_COLOR_GROUPS, SKIN_COMPONENT_NUMBER_FIELDS, skinSwatches, type SkinComponentTokens, type SkinDefinition, type SkinModeTokens, type SkinThemeMode } from "@/lib/skin-themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AdminStatusBadge } from "@/pages/admin/components/admin-ui";
|
||||
|
||||
export function SkinThemeEditor({
|
||||
themes,
|
||||
selectedID,
|
||||
disabled,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
onChange,
|
||||
}: {
|
||||
themes: SkinDefinition[];
|
||||
selectedID: string;
|
||||
disabled: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onCreate: () => void;
|
||||
onDuplicate: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onChange: (theme: SkinDefinition) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<SkinThemeMode>("light");
|
||||
const selected = useMemo(() => themes.find((theme) => theme.id === selectedID) || themes[0], [selectedID, themes]);
|
||||
if (!selected) return null;
|
||||
const editorDisabled = disabled || selected.locked;
|
||||
|
||||
const updateIdentity = (patch: Partial<Pick<SkinDefinition, "name" | "description">>) => onChange({ ...selected, ...patch });
|
||||
const updateColor = (key: keyof SkinModeTokens, value: string) =>
|
||||
onChange({
|
||||
...selected,
|
||||
tokens: { ...selected.tokens, [mode]: { ...selected.tokens[mode], [key]: value.trim().toLowerCase() } },
|
||||
});
|
||||
const updateComponent = <Key extends keyof SkinComponentTokens>(key: Key, value: SkinComponentTokens[Key]) => onChange({ ...selected, tokens: { ...selected.tokens, components: { ...selected.tokens.components, [key]: value } } });
|
||||
|
||||
return (
|
||||
<div className="admin-skin-workspace">
|
||||
<div className="admin-skin-library-toolbar">
|
||||
<div>
|
||||
<strong>主题库</strong>
|
||||
<span>共 {themes.length} 套,最多 16 套</span>
|
||||
</div>
|
||||
<div>
|
||||
<Button icon={<Plus className="size-3.5" />} disabled={disabled || themes.length >= 16} onClick={onCreate}>
|
||||
从默认新建
|
||||
</Button>
|
||||
<Button icon={<Copy className="size-3.5" />} disabled={disabled || themes.length >= 16} onClick={() => onDuplicate(selected.id)}>
|
||||
复制当前
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-skin-library" role="radiogroup" aria-label="选择全站皮肤主题">
|
||||
{themes.map((theme) => {
|
||||
const active = theme.id === selected.id;
|
||||
const swatches = skinSwatches(theme);
|
||||
return (
|
||||
<article key={theme.id} className={cn("admin-skin-card", active && "is-selected")}>
|
||||
<button type="button" className="admin-skin-card-select" role="radio" aria-checked={active} disabled={disabled} onClick={() => onSelect(theme.id)}>
|
||||
<span className="admin-skin-card-preview" aria-hidden="true" style={{ background: theme.tokens.dark.canvas, borderRadius: theme.tokens.components.cardRadius }}>
|
||||
<span style={{ background: theme.tokens.dark.surface, borderRadius: theme.tokens.components.inputRadius }}>
|
||||
{swatches.map((color, index) => (
|
||||
<i key={`${color}-${index}`} style={{ background: color }} />
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
<span className="admin-skin-card-copy">
|
||||
<strong>{theme.name}</strong>
|
||||
<small>{theme.description || "暂无说明"}</small>
|
||||
</span>
|
||||
{theme.locked ? <LockKeyhole className="admin-skin-card-lock" aria-label="系统默认主题不可修改" /> : null}
|
||||
{active ? (
|
||||
<span className="admin-skin-card-check" aria-hidden="true">
|
||||
<Check />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
<div className="admin-skin-card-actions">
|
||||
<Tooltip title="复制这套主题">
|
||||
<Button type="text" size="small" aria-label={`复制 ${theme.name}`} icon={<Copy className="size-3.5" />} disabled={disabled || themes.length >= 16} onClick={() => onDuplicate(theme.id)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={theme.locked ? "系统默认主题不可删除" : "删除这套主题"}>
|
||||
<Button
|
||||
className="admin-skin-delete-button"
|
||||
type="primary"
|
||||
danger
|
||||
size="small"
|
||||
aria-label={`删除 ${theme.name}`}
|
||||
icon={<Trash2 className="size-3.5" />}
|
||||
disabled={disabled || theme.locked}
|
||||
onClick={() => onDelete(theme.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="admin-skin-editor">
|
||||
<div className="admin-skin-editor-heading">
|
||||
<div>
|
||||
<span>当前编辑</span>
|
||||
<strong>{selected.name}</strong>
|
||||
</div>
|
||||
<AdminStatusBadge label={selected.locked ? "系统默认 · 只读" : "自定义主题"} tone={selected.locked ? "neutral" : "info"} />
|
||||
</div>
|
||||
|
||||
{selected.locked ? (
|
||||
<div className="admin-skin-locked-note">
|
||||
<LockKeyhole className="size-4" aria-hidden="true" />
|
||||
<span>经典黑白始终保留项目原始视觉,不能改名、修改或删除。点击“从默认新建”即可复制全部参数后自由调整。</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Form className="admin-appearance-form admin-skin-identity-form" layout="vertical" requiredMark={false} disabled={editorDisabled}>
|
||||
<div className="admin-appearance-form-grid">
|
||||
<Form.Item label="主题名称" extra="1–40 个字符,可随时更改。">
|
||||
<Input value={selected.name} maxLength={40} showCount onChange={(event) => updateIdentity({ name: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="主题说明" extra="用于主题库辨识,不影响前台文案。">
|
||||
<Input value={selected.description} maxLength={100} showCount onChange={(event) => updateIdentity({ description: event.target.value })} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div className="admin-skin-mode-bar">
|
||||
<div>
|
||||
<strong>颜色体系</strong>
|
||||
<span>同一用途的控件统一消费下列语义颜色</span>
|
||||
</div>
|
||||
<Segmented<SkinThemeMode>
|
||||
value={mode}
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="admin-skin-mode-label">
|
||||
<Sun />
|
||||
浅色
|
||||
</span>
|
||||
),
|
||||
value: "light",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="admin-skin-mode-label">
|
||||
<Moon />
|
||||
深色
|
||||
</span>
|
||||
),
|
||||
value: "dark",
|
||||
},
|
||||
]}
|
||||
onChange={setMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SkinThemePreview theme={selected} mode={mode} />
|
||||
|
||||
<div className="admin-skin-color-groups">
|
||||
{SKIN_COLOR_GROUPS.map((group, index) => (
|
||||
<details key={group.key} className="admin-skin-token-group" open={index < 2}>
|
||||
<summary>
|
||||
<span>{group.label}</span>
|
||||
<small>{group.fields.length} 项</small>
|
||||
</summary>
|
||||
<div className="admin-skin-color-grid">
|
||||
{group.fields.map((field) => (
|
||||
<ColorTokenField key={field.key} label={field.label} help={field.help} value={selected.tokens[mode][field.key]} disabled={editorDisabled} onChange={(value) => updateColor(field.key, value)} />
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<details className="admin-skin-token-group admin-skin-component-group" open>
|
||||
<summary>
|
||||
<span>控件尺寸、圆角与反馈</span>
|
||||
<small>17 项</small>
|
||||
</summary>
|
||||
<div className="admin-skin-component-grid">
|
||||
{SKIN_COMPONENT_NUMBER_FIELDS.map((field) => (
|
||||
<label key={field.key} className="admin-skin-number-field">
|
||||
<span>{field.label}</span>
|
||||
<InputNumber
|
||||
value={selected.tokens.components[field.key]}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step || 1}
|
||||
addonAfter={field.suffix || undefined}
|
||||
disabled={editorDisabled}
|
||||
onChange={(value) => {
|
||||
if (typeof value === "number") updateComponent(field.key, value);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<label className="admin-skin-number-field">
|
||||
<span>阴影风格</span>
|
||||
<Select
|
||||
value={selected.tokens.components.shadowStyle}
|
||||
disabled={editorDisabled}
|
||||
options={[
|
||||
{ label: "无阴影", value: "none" },
|
||||
{ label: "柔和阴影", value: "soft" },
|
||||
{ label: "强层次阴影", value: "strong" },
|
||||
]}
|
||||
onChange={(value) => updateComponent("shadowStyle", value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorTokenField({ label, help, value, disabled, onChange }: { label: string; help: string; value: string; disabled: boolean; onChange: (value: string) => void }) {
|
||||
const pickerValue = /^#[0-9a-f]{6}$/i.test(value) ? value : /^#[0-9a-f]{8}$/i.test(value) ? value.slice(0, 7) : "#000000";
|
||||
return (
|
||||
<div className="admin-skin-color-field">
|
||||
<span>
|
||||
<strong>{label}</strong>
|
||||
<small>{help}</small>
|
||||
</span>
|
||||
<span className="admin-skin-color-control">
|
||||
<input type="color" value={pickerValue} disabled={disabled} aria-label={`${label}取色器`} title={`选择${label}`} onChange={(event) => onChange(event.target.value)} />
|
||||
<Input value={value} maxLength={9} disabled={disabled} spellCheck={false} status={/^#[0-9a-f]{6}([0-9a-f]{2})?$/i.test(value) ? undefined : "error"} onChange={(event) => onChange(event.target.value)} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkinThemePreview({ theme, mode }: { theme: SkinDefinition; mode: SkinThemeMode }) {
|
||||
const color = theme.tokens[mode];
|
||||
const component = theme.tokens.components;
|
||||
const shadow = component.shadowStyle === "none" ? "none" : component.shadowStyle === "strong" ? "0 18px 44px #0000003d" : "0 10px 28px #00000024";
|
||||
return (
|
||||
<div className="admin-skin-live-preview" style={{ background: color.canvas, color: color.text, borderColor: color.border, borderRadius: component.cardRadius }}>
|
||||
<div className="admin-skin-live-preview-card" style={{ background: color.surface, borderColor: color.border, borderRadius: component.cardRadius, boxShadow: shadow }}>
|
||||
<span className="admin-skin-live-preview-icon" style={{ background: color.surfaceSubtle, color: color.iconActive, borderRadius: component.inputRadius }}>
|
||||
<Sparkles />
|
||||
</span>
|
||||
<div>
|
||||
<strong>控件组合预览</strong>
|
||||
<small style={{ color: color.textMuted }}>按钮、输入、选中与状态均来自同一套主题参数</small>
|
||||
</div>
|
||||
<span className="admin-skin-live-preview-status" style={{ background: color.selected, color: color.selectedForeground, borderRadius: component.menuRadius }}>
|
||||
已选中
|
||||
</span>
|
||||
<span className="admin-skin-live-preview-menu" style={{ background: color.selected, color: color.selectedForeground, borderRadius: component.menuRadius }}>
|
||||
后台菜单
|
||||
</span>
|
||||
<span
|
||||
className="admin-skin-live-preview-input"
|
||||
style={{ height: component.controlHeight, background: color.control, borderColor: color.controlBorder, borderWidth: component.borderWidth, borderRadius: component.inputRadius, color: color.textMuted }}
|
||||
>
|
||||
输入框状态
|
||||
</span>
|
||||
<span className="admin-skin-live-preview-button" style={{ height: component.controlHeight, background: color.primary, color: color.primaryForeground, borderRadius: component.buttonRadius, fontWeight: component.buttonFontWeight }}>
|
||||
主要操作
|
||||
</span>
|
||||
<span className="admin-skin-live-preview-switches" aria-label="开关开启与关闭状态预览">
|
||||
<i className="is-on" style={{ background: color.switchChecked }}>
|
||||
<b style={{ background: color.switchCheckedHandle }} />
|
||||
</i>
|
||||
<i style={{ background: color.switchUnchecked }}>
|
||||
<b style={{ background: color.switchUncheckedHandle }} />
|
||||
</i>
|
||||
</span>
|
||||
<span className="admin-skin-live-preview-danger" style={{ background: color.danger, color: color.dangerForeground, borderRadius: component.buttonRadius }}>
|
||||
删除
|
||||
</span>
|
||||
<span className="admin-skin-live-preview-signals">
|
||||
<i style={{ background: color.success }} />
|
||||
<i style={{ background: color.warning }} />
|
||||
<i style={{ background: color.danger }} />
|
||||
<i style={{ background: color.info }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useBlocker } from "react-router";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAdminLibTVSetting, testAdminLibTV, updateAdminLibTVSetting, type AdminLibTVSetting } from "@/services/api/libtv";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminStatusBadge, configuredSecretText, SettingsSectionCard } from "../components/admin-ui";
|
||||
|
||||
@@ -12,6 +13,7 @@ type ConnectionTestStatus = "idle" | "testing" | "success" | "error";
|
||||
|
||||
export default function LibTVSettingsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const [setting, setSetting] = useState<AdminLibTVSetting | null>(null);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [token, setToken] = useState("");
|
||||
@@ -311,7 +313,7 @@ export default function LibTVSettingsPage() {
|
||||
className="admin-third-party-section admin-third-party-credential-section"
|
||||
icon={<KeyRound className="size-4" aria-hidden="true" />}
|
||||
title="1. 配置 LibTV 服务端访问凭据"
|
||||
description="Token 只提交到影策服务端,保存后浏览器不会再收到或显示明文。"
|
||||
description={`Token 只提交到${brandName}服务端,保存后浏览器不会再收到或显示明文。`}
|
||||
status={<AdminStatusBadge label={credentialDraftLabel} tone={clearTokenDraft ? "warning" : draftHasToken ? "success" : "neutral"} />}
|
||||
footer={!draftHasToken ? <ThirdPartySaveFooter setting={setting} dirty={dirty} saving={saving} refreshing={refreshing} onReset={resetDraft} onSave={submitSave} /> : undefined}
|
||||
>
|
||||
@@ -399,7 +401,7 @@ export default function LibTVSettingsPage() {
|
||||
<span aria-hidden="true">→</span>
|
||||
<FlowStep icon={<Wifi className="size-4" />} label="读取画布" detail="按 UUID 请求" />
|
||||
<span aria-hidden="true">→</span>
|
||||
<FlowStep icon={<CloudDownload className="size-4" />} label="导入影策" detail="生成节点与连线" />
|
||||
<FlowStep icon={<CloudDownload className="size-4" />} label={`导入${brandName}`} detail="生成节点与连线" />
|
||||
</div>
|
||||
</SettingsSectionCard>
|
||||
</div>
|
||||
@@ -411,7 +413,7 @@ export default function LibTVSettingsPage() {
|
||||
className="admin-third-party-section admin-third-party-test-card"
|
||||
icon={<Wifi className="size-4" aria-hidden="true" />}
|
||||
title="3. 验证已保存的 LibTV 凭据"
|
||||
description="使用服务端当前保存的 Token 发起一次只读请求,不会向 LibTV 或当前影策画布写入内容。"
|
||||
description={`使用服务端当前保存的 Token 发起一次只读请求,不会向 LibTV 或当前${brandName}画布写入内容。`}
|
||||
status={<TestStatus status={testStatus} />}
|
||||
>
|
||||
<div className="admin-third-party-test-controls">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ArrowLeft, Play } from "lucide-react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router";
|
||||
|
||||
import { BrandLogo } from "@/components/brand/brand-logo";
|
||||
import { SiteComplianceFooter } from "@/components/layout/site-compliance-footer";
|
||||
import { aceternityMotion } from "@/lib/aceternity-motion";
|
||||
import { getAntThemeConfig } from "@/lib/app-theme";
|
||||
import { brandStudioLabel, useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
@@ -54,12 +55,12 @@ export function AuthScene() {
|
||||
const copy = recovery ? authCopy.recovery : activeTab === "register" ? authCopy.register : authCopy.login;
|
||||
|
||||
return (
|
||||
<main className="h-dvh min-h-0 overflow-y-auto bg-[#08090c] text-white lg:overflow-hidden">
|
||||
<main className="auth-scene h-dvh min-h-0 overflow-y-auto 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={`${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 aria-hidden className="auth-scene-video-blend absolute inset-y-0 right-0 hidden w-[clamp(120px,14vw,240px)] 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">
|
||||
<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" }} />} />
|
||||
@@ -82,11 +83,8 @@ export function AuthScene() {
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
<section className="relative flex min-h-[620px] items-start justify-center overflow-y-auto bg-[#0b0c10] px-4 pb-8 pt-20 sm:px-8 lg:min-h-0 lg:px-10 lg:pb-10 lg:pt-20">
|
||||
<Link
|
||||
to="/"
|
||||
className="absolute right-5 top-5 z-20 inline-flex h-9 items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-4 text-xs text-white/58 backdrop-blur-xl transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white lg:right-8 lg:top-8"
|
||||
>
|
||||
<section className="auth-scene-form-pane relative flex min-h-[660px] items-start justify-center overflow-y-auto px-4 pb-24 pt-20 sm:px-8 lg:min-h-0 lg:px-10 lg:pb-24 lg:pt-20">
|
||||
<Link to="/" className="auth-scene-return absolute right-5 top-5 z-20 inline-flex h-9 items-center gap-2 rounded-full px-4 text-xs backdrop-blur-xl transition lg:right-8 lg:top-8">
|
||||
<ArrowLeft className="size-3.5" />
|
||||
返回首页
|
||||
</Link>
|
||||
@@ -98,13 +96,13 @@ export function AuthScene() {
|
||||
transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }}
|
||||
className="my-auto w-full max-w-[460px]"
|
||||
>
|
||||
<ConfigProvider theme={getAntThemeConfig(true)}>
|
||||
<div className="auth-card-dark h-auto overflow-hidden rounded-lg bg-[#121318]/94 shadow-[0_28px_80px_rgba(0,0,0,.34)] backdrop-blur-2xl">
|
||||
<ConfigProvider theme={getAntThemeConfig(true, appearance.activeSkin)}>
|
||||
<div className="auth-card-dark auth-scene-card h-auto overflow-hidden rounded-lg backdrop-blur-2xl">
|
||||
<section aria-label={copy.title} className={`flex flex-col ${recovery ? "min-h-[600px]" : activeTab === "login" ? "min-h-[500px]" : "min-h-[620px] sm:min-h-[640px]"}`}>
|
||||
<header className="px-6 pb-5 pt-6 sm:px-8 sm:pt-7">
|
||||
<p className="text-xs font-semibold tracking-[0.18em] text-blue-300/80">{copy.eyebrow}</p>
|
||||
<p className="auth-scene-eyebrow text-xs font-semibold tracking-[0.18em]">{copy.eyebrow}</p>
|
||||
<h2 className="mt-2 text-3xl font-semibold">{copy.title}</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-white/45">{copy.description}</p>
|
||||
<p className="auth-scene-muted mt-2 text-sm leading-6">{copy.description}</p>
|
||||
</header>
|
||||
{!recovery ? (
|
||||
<div className="px-6 sm:px-8">
|
||||
@@ -118,6 +116,7 @@ export function AuthScene() {
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
</motion.div>
|
||||
<SiteComplianceFooter variant="auth" className="absolute inset-x-0 bottom-0" />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -26,9 +26,11 @@ import { resourceFileUrl, resourceStorageKey, uploadResourceFile } from "@/servi
|
||||
import { primeResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import { ensureCanvasNodeAsset } from "@/services/project-asset-sync";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
|
||||
export default function CanvasPage() {
|
||||
const { message } = App.useApp();
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -497,7 +499,7 @@ export default function CanvasPage() {
|
||||
移出项目
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="small" disabled={!hydrated} icon={<Download className="size-3.5" />} onClick={() => void exportCanvasProjects(selectedProjects, `影策画布-${selectedIds.length}个画布`)}>
|
||||
<Button size="small" disabled={!hydrated} icon={<Download className="size-3.5" />} onClick={() => void exportCanvasProjects(selectedProjects, `${brandName}画布-${selectedIds.length}个画布`)}>
|
||||
导出
|
||||
</Button>
|
||||
<Button size="small" danger disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
|
||||
|
||||
@@ -8,6 +8,7 @@ import "@/lib/plugins/builtin";
|
||||
import { createEagleAssetSource, EAGLE_DEFAULT_BASE_URL, eagleAssetPlugin } from "@/lib/plugins/builtin/eagle";
|
||||
import type { ExternalAssetFolder, ExternalAssetItem } from "@/lib/plugins/plugin-types";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
|
||||
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
|
||||
@@ -16,6 +17,7 @@ import "./eagle.css";
|
||||
export default function EagleLibraryPage() {
|
||||
const navigate = useNavigate();
|
||||
const { message } = App.useApp();
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
const hydrated = usePluginStore((state) => state.hydrated);
|
||||
const ensurePlugin = usePluginStore((state) => state.ensurePlugin);
|
||||
@@ -133,13 +135,13 @@ export default function EagleLibraryPage() {
|
||||
<WorkspacePage grid className="library-page eagle-library-page">
|
||||
<PageHeader
|
||||
title="Eagle 素材库"
|
||||
description="把 Eagle 作为影策的外部素材来源,直接浏览和管理 Eagle 原始文件。"
|
||||
actions={<Button icon={<ArrowLeft className="size-3.5" />} onClick={() => navigate("/assets")}>返回影策素材库</Button>}
|
||||
description={`把 Eagle 作为${brandName}的外部素材来源,直接浏览和管理 Eagle 原始文件。`}
|
||||
actions={<Button icon={<ArrowLeft className="size-3.5" />} onClick={() => navigate("/assets")}>返回{brandName}素材库</Button>}
|
||||
/>
|
||||
<section className="mt-4 library-card-surface flex min-h-72 flex-col items-center justify-center rounded-[var(--r-xl)] px-6 py-10 text-center">
|
||||
<span className="grid size-14 place-items-center rounded-[var(--r-lg)] bg-[var(--workspace-accent-soft)] text-[var(--workspace-accent)]"><FolderOpen className="size-7" aria-hidden="true" /></span>
|
||||
<h2 className="mt-4 text-base font-semibold">先启用 Eagle 素材来源</h2>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-foreground/55">启用后,这里会直接显示 Eagle 原本的文件夹和文件,不会把素材复制成影策本地素材。</p>
|
||||
<p className="mt-2 max-w-md text-sm leading-6 text-foreground/55">启用后,这里会直接显示 Eagle 原本的文件夹和文件,不会把素材复制成{brandName}本地素材。</p>
|
||||
<Button type="primary" className="mt-5" icon={<Settings2 className="size-4" />} onClick={() => navigate("/plugins")}>去插件中心启用</Button>
|
||||
</section>
|
||||
</WorkspacePage>
|
||||
@@ -152,13 +154,13 @@ export default function EagleLibraryPage() {
|
||||
<div className="studio-band">
|
||||
<PageHeader
|
||||
title="Eagle 素材库"
|
||||
description="Eagle 是影策的外部素材来源;这里复用影策素材库的浏览方式,直接读取和写入 Eagle 原始文件。"
|
||||
description={`Eagle 是${brandName}的外部素材来源;这里复用${brandName}素材库的浏览方式,直接读取和写入 Eagle 原始文件。`}
|
||||
meta={<span className="app-projects-header-meta assets-header-meta">{error ? "Eagle · 连接异常" : "Eagle · 已连接"}</span>}
|
||||
actions={(
|
||||
<div className="assets-header-actions">
|
||||
<div className="assets-header-action-buttons">
|
||||
<Button className="library-primary-action" type="primary" icon={<Upload className="size-3.5" />} onClick={() => fileInputRef.current?.click()} disabled={working}>写入素材</Button>
|
||||
<Button icon={<ArrowLeft className="size-3.5" />} onClick={() => navigate("/assets")}>影策素材库</Button>
|
||||
<Button icon={<ArrowLeft className="size-3.5" />} onClick={() => navigate("/assets")}>{brandName}素材库</Button>
|
||||
<Button icon={<Settings2 className="size-3.5" />} onClick={() => navigate("/plugins")}>插件设置</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,7 +212,7 @@ export default function EagleLibraryPage() {
|
||||
<h2 className="text-base font-semibold">{currentFolder?.name || "全部素材"}</h2>
|
||||
<span className="app-projects-header-meta">{items.length} 个素材</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-foreground/48">{currentFolder ? "当前文件夹由 Eagle 管理,影策只负责展示和调用。" : "当前展示 Eagle 素材库中的全部文件。"}</p>
|
||||
<p className="mt-1 text-xs text-foreground/48">{currentFolder ? `当前文件夹由 Eagle 管理,${brandName}只负责展示和调用。` : "当前展示 Eagle 素材库中的全部文件。"}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button icon={<FolderPlus className="size-3.5" />} onClick={() => setFolderName((value) => value ? "" : "新文件夹")}>新建文件夹</Button>
|
||||
@@ -348,4 +350,4 @@ function externalFolderPath(folders: ExternalAssetFolder[], folderId: string) {
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ART_CRITIQUE_PLUGIN_ID } from "@/lib/art-critique/contracts";
|
||||
import type { PluginManifest, RegisteredPlugin } from "@/lib/plugins/plugin-types";
|
||||
import { getEagleLibrary, type EagleFolder } from "@/services/api/eagle";
|
||||
import { fetchPlugins, setUserPluginEnabled, type BackendPlugin, type PluginState } from "@/services/api/plugins";
|
||||
import { useAppearanceStore } from "@/stores/use-appearance-store";
|
||||
import { usePluginStore } from "@/stores/use-plugin-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
@@ -64,6 +65,7 @@ const protocolSectionMeta = [
|
||||
export default function PluginsPage() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const brandName = useAppearanceStore((state) => state.appearance.brandName);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const features = useUserStore((state) => state.features);
|
||||
const installations = usePluginStore((state) => state.installations);
|
||||
@@ -516,14 +518,14 @@ export default function PluginsPage() {
|
||||
<div className="min-w-0">
|
||||
<label htmlFor="eagle-base-url">Eagle 本地 API 地址</label>
|
||||
<Input id="eagle-base-url" aria-label="Eagle 本地 API 地址" value={eagleBaseUrl} onChange={(event) => setEagleBaseUrl(event.target.value)} placeholder="http://localhost:41595" />
|
||||
<p>Eagle 必须在本机运行;影策通过插件直接读取和写入 Eagle 原始文件。</p>
|
||||
<p>Eagle 必须在本机运行;{brandName}通过插件直接读取和写入 Eagle 原始文件。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="plugin-setting-label-row">
|
||||
<label htmlFor="eagle-auto-upload-generated">自动归档生成结果</label>
|
||||
<Switch id="eagle-auto-upload-generated" checked={eagleAutoUploadGenerated} onChange={setEagleAutoUploadGenerated} aria-label="自动归档生成结果到 Eagle" />
|
||||
</div>
|
||||
<p>图片、视频和音频生成成功后,自动写入 Eagle;影策本地素材仍会保留。</p>
|
||||
<p>图片、视频和音频生成成功后,自动写入 Eagle;{brandName}本地素材仍会保留。</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="plugin-setting-label-row">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 上传统一插件包
|
||||
|
||||
影策只有一种插件:一个版本化 Manifest 描述一个插件包可以向宿主贡献的全部能力。Provider、工作流、画布节点、媒体转换、素材源、Agent 和命令都写在同一个 `contributes` 下;运行时可以不同,但不会再出现“协议插件”和“UI 插件”两套清单。
|
||||
宿主只有一种插件:一个版本化 Manifest 描述一个插件包可以向宿主贡献的全部能力。Provider、工作流、画布节点、媒体转换、素材源、Agent 和命令都写在同一个 `contributes` 下;运行时可以不同,但不会再出现“协议插件”和“UI 插件”两套清单。
|
||||
|
||||
## 1. 包格式
|
||||
|
||||
@@ -65,9 +65,9 @@ my-plugin.yingce-plugin
|
||||
|
||||
## 4. Provider 与工作流
|
||||
|
||||
请求字段右侧只能引用影策统一请求,例如 `request.prompt`、`request.model`、`request.duration`、`request.extra`。声明式字段也支持安全的对象/数组路径(例如 `request.images.0.url`)和通用字符串变换(`|trim`、`|lower`、`|upper`),不执行插件代码。渠道声明的枚举值由画布完整保留,插件可使用通用大小写变换适配上游,不应要求宿主增加渠道专用归一化。字段解析为空时会省略该字段,适合映射可选参考素材。Provider 如果把 `requiresPublicMediaUrls` 设为 `true`,宿主会在请求前把用户资源转换为短期签名公网 URL。响应映射支持对象路径和数组下标,并可用 `errorPaths` 声明上游错误码路径。同步 provider 只需要 `create` 和 `response`;异步 provider 再声明 `poll`。
|
||||
请求字段右侧只能引用宿主统一请求,例如 `request.prompt`、`request.model`、`request.duration`、`request.extra`。声明式字段也支持安全的对象/数组路径(例如 `request.images.0.url`)和通用字符串变换(`|trim`、`|lower`、`|upper`),不执行插件代码。渠道声明的枚举值由画布完整保留,插件可使用通用大小写变换适配上游,不应要求宿主增加渠道专用归一化。字段解析为空时会省略该字段,适合映射可选参考素材。Provider 如果把 `requiresPublicMediaUrls` 设为 `true`,宿主会在请求前把用户资源转换为短期签名公网 URL。响应映射支持对象路径和数组下标,并可用 `errorPaths` 声明上游错误码路径。同步 provider 只需要 `create` 和 `response`;异步 provider 再声明 `poll`。
|
||||
|
||||
结果 URL 如果是短期地址,必须设置 `resultEphemeral: true`。宿主会在任务完成后立即下载并保存资源,画布和任务记录只保存影策资源引用。
|
||||
结果 URL 如果是短期地址,必须设置 `resultEphemeral: true`。宿主会在任务完成后立即下载并保存资源,画布和任务记录只保存宿主资源引用。
|
||||
|
||||
## 5. 安全与运行边界
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export function UploadPluginModal({ open, onClose, onUpload }: UploadPluginModal
|
||||
<span className="plugin-upload-panel-icon"><CloudUpload className="size-5" /></span>
|
||||
<div>
|
||||
<h2>安装插件包</h2>
|
||||
<p>选择统一影策插件包,安装后会立即进入插件中心。</p>
|
||||
<p>选择统一站点插件包,安装后会立即进入插件中心。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
Reference in new issue
Block a user