feat: add custom realtime mode icons
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-20 10:41:29 +08:00
parent 7164304451
commit 3775485bdf
4 changed files with 535 additions and 28 deletions

View File

@ -113,6 +113,7 @@ import {
Loader2,
Check,
Paintbrush,
Wand2,
Database,
} from "lucide-react";
import {
@ -284,6 +285,13 @@ function LocalAiSectionToggleButton({
);
}
function maskAdminSecret(value: string) {
const normalized = String(value || "").trim();
if (!normalized) return "未填写";
if (normalized.length <= 10) return "••••••";
return `${normalized.slice(0, 6)}••••${normalized.slice(-4)}`;
}
function reorderEnhanceProPresets(
presets: EnhanceProPreset[],
fromIndex: number,
@ -581,6 +589,13 @@ type StorageConfig = {
isEnabled: boolean;
};
type RealtimeCanvasModeIconConfig = {
realtimeIconUrl: string;
enhanceIconUrl: string;
};
type RealtimeCanvasModeIconKey = keyof RealtimeCanvasModeIconConfig;
type SiteSettings = {
siteName: string;
siteLogo: string | null;
@ -621,6 +636,7 @@ type SiteSettings = {
};
agentWorkflowUiConfig: AgentWorkflowUiConfig;
realtimeCanvasEnabled: boolean;
realtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig;
aiDetailGenerationEnabled: boolean;
canvasDefaultModelId: string | null;
canvasAutoModeDefaultModelId: string | null;
@ -695,6 +711,25 @@ const defaultContactConfig: ContactConfig = {
wechatQrUrl: "",
};
const defaultRealtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig = {
realtimeIconUrl: "",
enhanceIconUrl: "",
};
function normalizeRealtimeCanvasModeIconConfig(
value: unknown
): RealtimeCanvasModeIconConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
realtimeIconUrl: String(raw.realtimeIconUrl || "").trim(),
enhanceIconUrl: String(raw.enhanceIconUrl || "").trim(),
};
}
const defaultStorageFailoverConfig: StorageFailoverConfig = {
enabled: false,
backupStorageConfigId: "",
@ -1163,6 +1198,7 @@ export function AdminSettingsClient() {
},
agentWorkflowUiConfig: defaultAgentWorkflowUiConfig,
realtimeCanvasEnabled: true,
realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig,
aiDetailGenerationEnabled: false,
canvasDefaultModelId: null,
canvasAutoModeDefaultModelId: null,
@ -1304,6 +1340,9 @@ export function AdminSettingsClient() {
),
realtimeCanvasEnabled:
(siteSettingsData as any).realtimeCanvasEnabled ?? true,
realtimeCanvasModeIconConfig: normalizeRealtimeCanvasModeIconConfig(
(siteSettingsData as any).realtimeCanvasModeIconConfig
),
aiDetailGenerationEnabled:
siteSettingsData.aiDetailGenerationEnabled ?? false,
canvasDefaultModelId: siteSettingsData.canvasDefaultModelId || null,
@ -1748,6 +1787,26 @@ export function AdminSettingsClient() {
}).apiKeys.length,
[enhanceProApiKeysDraft]
);
const savedEnhanceProKeyRows = useMemo(
() =>
enhanceProConfig.apiKeys.map((apiKey, index) => ({
id: `saved-enhance-pro-key-${index}-${apiKey.slice(-6)}`,
name: `Krea Key ${index + 1}`,
maskedKey: maskAdminSecret(apiKey),
})),
[enhanceProConfig.apiKeys]
);
const draftEnhanceProKeyRows = useMemo(
() =>
normalizeEnhanceProKreaConfig({
apiKeys: enhanceProApiKeysDraft,
}).apiKeys.map((apiKey, index) => ({
id: `draft-enhance-pro-key-${index}-${apiKey.slice(-6)}`,
name: `待保存 Key ${index + 1}`,
maskedKey: maskAdminSecret(apiKey),
})),
[enhanceProApiKeysDraft]
);
useEffect(() => {
if (enhanceProConfig.presets.length === 0) {
@ -2282,6 +2341,8 @@ export function AdminSettingsClient() {
const [canvasSkillTagUploadingId, setCanvasSkillTagUploadingId] = useState<
string | null
>(null);
const [realtimeModeIconUploading, setRealtimeModeIconUploading] =
useState<RealtimeCanvasModeIconKey | null>(null);
const { data: canvasSkillTagsData, mutate: mutateCanvasSkillTags } = useSWR<{
enabled?: boolean;
title?: string;
@ -2418,6 +2479,50 @@ export function AdminSettingsClient() {
}
};
const handleRealtimeModeIconUpload = async (
field: RealtimeCanvasModeIconKey,
file: File
) => {
const isSvg =
file.type === "image/svg+xml" || file.name.toLowerCase().endsWith(".svg");
if (!isSvg) {
toast.error("请上传 SVG 图标文件");
return;
}
if (file.size > 512 * 1024) {
toast.error("SVG 图标不能超过 512KB");
return;
}
try {
setRealtimeModeIconUploading(field);
const formData = new FormData();
formData.append("file", file);
formData.append("purpose", "admin-svg-icon");
const response = await fetchUploadWithRetry(formData);
const result = await response.json();
if (!response.ok || !result?.url) {
throw new Error(result?.error || "上传失败");
}
setSiteSettings((prev) => ({
...prev,
realtimeCanvasModeIconConfig: {
...prev.realtimeCanvasModeIconConfig,
[field]: result.url,
},
}));
toast.success("模式图标上传成功,请保存配置后生效");
} catch (error: any) {
toast.error(error?.message || "模式图标上传失败");
} finally {
setRealtimeModeIconUploading(null);
}
};
const handleAddCanvasSkillTag = () => {
setCanvasSkillTags((prev) => [...prev, createEmptyCanvasSkillTag()]);
};
@ -9812,6 +9917,151 @@ export function AdminSettingsClient() {
/>
</div>
{siteSettings.realtimeCanvasEnabled && (
<div className="rounded-lg border p-4 space-y-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">
实时画布模式图标
</div>
<div className="text-xs text-muted-foreground">
上传 SVG 后会替换前端“实时画布模式”和“图像增强模式”的按钮图标;前端会按选中状态自动切换黑白色。
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
{(
[
{
field: "realtimeIconUrl",
title: "实时画布模式",
description: "左侧模式切换按钮的画笔图标",
uploadId: "realtime-mode-icon-upload",
fallback: Paintbrush,
},
{
field: "enhanceIconUrl",
title: "图像增强模式",
description: "右侧模式切换按钮的魔法棒图标",
uploadId: "enhance-mode-icon-upload",
fallback: Wand2,
},
] as const
).map((item) => {
const iconUrl =
siteSettings.realtimeCanvasModeIconConfig[
item.field
];
const FallbackIcon = item.fallback;
const isUploading =
realtimeModeIconUploading === item.field;
return (
<div
key={item.field}
className="rounded-xl border bg-muted/10 p-3 space-y-3"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="text-sm font-medium">
{item.title}
</div>
<div className="text-xs text-muted-foreground">
{item.description}
</div>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
disabled={isUploading}
onClick={() =>
document
.getElementById(item.uploadId)
?.click()
}
>
{isUploading ? (
<>
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
上传中
</>
) : (
"上传 SVG"
)}
</Button>
{iconUrl ? (
<Button
type="button"
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground"
onClick={() =>
setSiteSettings((prev) => ({
...prev,
realtimeCanvasModeIconConfig: {
...prev.realtimeCanvasModeIconConfig,
[item.field]: "",
},
}))
}
>
清除
</Button>
) : null}
</div>
<input
id={item.uploadId}
type="file"
accept=".svg,image/svg+xml"
className="hidden"
onChange={async (event) => {
const file = event.target.files?.[0];
if (file) {
await handleRealtimeModeIconUpload(
item.field,
file
);
}
event.target.value = "";
}}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex h-10 items-center gap-2 rounded-[18px] bg-[#111111] px-3 text-xs font-medium text-white">
{iconUrl ? (
<img
alt={`${item.title}选中预览`}
className="h-4 w-4 object-contain brightness-0 invert"
src={iconUrl}
/>
) : (
<FallbackIcon className="h-4 w-4 text-white" />
)}
选中态
</div>
<div className="inline-flex h-10 items-center gap-2 rounded-[18px] border bg-white px-3 text-xs font-medium text-[#666666]">
{iconUrl ? (
<img
alt={`${item.title}未选中预览`}
className="h-4 w-4 object-contain brightness-0 opacity-70"
src={iconUrl}
/>
) : (
<FallbackIcon className="h-4 w-4 text-[#666666]" />
)}
未选中态
</div>
</div>
</div>
);
})}
</div>
</div>
)}
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label htmlFor="videoCanvasEnabled">
@ -9833,6 +10083,24 @@ export function AdminSettingsClient() {
/>
</div>
<div className="rounded-lg border p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div className="text-sm font-medium">
视频画布功能配置
</div>
<div className="text-xs text-muted-foreground">
独立维护复刻画布里的功能预设提示词、默认模型、单功能模型和扣点配置。
</div>
</div>
<Button asChild variant="outline">
<Link href="/settings/video-canvas">
前往视频画布功能配置
</Link>
</Button>
</div>
</div>
{siteSettings.videoCanvasEnabled && (
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
@ -15680,23 +15948,18 @@ export function AdminSettingsClient() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceProApiKeys">API Key 池</Label>
<Textarea
id="enhanceProApiKeys"
value={enhanceProApiKeysDraft}
onChange={(e) => setEnhanceProApiKeysDraft(e.target.value)}
placeholder="为安全起见不回显已保存 Key;每行一个新 Krea Bearer Token,保存后会替换当前 Key 池。留空保存则保持当前池不变。"
rows={4}
/>
<div className="flex flex-col gap-2 text-xs text-muted-foreground md:flex-row md:items-center md:justify-between">
<span>
已保存 {enhanceProConfig.apiKeys.length} 个 Key
{enhanceProApiKeysDraft.trim()
? `,本次保存会替换为 ${enhanceProApiKeysDraftCount} 个 Key`
: ";留空不会覆盖已保存池"}
。生成任务会轮询使用,异步查询会绑定回创建任务时使用的 Key。
</span>
<div className="space-y-4 rounded-xl border p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-medium">Krea Key 池</div>
<div className="text-xs text-muted-foreground">
当前共 {enhanceProConfig.apiKeys.length} 个 Key
{enhanceProApiKeysDraft.trim()
? `,本次保存会替换为 ${enhanceProApiKeysDraftCount} 个 Key`
: ",留空保存不会覆盖已保存池"}
。增强 Pro 任务会轮询使用可用 Key。
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
@ -15724,6 +15987,93 @@ export function AdminSettingsClient() {
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="enhanceProApiKeys">批量导入 Key</Label>
<Textarea
id="enhanceProApiKeys"
value={enhanceProApiKeysDraft}
onChange={(e) => setEnhanceProApiKeysDraft(e.target.value)}
placeholder="为安全起见不回显已保存 Key;每行一个新 Krea Bearer Token,保存后会替换当前 Key 池。留空保存则保持当前池不变。"
rows={4}
/>
<div className="text-xs text-muted-foreground">
异步查询会绑定回创建任务时使用的 Key,避免轮询切换 Key 后查不到任务。
</div>
</div>
<div className="space-y-3">
{draftEnhanceProKeyRows.length ? (
<div className="rounded-lg border border-emerald-200 bg-emerald-50/70 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-medium text-emerald-900">
待保存 Key 预览
</div>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-700">
{draftEnhanceProKeyRows.length} 个
</span>
</div>
<div className="space-y-2">
{draftEnhanceProKeyRows.map((item) => (
<div
key={item.id}
className="flex flex-col gap-2 rounded-lg border bg-background p-3 lg:flex-row lg:items-center lg:justify-between"
>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{item.name}</div>
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-600">
待保存
</span>
</div>
<div className="font-mono text-xs text-muted-foreground">
Key: {item.maskedKey}
</div>
<div className="text-xs text-muted-foreground">
API: {enhanceProConfig.baseUrl || ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
</div>
</div>
</div>
))}
</div>
</div>
) : null}
{savedEnhanceProKeyRows.length ? (
savedEnhanceProKeyRows.map((item) => (
<div
key={item.id}
className="flex flex-col gap-4 rounded-lg border p-4 lg:flex-row lg:items-center lg:justify-between"
>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<div className="font-medium">{item.name}</div>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs",
enhanceProConfig.enabled
? "bg-emerald-500/10 text-emerald-600"
: "bg-zinc-200 text-zinc-600"
)}
>
{enhanceProConfig.enabled ? "已启用" : "已禁用"}
</span>
</div>
<div className="font-mono text-xs text-muted-foreground">
Key: {item.maskedKey}
</div>
<div className="text-xs text-muted-foreground">
API: {enhanceProConfig.baseUrl || ENHANCE_PRO_KREA_DEFAULT_BASE_URL}
</div>
</div>
</div>
))
) : (
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
当前还没有导入任何 Krea Key。
</div>
)}
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">

View File

@ -3,6 +3,7 @@ import { z } from "zod";
import { auth } from "@/app/(auth)/auth";
import { upsertMediaAsset } from "@/lib/db/queries";
import { isSiteAdminType } from "@/lib/middleware/admin";
import { uploadFile } from "@/lib/storage/service";
/**
@ -90,9 +91,15 @@ const FileSchema = z.object({
message: "File size should be less than 50MB",
})
// Update the file type based on the kind of files you want to accept
.refine((file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type), {
message: "File type should be JPEG, PNG or WebP",
}),
.refine(
(file) =>
["image/jpeg", "image/png", "image/webp", "image/svg+xml"].includes(
file.type
),
{
message: "File type should be JPEG, PNG, WebP or SVG",
}
),
});
export async function POST(request: Request) {
@ -121,6 +128,18 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
const uploadPurpose = String(formData.get("purpose") || "");
const isSvgUpload = file.type === "image/svg+xml";
if (
isSvgUpload &&
(uploadPurpose !== "admin-svg-icon" || !isSiteAdminType(session.user.type))
) {
return NextResponse.json(
{ error: "SVG uploads are only allowed for administrator icon settings" },
{ status: 403 }
);
}
console.log("[Upload API] File info:", {
type: file.type,
size: file.size,
@ -153,9 +172,9 @@ export async function POST(request: Request) {
// WebP格式不优化(用于预览图,保持原样)
if (file.type === 'image/jpeg' || file.type === 'image/jpg' || file.type === 'image/png') {
optimizedBuffer = await optimizeImage(optimizedBuffer, file.type);
} else if (file.type === 'image/webp') {
// WebP格式不优化(用于预览图),直接使用
console.log(`[Upload] WebP格式(预览图),跳过优化,直接上传`);
} else if (file.type === 'image/webp' || file.type === 'image/svg+xml') {
// WebP/SVG 不优化;SVG 是矢量文件,交给前端按状态反色渲染。
console.log(`[Upload] ${file.type}格式,跳过优化,直接上传`);
}
// 将Buffer转换为ArrayBuffer用于上传

View File

@ -107,6 +107,7 @@ const MEMBERSHIP_CONFIG_KEY = "membership_config";
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
const STORAGE_FAILOVER_CONFIG_KEY = "storage_failover_config";
const REALTIME_CANVAS_CONFIG_KEY = "realtime_canvas_config";
const REALTIME_CANVAS_MODE_ICON_CONFIG_KEY = "realtime_canvas_mode_icon_config";
const LAYER_SPLIT_CONFIG_KEY = "layer_split_config";
const AGENT_CATEGORY_TREE_CONFIG_KEY = "agent_category_tree_config";
const CANVAS_AGENT_MODE_ENABLED_KEY = "canvas_agent_mode_enabled";
@ -134,6 +135,31 @@ const AGENT_WORKFLOW_SETTINGS_FIELD_KEY_MAP = {
AGENT_PSD_TEMPLATE_SECONDARY_CATEGORY_CONFIG_KEY,
} as const;
type AgentWorkflowSettingsField = keyof typeof AGENT_WORKFLOW_SETTINGS_FIELD_KEY_MAP;
type RealtimeCanvasModeIconConfig = {
realtimeIconUrl: string;
enhanceIconUrl: string;
};
const defaultRealtimeCanvasModeIconConfig: RealtimeCanvasModeIconConfig = {
realtimeIconUrl: "",
enhanceIconUrl: "",
};
function normalizeRealtimeCanvasModeIconConfig(
value: unknown
): RealtimeCanvasModeIconConfig {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const normalizeUrl = (input: unknown) =>
String(input || "").trim().slice(0, 2000);
return {
realtimeIconUrl: normalizeUrl(raw.realtimeIconUrl),
enhanceIconUrl: normalizeUrl(raw.enhanceIconUrl),
};
}
function stableStringifySettingsValue(value: unknown): string {
if (value === null || typeof value !== "object") {
@ -406,6 +432,7 @@ export async function GET() {
pointsPackagesSetting,
dulupayConfigSetting,
realtimeCanvasConfigSetting,
realtimeCanvasModeIconConfigSetting,
layerSplitConfigSetting,
canvasAgentModeEnabledSetting,
videoCanvasEnabledSetting,
@ -510,6 +537,12 @@ export async function GET() {
.where(eq(systemSettings.key, REALTIME_CANVAS_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
.where(eq(systemSettings.key, REALTIME_CANVAS_MODE_ICON_CONFIG_KEY))
.limit(1)
.then(rows => rows[0]),
db
.select()
.from(systemSettings)
@ -642,6 +675,9 @@ export async function GET() {
: normalizeDulupayForPublic(dulupayConfigSetting?.value);
const realtimeCanvasServiceEnabled =
(realtimeCanvasConfigSetting?.value as { enabled?: boolean } | undefined)?.enabled !== false;
const realtimeCanvasModeIconConfig = normalizeRealtimeCanvasModeIconConfig(
realtimeCanvasModeIconConfigSetting?.value
);
const layerSplitEnabled = normalizeLayerSplitEnabled(
layerSplitConfigSetting?.value
);
@ -749,6 +785,7 @@ export async function GET() {
agentPsdTemplateCategoryTagIndex:
defaultAgentPsdTemplateCategoryTagIndex,
realtimeCanvasEnabled: true,
realtimeCanvasModeIconConfig: defaultRealtimeCanvasModeIconConfig,
realtimeCanvasServiceEnabled: true,
layerSplitEnabled,
storageFailoverConfig,
@ -809,6 +846,7 @@ export async function GET() {
pointsPackages,
dulupayConfig,
realtimeCanvasServiceEnabled,
realtimeCanvasModeIconConfig,
layerSplitEnabled,
canvasAgentModeEnabled,
videoCanvasEnabled,
@ -1149,6 +1187,12 @@ const updateSchema = z.object({
})
.optional(),
realtimeCanvasEnabled: z.boolean().optional(),
realtimeCanvasModeIconConfig: z
.object({
realtimeIconUrl: z.string().trim().max(2000).optional(),
enhanceIconUrl: z.string().trim().max(2000).optional(),
})
.optional(),
aiDetailGenerationEnabled: z.boolean().optional(),
canvasDefaultModelId: z.string().nullable().optional(),
canvasAutoModeDefaultModelId: z.string().nullable().optional(),
@ -1275,6 +1319,7 @@ export async function PUT(request: NextRequest) {
dulupayConfig,
storageFailoverConfig,
ossImageMigrationConfig,
realtimeCanvasModeIconConfig,
canvasAgentModeEnabled,
videoCanvasEnabled,
videoCanvasSidebarEnabled,
@ -1382,6 +1427,25 @@ export async function PUT(request: NextRequest) {
});
};
const persistSystemSetting = async (key: string, value: unknown) => {
await db
.insert(systemSettings)
.values({
key,
value,
updatedAt: new Date(),
updatedBy: session.user.id,
})
.onConflictDoUpdate({
target: systemSettings.key,
set: {
value,
updatedAt: new Date(),
updatedBy: session.user.id,
},
});
};
const persistHomeAnimationConfig = async () => {
if (homeAnimationConfig === undefined) {
return;
@ -1918,6 +1982,13 @@ export async function PUT(request: NextRequest) {
});
}
if (realtimeCanvasModeIconConfig !== undefined) {
await persistSystemSetting(
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
);
}
await persistHomeAnimationConfig();
return NextResponse.json({
@ -2018,6 +2089,10 @@ export async function PUT(request: NextRequest) {
ossImageMigrationConfig !== undefined
? normalizeOssImageMigrationConfig(ossImageMigrationConfig)
: defaultOssImageMigrationConfig,
realtimeCanvasModeIconConfig:
realtimeCanvasModeIconConfig !== undefined
? normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
: defaultRealtimeCanvasModeIconConfig,
canvasAgentModeEnabled:
canvasAgentModeEnabled !== undefined
? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled)
@ -2514,6 +2589,13 @@ export async function PUT(request: NextRequest) {
});
}
if (realtimeCanvasModeIconConfig !== undefined) {
await persistSystemSetting(
REALTIME_CANVAS_MODE_ICON_CONFIG_KEY,
normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
);
}
if (videoCanvasEnabled !== undefined) {
const normalized = normalizeVideoCanvasEnabled(videoCanvasEnabled);
await db
@ -2632,6 +2714,10 @@ export async function PUT(request: NextRequest) {
ossImageMigrationConfig !== undefined
? normalizeOssImageMigrationConfig(ossImageMigrationConfig)
: defaultOssImageMigrationConfig,
realtimeCanvasModeIconConfig:
realtimeCanvasModeIconConfig !== undefined
? normalizeRealtimeCanvasModeIconConfig(realtimeCanvasModeIconConfig)
: defaultRealtimeCanvasModeIconConfig,
canvasAgentModeEnabled:
canvasAgentModeEnabled !== undefined
? normalizeCanvasAgentModeEnabled(canvasAgentModeEnabled)

View File

@ -82,6 +82,10 @@ type RealtimeConfig = {
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
type RealtimeWorkspaceMode = "realtime" | "enhance-pro";
type RealtimeCanvasModeIconConfig = {
realtimeIconUrl: string;
enhanceIconUrl: string;
};
type RealtimeBrushMode =
| "select"
| "brush"
@ -849,6 +853,11 @@ export function RealtimeCanvasDialog({
const [siteName, setSiteName] = useState("AI Chatbot");
const [siteLogo, setSiteLogo] = useState<string | null>(null);
const [isLayerSplitEnabled, setIsLayerSplitEnabled] = useState(false);
const [modeIconConfig, setModeIconConfig] =
useState<RealtimeCanvasModeIconConfig>({
realtimeIconUrl: "",
enhanceIconUrl: "",
});
const statusBadge = useMemo(() => {
if (status === "live")
@ -911,6 +920,21 @@ export function RealtimeCanvasDialog({
setSiteLogo(data.siteLogoDark.trim());
}
setIsLayerSplitEnabled(data.layerSplitEnabled === true);
const iconConfig =
data.realtimeCanvasModeIconConfig &&
typeof data.realtimeCanvasModeIconConfig === "object"
? data.realtimeCanvasModeIconConfig
: {};
setModeIconConfig({
realtimeIconUrl:
typeof iconConfig.realtimeIconUrl === "string"
? iconConfig.realtimeIconUrl.trim()
: "",
enhanceIconUrl:
typeof iconConfig.enhanceIconUrl === "string"
? iconConfig.enhanceIconUrl.trim()
: "",
});
})
.catch((error) => {
console.warn("[RealtimeCanvas] Failed to load site settings:", error);
@ -919,7 +943,7 @@ export function RealtimeCanvasDialog({
return () => {
cancelled = true;
};
}, []);
}, [open]);
const pushMetric = useCallback((event: any) => {
metricsQueueRef.current.push(event);
@ -3160,7 +3184,7 @@ export function RealtimeCanvasDialog({
<div className="inline-flex h-12 min-w-0 items-center gap-1 rounded-[22px] border border-[#ebebeb] bg-[#fafafa] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.9)]">
<button
className={cn(
"inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
"group inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
workspaceMode === "realtime"
? softActionButtonClass
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111]"
@ -3168,12 +3192,26 @@ export function RealtimeCanvasDialog({
onClick={() => handleWorkspaceModeChange("realtime")}
type="button"
>
<Paintbrush className="h-4 w-4" />
{modeIconConfig.realtimeIconUrl ? (
<img
alt=""
aria-hidden="true"
className={cn(
"h-4 w-4 object-contain transition",
workspaceMode === "realtime"
? "brightness-0 invert"
: "brightness-0 opacity-70 group-hover:opacity-100"
)}
src={modeIconConfig.realtimeIconUrl}
/>
) : (
<Paintbrush className="h-4 w-4" />
)}
实时画布模式
</button>
<button
className={cn(
"inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
"group inline-flex h-full items-center gap-2 rounded-[18px] px-4 text-[14px] font-medium tracking-[-0.02em] transition",
workspaceMode === "enhance-pro"
? softActionButtonClass
: "border border-transparent bg-transparent text-[#666666] hover:bg-white hover:text-[#111111]"
@ -3181,7 +3219,21 @@ export function RealtimeCanvasDialog({
onClick={() => handleWorkspaceModeChange("enhance-pro")}
type="button"
>
<Wand2 className="h-4 w-4" />
{modeIconConfig.enhanceIconUrl ? (
<img
alt=""
aria-hidden="true"
className={cn(
"h-4 w-4 object-contain transition",
workspaceMode === "enhance-pro"
? "brightness-0 invert"
: "brightness-0 opacity-70 group-hover:opacity-100"
)}
src={modeIconConfig.enhanceIconUrl}
/>
) : (
<Wand2 className="h-4 w-4" />
)}
图像增强模式
</button>
</div>