Files
ttstd-infinite-canvas/web/src/pages/admin/settings/drawing-engine-settings-page.tsx

376 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { App, Button, Input, Skeleton, type InputRef } from "antd";
import { AlertTriangle, Brush, KeyRound, RefreshCw, RotateCcw, Save, Shapes } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useBlocker } from "react-router";
import { drawingEngineLabel, isDrawingEngineAvailable, type CanvasDrawingEngine, type CanvasDrawingEngineSetting } from "@/lib/canvas/canvas-drawing-engine";
import { cn } from "@/lib/utils";
import { getAdminDrawingEngineSetting, updateAdminDrawingEngineSetting } from "@/services/api/auth";
import { useUserStore } from "@/stores/use-user-store";
import { AdminPageFrame } from "../components/admin-shell";
import { AdminStatusBadge, SettingsSectionCard } from "../components/admin-ui";
type DrawingEngineDraft = {
defaultEngine: CanvasDrawingEngine;
tldrawLicenseKey: string;
};
const engineOptions: Array<{
value: CanvasDrawingEngine;
title: string;
description: string;
}> = [
{
value: "excalidraw",
title: "Excalidraw",
description: "无需授权,适合草图、结构标注和轻量协作。",
},
{
value: "tldraw",
title: "tldraw",
description: "需要浏览器端 License Key,也用于打开已有 tldraw 绘图。",
},
];
export default function DrawingEngineSettingsPage() {
const { message, modal } = App.useApp();
const setDrawingEngine = useUserStore((state) => state.setDrawingEngine);
const [savedSetting, setSavedSetting] = useState<CanvasDrawingEngineSetting | null>(null);
const [draft, setDraft] = useState<DrawingEngineDraft | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [saving, setSaving] = useState(false);
const [loadError, setLoadError] = useState("");
const [saveError, setSaveError] = useState("");
const requestVersionRef = useRef(0);
const navigationConfirmOpenRef = useRef(false);
const navigationTriggerRef = useRef<HTMLElement | null>(null);
const licenseInputRef = useRef<InputRef>(null);
const load = useCallback(
async (initial = false, announce = false) => {
const requestVersion = ++requestVersionRef.current;
if (initial) setLoading(true);
else setRefreshing(true);
setLoadError("");
try {
const result = await getAdminDrawingEngineSetting();
const setting = parseDrawingEngineSetting(result.setting);
if (requestVersion !== requestVersionRef.current) return;
setSavedSetting(setting);
setDraft(toDraft(setting));
setDrawingEngine(setting);
setSaveError("");
if (announce) message.success("已重新读取当前绘图工具配置");
} catch (error) {
if (requestVersion !== requestVersionRef.current) return;
const errorMessage = error instanceof Error ? error.message : "读取绘图工具配置失败";
setLoadError(errorMessage);
if (!initial) message.error(errorMessage);
} finally {
if (requestVersion === requestVersionRef.current) {
setLoading(false);
setRefreshing(false);
}
}
},
[message, setDrawingEngine],
);
useEffect(() => {
void load(true);
return () => {
requestVersionRef.current += 1;
};
}, [load]);
const normalizedDraft = draft ? normalizeDraft(draft) : null;
const dirtyFields = useMemo(() => {
if (!savedSetting || !normalizedDraft) return [];
const fields: Array<"defaultEngine" | "tldrawLicenseKey"> = [];
if (savedSetting.defaultEngine !== normalizedDraft.defaultEngine) fields.push("defaultEngine");
if ((savedSetting.tldrawLicenseKey || "").trim() !== normalizedDraft.tldrawLicenseKey) fields.push("tldrawLicenseKey");
return fields;
}, [normalizedDraft, savedSetting]);
const dirty = dirtyFields.length > 0;
const blocker = useBlocker(dirty && !saving);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
if (!dirty || saving) return;
event.preventDefault();
};
window.addEventListener("beforeunload", beforeUnload);
return () => window.removeEventListener("beforeunload", beforeUnload);
}, [dirty, saving]);
useEffect(() => {
if (blocker.state !== "blocked" || navigationConfirmOpenRef.current) return;
navigationConfirmOpenRef.current = true;
navigationTriggerRef.current = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;
modal.confirm({
title: "放弃绘图工具调整?",
content: `当前有 ${dirtyFields.length} 项调整尚未保存,离开后暂存内容会丢失。`,
okText: "放弃并离开",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => {
navigationConfirmOpenRef.current = false;
navigationTriggerRef.current = null;
blocker.proceed();
},
onCancel: () => {
navigationConfirmOpenRef.current = false;
blocker.reset();
window.requestAnimationFrame(() => {
const fallback = document.querySelector<HTMLButtonElement>(".admin-drawing-command-actions button");
const target = navigationTriggerRef.current?.isConnected ? navigationTriggerRef.current : fallback;
target?.focus();
navigationTriggerRef.current = null;
});
},
});
}, [blocker, dirtyFields.length, modal]);
const tldrawAvailable = draft ? isDrawingEngineAvailable("tldraw", draft.tldrawLicenseKey) : false;
const draftHasStoredLicense = Boolean(normalizedDraft?.tldrawLicenseKey);
const usingEnvironmentLicense = tldrawAvailable && !draftHasStoredLicense;
const selectedEngineAvailable = draft ? isDrawingEngineAvailable(draft.defaultEngine, draft.tldrawLicenseKey) : false;
const resetDraft = () => {
if (!savedSetting || saving) return;
setDraft(toDraft(savedSetting));
setSaveError("");
message.info("已撤销本页尚未保存的调整");
};
const requestRefresh = () => {
if (!dirty) {
void load(false, true);
return;
}
modal.confirm({
title: "放弃调整并重新读取?",
content: `当前有 ${dirtyFields.length} 项调整尚未保存。重新读取会丢弃暂存内容,并以服务端当前配置为准。`,
okText: "放弃并刷新",
cancelText: "继续编辑",
okButtonProps: { danger: true },
onOk: () => load(false, true),
});
};
const selectEngine = (engine: CanvasDrawingEngine) => {
if (!draft || saving) return;
setDraft({ ...draft, defaultEngine: engine });
setSaveError("");
};
async function save(override?: DrawingEngineDraft) {
const expected = normalizeDraft(override || draft || { defaultEngine: "excalidraw", tldrawLicenseKey: "" });
const hasChanges = Boolean(savedSetting && (savedSetting.defaultEngine !== expected.defaultEngine || (savedSetting.tldrawLicenseKey || "").trim() !== expected.tldrawLicenseKey));
if (!savedSetting || !hasChanges || saving) return;
if (!isDrawingEngineAvailable(expected.defaultEngine, expected.tldrawLicenseKey)) {
setSaveError("当前选择的 tldraw 不可用,请先配置授权或改用 Excalidraw。");
licenseInputRef.current?.focus();
throw new Error("请先配置 tldraw License Key");
}
setSaving(true);
setSaveError("");
try {
const result = await updateAdminDrawingEngineSetting(expected);
const setting = parseDrawingEngineSetting(result.setting);
if (setting.defaultEngine !== expected.defaultEngine || (setting.tldrawLicenseKey || "").trim() !== expected.tldrawLicenseKey) {
throw new Error("服务端返回的绘图工具配置与本次保存内容不一致,请重新读取后核对");
}
setSavedSetting(setting);
setDraft(toDraft(setting));
setDrawingEngine(setting);
message.success("绘图工具配置已保存");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "保存绘图工具配置失败";
setSaveError(`${errorMessage}。未自动重试,请重新读取当前配置后再决定是否保存。`);
message.error(errorMessage);
throw error;
} finally {
setSaving(false);
}
}
const submitSave = async () => {
try {
await save();
} catch {
// 校验或保存错误已在 save 中就地提示。
}
};
if (loading && !draft) {
return (
<AdminPageFrame title="绘图工具" description="管理新建绘图的默认编辑器与 tldraw 授权" scroll>
<div className="admin-settings-stack admin-drawing-settings" aria-label="正在读取绘图工具配置" role="status">
<div className="admin-drawing-command-bar">
<Skeleton active title={{ width: 180 }} paragraph={false} />
</div>
<div className="admin-drawing-loading-card">
<Skeleton active paragraph={{ rows: 5 }} />
</div>
</div>
</AdminPageFrame>
);
}
if (!draft || !savedSetting) {
return (
<AdminPageFrame title="绘图工具" description="管理新建绘图的默认编辑器与 tldraw 授权" scroll>
<div className="admin-settings-stack admin-drawing-settings">
<div className="admin-drawing-load-error" role="alert">
<span className="admin-drawing-load-error-icon">
<AlertTriangle className="size-5" aria-hidden="true" />
</span>
<div>
<h2>无法读取绘图工具配置</h2>
<p>{loadError || "当前没有可显示的配置,请稍后重试。"}</p>
</div>
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void load(true)}>
重新读取
</Button>
</div>
</div>
</AdminPageFrame>
);
}
const licenseStatus = draftHasStoredLicense ? "后台已配置" : usingEnvironmentLicense ? "部署环境提供" : "未配置";
return (
<AdminPageFrame title="绘图工具" description="管理新建绘图的默认编辑器与 tldraw 授权" scroll>
<div className="admin-settings-stack admin-drawing-settings">
<div className={cn("admin-drawing-command-bar", dirty && "is-dirty")}>
<div className="admin-drawing-command-copy" aria-live="polite">
<div className="flex flex-wrap items-center gap-2">
<strong>{dirty ? `${dirtyFields.length} 项调整待保存` : "绘图工具配置已同步"}</strong>
<AdminStatusBadge label={dirty ? "尚未生效" : savedSetting.configured ? "服务端配置" : "系统默认"} tone={dirty ? "warning" : "neutral"} />
</div>
</div>
<div className="admin-drawing-command-actions">
{dirty ? (
<Button icon={<RotateCcw className="size-4" />} disabled={saving} onClick={resetDraft}>
撤销改动
</Button>
) : null}
<Button icon={<RefreshCw className="size-4" />} loading={refreshing} disabled={saving} onClick={requestRefresh}>
刷新状态
</Button>
<Button type="primary" icon={<Save className="size-4" />} loading={saving} disabled={!dirty || !selectedEngineAvailable} onClick={() => void submitSave()}>
保存修改
</Button>
</div>
</div>
{loadError || saveError ? (
<div className="admin-drawing-inline-alert" role="alert">
<AlertTriangle className="size-4 shrink-0" aria-hidden="true" />
<span>{saveError || `${loadError}。页面仍显示上一次成功读取的配置。`}</span>
</div>
) : null}
<div className="admin-drawing-section-grid">
<div id="admin-drawing-default-engine" className="admin-settings-anchor">
<SettingsSectionCard
icon={<Brush className="size-4" aria-hidden="true" />}
title="1. 选择新建绘图默认编辑器"
description="仅影响之后新建的绘图,不转换已有节点。"
status={<AdminStatusBadge label={drawingEngineLabel(draft.defaultEngine)} tone={selectedEngineAvailable ? "info" : "warning"} />}
>
<div className="admin-drawing-engine-grid" role="group" aria-label="选择新建绘图默认编辑器">
{engineOptions.map((option) => {
const available = isDrawingEngineAvailable(option.value, draft.tldrawLicenseKey);
const selected = draft.defaultEngine === option.value;
return (
<button
key={option.value}
type="button"
className={cn("admin-drawing-engine-choice", selected && "is-selected", !available && "is-unavailable")}
aria-label={`选择 ${option.title} 作为新建绘图默认编辑器${available ? "" : ",保存前需要配置授权"}`}
aria-pressed={selected}
disabled={saving}
onClick={() => selectEngine(option.value)}
>
<span className="admin-drawing-engine-choice-heading">
<span className="admin-drawing-engine-choice-icon">{option.value === "excalidraw" ? <Shapes className="size-4" aria-hidden="true" /> : <Brush className="size-4" aria-hidden="true" />}</span>
<span className="min-w-0 flex-1">
<strong>{option.title}</strong>
</span>
<AdminStatusBadge label={selected ? "已选择" : available ? "可用" : "需要授权"} tone={selected ? "info" : available ? "success" : "warning"} />
</span>
<span className="admin-drawing-engine-choice-description">{option.description}</span>
</button>
);
})}
</div>
</SettingsSectionCard>
</div>
<div id="admin-drawing-tldraw-license" className="admin-settings-anchor">
<SettingsSectionCard
icon={<KeyRound className="size-4" aria-hidden="true" />}
title="2. 配置 tldraw 授权(按需)"
description="选择 tldraw 或需要打开已有 tldraw 绘图时配置。"
status={<AdminStatusBadge label={licenseStatus} tone={tldrawAvailable ? "success" : "warning"} />}
>
<div className="admin-drawing-license-layout">
<div className="admin-drawing-license-field">
<label htmlFor="admin-tldraw-license-key">tldraw License Key</label>
<Input.Password
ref={licenseInputRef}
id="admin-tldraw-license-key"
value={draft.tldrawLicenseKey}
onChange={(event) => {
setDraft({ ...draft, tldrawLicenseKey: event.target.value });
setSaveError("");
}}
placeholder="输入用于浏览器端的 tldraw License Key"
disabled={saving}
autoComplete="new-password"
aria-describedby="admin-tldraw-license-help"
/>
<p id="admin-tldraw-license-help">留空时使用部署环境授权;两处都未配置时 tldraw 不可用。</p>
</div>
</div>
</SettingsSectionCard>
</div>
</div>
</div>
</AdminPageFrame>
);
}
function toDraft(setting: CanvasDrawingEngineSetting): DrawingEngineDraft {
return {
defaultEngine: setting.defaultEngine,
tldrawLicenseKey: setting.tldrawLicenseKey || "",
};
}
function normalizeDraft(draft: DrawingEngineDraft): DrawingEngineDraft {
return {
defaultEngine: draft.defaultEngine,
tldrawLicenseKey: draft.tldrawLicenseKey.trim(),
};
}
function parseDrawingEngineSetting(value: unknown): CanvasDrawingEngineSetting {
if (!value || typeof value !== "object") throw new Error("绘图工具配置响应格式无效");
const record = value as Record<string, unknown>;
if (record.defaultEngine !== "excalidraw" && record.defaultEngine !== "tldraw") throw new Error("绘图工具配置缺少有效的默认编辑器");
if (record.tldrawLicenseKey !== undefined && typeof record.tldrawLicenseKey !== "string") throw new Error("绘图工具配置中的 tldraw 授权格式无效");
return {
defaultEngine: record.defaultEngine,
tldrawLicenseKey: typeof record.tldrawLicenseKey === "string" ? record.tldrawLicenseKey : "",
configured: typeof record.configured === "boolean" ? record.configured : undefined,
updatedBy: typeof record.updatedBy === "string" ? record.updatedBy : undefined,
createdAt: typeof record.createdAt === "string" ? record.createdAt : undefined,
updatedAt: typeof record.updatedAt === "string" ? record.updatedAt : undefined,
};
}