feat(*): 画布助手 - 新增 Live2D 配置导入与模型渲染支持
This commit is contained in:
@@ -16,7 +16,7 @@ export default function StorageResourcesPanel() {
|
||||
const { message, modal } = App.useApp();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const keyword = searchParams.get("filter") || "";
|
||||
const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file"]);
|
||||
const kind = normalizeOption(searchParams.get("kind"), ["image", "video", "audio", "file", "live2d"]);
|
||||
const status = normalizeOption(searchParams.get("status"), ["pending", "ready", "failed", "deleted"]);
|
||||
const provider = normalizeOption(searchParams.get("provider"), ["local", "aliyun", "tencent", "qiniu", "s3"]);
|
||||
const userId = searchParams.get("userId") || "";
|
||||
@@ -317,6 +317,7 @@ const kindOptions = [
|
||||
{ label: "视频", value: "video" },
|
||||
{ label: "音频", value: "audio" },
|
||||
{ label: "文件", value: "file" },
|
||||
{ label: "Live2D 模型", value: "live2d" },
|
||||
];
|
||||
const statusOptions = [
|
||||
{ label: "全部状态", value: "all" },
|
||||
@@ -349,7 +350,7 @@ function fileName(objectKey: string) {
|
||||
return objectKey.split("/").filter(Boolean).at(-1) || "";
|
||||
}
|
||||
function kindLabel(kind: string) {
|
||||
return ({ image: "图片", video: "视频", audio: "音频", file: "文件" } as Record<string, string>)[kind] || kind || "未知";
|
||||
return ({ image: "图片", video: "视频", audio: "音频", file: "文件", live2d: "Live2D" } as Record<string, string>)[kind] || kind || "未知";
|
||||
}
|
||||
function statusLabel(status: string) {
|
||||
return ({ pending: "待处理", ready: "已就绪", failed: "失败", deleted: "已删除" } as Record<string, string>)[status] || status || "未知";
|
||||
|
||||
@@ -10,6 +10,8 @@ import { cn } from "@/lib/utils";
|
||||
import { cloneSkinDefinition, DEFAULT_CLASSIC_SKIN, duplicateSkinDefinition, isSkinButtonFill, normalizeSkinDefinition, type SkinDefinition } from "@/lib/skin-themes";
|
||||
import { SkinThemeEditor } from "@/pages/admin/settings/components/skin-theme-editor";
|
||||
import { WelcomeSetting } from "@/pages/admin/settings/components/welcome-setting";
|
||||
import { CanvasAppearanceEditor } from "./components/canvas-appearance-editor";
|
||||
import { DEFAULT_CANVAS_APPEARANCE, type CanvasAppearance } from "@/lib/canvas/agent-appearance";
|
||||
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";
|
||||
@@ -31,6 +33,8 @@ export default function AppearanceSettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState("brand");
|
||||
const [setting, setSetting] = useState<AdminAppearance | null>(null);
|
||||
const [brandName, setBrandName] = useState("");
|
||||
const [canvas, setCanvas] = useState<CanvasAppearance>(DEFAULT_CANVAS_APPEARANCE);
|
||||
const [canvasUploading, setCanvasUploading] = useState(false);
|
||||
const [brandSlug, setBrandSlug] = useState("");
|
||||
const [authHeroTitle, setAuthHeroTitle] = useState("");
|
||||
const [authHeroDescription, setAuthHeroDescription] = useState("");
|
||||
@@ -61,7 +65,7 @@ export default function AppearanceSettingsPage() {
|
||||
|
||||
const dirty =
|
||||
Boolean(setting) &&
|
||||
(brandName.trim() !== setting?.brandName ||
|
||||
(JSON.stringify(canvas) !== JSON.stringify(setting?.canvas || DEFAULT_CANVAS_APPEARANCE) || canvasUploading || brandName.trim() !== setting?.brandName ||
|
||||
brandSlug.trim().toLocaleLowerCase() !== setting?.brandSlug ||
|
||||
normalizeDraftCopy(authHeroTitle) !== setting?.authHeroTitle ||
|
||||
normalizeDraftCopy(authHeroDescription) !== setting?.authHeroDescription ||
|
||||
@@ -84,6 +88,7 @@ export default function AppearanceSettingsPage() {
|
||||
const selectedID = themes.some((theme) => theme.id === value.skinId) ? value.skinId : "classic";
|
||||
setSetting({ ...value, skinThemes: themes, skinId: selectedID });
|
||||
setBrandName(value.brandName);
|
||||
setCanvas(value.canvas || DEFAULT_CANVAS_APPEARANCE);
|
||||
setBrandSlug(value.brandSlug);
|
||||
setAuthHeroTitle(value.authHeroTitle);
|
||||
setAuthHeroDescription(value.authHeroDescription);
|
||||
@@ -136,7 +141,7 @@ export default function AppearanceSettingsPage() {
|
||||
if (blocker.state !== "blocked") return;
|
||||
modal.confirm({
|
||||
title: "放弃站点及外观调整?",
|
||||
content: "当前品牌、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。",
|
||||
content: "当前品牌、画布 Agent、SEO、备案、皮肤或媒体配置尚未保存,离开后草稿会丢失。线上站点不会改变。",
|
||||
okText: "放弃并离开",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
@@ -191,7 +196,8 @@ export default function AppearanceSettingsPage() {
|
||||
};
|
||||
|
||||
const discardDraft = () => {
|
||||
if (!setting || saving || restoring) return;
|
||||
if (!setting || saving || restoring || canvasUploading) return;
|
||||
setCanvas(setting.canvas || DEFAULT_CANVAS_APPEARANCE);
|
||||
setBrandName(setting.brandName);
|
||||
setBrandSlug(setting.brandSlug);
|
||||
setAuthHeroTitle(setting.authHeroTitle);
|
||||
@@ -215,13 +221,14 @@ export default function AppearanceSettingsPage() {
|
||||
};
|
||||
|
||||
const requestRefresh = () => {
|
||||
if (canvasUploading) return;
|
||||
if (!dirty) {
|
||||
void load(false);
|
||||
return;
|
||||
}
|
||||
modal.confirm({
|
||||
title: "放弃调整并重新读取?",
|
||||
content: "重新读取会丢弃当前品牌、SEO、备案、皮肤和待上传文件。",
|
||||
content: "重新读取会丢弃当前品牌、画布 Agent、SEO、备案、皮肤和待上传文件。",
|
||||
okText: "放弃并刷新",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
@@ -230,10 +237,10 @@ export default function AppearanceSettingsPage() {
|
||||
};
|
||||
|
||||
const restoreBuiltInAppearance = () => {
|
||||
if (!setting?.configured || saving || refreshing || restoring) return;
|
||||
if (!setting?.configured || saving || refreshing || restoring || canvasUploading) return;
|
||||
modal.confirm({
|
||||
title: "恢复影策默认品牌标识?",
|
||||
content: "品牌名称、英文标识、Logo、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值。已上传文件仍保留在存储资源中,不会被删除。",
|
||||
content: "品牌名称、英文标识、Logo、画布 Agent 名称/文案/形象、登录页文案、视频、封面、SEO、备案和皮肤主题会立即恢复为项目内置值。已上传文件仍保留在存储资源中,不会被删除。",
|
||||
okText: "恢复默认",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
@@ -259,7 +266,12 @@ export default function AppearanceSettingsPage() {
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (!setting || saving || restoring) return;
|
||||
if (!setting || saving || restoring || canvasUploading) return;
|
||||
if (![canvas.agentName, canvas.panelTitle, canvas.welcomeTitle, canvas.inputPlaceholder].every((text) => text.trim())) {
|
||||
setActiveTab("canvas");
|
||||
message.error("请填写助手名称、面板标题、欢迎标题和输入框提示");
|
||||
return;
|
||||
}
|
||||
const nextBrandName = brandName.trim();
|
||||
const nextBrandSlug = brandSlug.trim().toLocaleLowerCase();
|
||||
const nextAuthHeroTitle = normalizeDraftCopy(authHeroTitle);
|
||||
@@ -330,6 +342,7 @@ export default function AppearanceSettingsPage() {
|
||||
ids[slot] = resource.id;
|
||||
}
|
||||
const updated = await updateAdminAppearance({
|
||||
canvas,
|
||||
brandName: nextBrandName,
|
||||
brandSlug: nextBrandSlug,
|
||||
authHeroTitle: nextAuthHeroTitle,
|
||||
@@ -443,17 +456,17 @@ export default function AppearanceSettingsPage() {
|
||||
</div>
|
||||
<div className="admin-appearance-command-actions">
|
||||
{dirty ? (
|
||||
<Button icon={<Undo2 className="size-4" />} disabled={saving || restoring} onClick={discardDraft}>
|
||||
<Button icon={<Undo2 className="size-4" />} disabled={saving || restoring || canvasUploading} onClick={discardDraft}>
|
||||
撤销调整
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<RotateCcw className="size-4" />} loading={restoring} disabled={!setting.configured || saving || refreshing} onClick={restoreBuiltInAppearance}>
|
||||
<Button icon={<RotateCcw className="size-4" />} loading={restoring} disabled={!setting.configured || saving || refreshing || canvasUploading} onClick={restoreBuiltInAppearance}>
|
||||
恢复影策默认
|
||||
</Button>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving || restoring} onClick={requestRefresh}>
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving || restoring || canvasUploading} onClick={requestRefresh}>
|
||||
刷新状态
|
||||
</Button>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || refreshing || restoring} onClick={() => void save()}>
|
||||
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || refreshing || restoring || canvasUploading} onClick={() => void save()}>
|
||||
保存修改
|
||||
</Button>
|
||||
</div>
|
||||
@@ -754,6 +767,7 @@ export default function AppearanceSettingsPage() {
|
||||
</SettingsSectionCard>
|
||||
),
|
||||
},
|
||||
{ key: "canvas", label: "画布配置", children: <CanvasAppearanceEditor value={canvas} onChange={setCanvas} disabled={saving || refreshing || restoring} onUploading={setCanvasUploading} /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Alert, App, Button, Form, Input, InputNumber, Radio } from "antd";
|
||||
import { ExternalLink, Upload } from "lucide-react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { Live2DAvatar } from "@/components/canvas/live2d-avatar";
|
||||
import { FluidOrb } from "@/components/ui/fluid-orb";
|
||||
import { agentCopy, type CanvasAppearance } from "@/lib/canvas/agent-appearance";
|
||||
import { live2DModelURL, uploadLive2D } from "@/services/api/appearance";
|
||||
|
||||
export function CanvasAppearanceEditor({ value, onChange, disabled, onUploading }: { value: CanvasAppearance; onChange: (value: CanvasAppearance) => void; disabled: boolean; onUploading: (value: boolean) => void }) {
|
||||
const { message } = App.useApp();
|
||||
const reducedMotion = useReducedMotion() ?? false;
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [readyURL, setReadyURL] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const url = value.live2dResourceId ? live2DModelURL(value.live2dResourceId, value.live2dEntry, true) : "";
|
||||
const change = (patch: Partial<CanvasAppearance>) => onChange({ ...value, ...patch });
|
||||
async function upload(file?: File) {
|
||||
if (!file) return;
|
||||
if (!file.name.toLowerCase().endsWith(".zip") || file.size > 128 * 1024 * 1024) {
|
||||
message.error("请选择不超过 128 MiB 的 Live2D ZIP");
|
||||
return;
|
||||
}
|
||||
setUploading(true);
|
||||
onUploading(true);
|
||||
setError("");
|
||||
try {
|
||||
const model = await uploadLive2D(file);
|
||||
setReadyURL("");
|
||||
change({ live2dResourceId: model.resourceId, live2dEntry: model.entry, avatarType: "orb" });
|
||||
message.success("导入成功,请预览后选择 Live2D,并点击页面顶部保存");
|
||||
} catch (cause) {
|
||||
message.error(cause instanceof Error ? cause.message : "模型导入失败");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
onUploading(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div>
|
||||
<h3 className="mb-3 font-semibold">Agent 身份与文案</h3>
|
||||
<p className="mb-4 text-sm text-foreground/60">独立于站点品牌。文案可使用 {"{agentName}"} 引用助手名称;这里只配置展示文字,不修改工具权限。</p>
|
||||
<Form layout="vertical" disabled={disabled || uploading}>
|
||||
{(
|
||||
[
|
||||
["agentName", "助手名称", 24],
|
||||
["launcherLabel", "悬浮入口文字(可留空)", 24],
|
||||
["panelTitle", "对话面板标题", 40],
|
||||
["welcomeTitle", "欢迎标题", 100],
|
||||
["welcomeDescription", "欢迎说明", 200],
|
||||
["inputPlaceholder", "输入框提示", 160],
|
||||
] as const
|
||||
).map(([key, label, max]) => (
|
||||
<Form.Item key={key} label={label}>
|
||||
<Input value={value[key]} maxLength={max} showCount onChange={(event) => change({ [key]: event.target.value })} />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
</div>
|
||||
<div className="grid content-start gap-4">
|
||||
<h3 className="font-semibold">Agent 形象</h3>
|
||||
<Radio.Group
|
||||
value={value.avatarType}
|
||||
disabled={disabled || uploading}
|
||||
onChange={(event) => change({ avatarType: event.target.value })}
|
||||
options={[
|
||||
{ label: "默认动态球", value: "orb" },
|
||||
{ label: "Live2D", value: "live2d", disabled: !url || readyURL !== url },
|
||||
]}
|
||||
/>
|
||||
<p className="text-sm text-foreground/60">上传有授权的 Cubism 3/4 运行时 ZIP:一个 model3.json、moc3、PNG 纹理及可选动作/表情 JSON。首期不支持音频、旧版模型和编辑工程。模型会发送到浏览器,无法保证防下载。</p>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<span className="text-foreground/60">参考资料</span>
|
||||
<a href="https://www.live2d.com/en/learn/sample/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 underline underline-offset-4">
|
||||
官方示例模型库
|
||||
<ExternalLink className="size-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">(在新标签页打开)</span>
|
||||
</a>
|
||||
<a href="https://docs.live2d.com/en/cubism-sdk-manual/cubism-sdk-for-web/" target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 underline underline-offset-4">
|
||||
Cubism Web SDK 文档
|
||||
<ExternalLink className="size-3.5" aria-hidden="true" />
|
||||
<span className="sr-only">(在新标签页打开)</span>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-xs text-foreground/60">下载前请确认素材授权;官方原始包需按上述要求整理为运行时 ZIP,不能直接整包导入。</p>
|
||||
</div>
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
void upload(event.target.files?.[0]);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button icon={<Upload className="size-4" />} loading={uploading} disabled={disabled} onClick={() => input.current?.click()}>
|
||||
导入 Live2D 模型(最大 128 MiB)
|
||||
</Button>
|
||||
{url ? (
|
||||
<Button
|
||||
disabled={disabled || uploading}
|
||||
onClick={() => {
|
||||
change({ avatarType: "orb", live2dResourceId: "", live2dEntry: "" });
|
||||
setError("");
|
||||
}}
|
||||
>
|
||||
移除模型引用
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="flex items-center gap-3 text-sm">
|
||||
形象高度
|
||||
<InputNumber aria-label="形象高度" min={120} max={360} value={value.avatarHeight} disabled={disabled || uploading} onChange={(height) => height !== null && change({ avatarHeight: height })} />
|
||||
px
|
||||
</label>
|
||||
<div className="grid justify-items-center gap-3 rounded-xl border border-border bg-background p-5" aria-label="Agent 形象预览">
|
||||
{url ? (
|
||||
<Live2DAvatar
|
||||
key={`${url}-${attempt}`}
|
||||
url={url}
|
||||
width={Math.round(value.avatarHeight * 0.75)}
|
||||
height={value.avatarHeight}
|
||||
reducedMotion={reducedMotion}
|
||||
fallback={<FluidOrb size={62} color="#7164f6" />}
|
||||
onReady={() => {
|
||||
setReadyURL(url);
|
||||
setError("");
|
||||
}}
|
||||
onError={(reason) => {
|
||||
setReadyURL("");
|
||||
setError(reason);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<FluidOrb size={62} color="#7164f6" />
|
||||
)}
|
||||
<strong>{agentCopy(value.welcomeTitle, value.agentName)}</strong>
|
||||
<p className="text-sm text-foreground/60">{agentCopy(value.welcomeDescription, value.agentName)}</p>
|
||||
{url ? <small>{readyURL === url ? "预览已就绪,可选择 Live2D 并保存启用" : "等待模型预览就绪"}</small> : null}
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
title="Live2D 暂不可用"
|
||||
description={error}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setError("");
|
||||
setAttempt((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-xs text-foreground/60">部署者需自行确认 Live2D 授权并安装 Cubism Core 至 /live2d/live2dcubismcore.min.js。未安装时名称、文案和默认动态球仍可使用。未启用的导入包可在资源管理中清理。</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user