Files
Endoleap/src/components/workspace/account-panel.tsx
T

81 lines
4.4 KiB
TypeScript

/**
* 账户资料编辑与新建工作区共用的客户端对话框。保存后通过回调更新宿主目录/资料,再关闭;
* demo分支只构造本地结果,真实分支走profile PATCH或workspaces POST,不由组件直接操作canvas runtime。
*/
"use client";
import { useState, type FormEvent } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { api, errorText, type Profile } from "@/lib/api";
import type { Workspace } from "@/lib/workspace";
import { UserAvatar } from "./user-avatar";
import styles from "./workspace.module.css";
/** mode决定表单,busy控制当前提交和退出,error用于就地反馈;输入默认值来自打开时profile。 */
export function AccountPanel({ mode, profile, demo, onClose, onProfile, onWorkspace }: {
mode: "settings" | "workspace";
profile: Profile;
demo: boolean;
onClose: () => void;
onProfile: (profile: Profile) => void;
onWorkspace: (workspace: Workspace) => void;
}) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const settings = mode === "settings";
const name = profile.display_name || profile.username;
/** 阻止原生导航,捕获FormData后提交;成功先通知宿主再onClose,失败显示错误并恢复按钮。
* HTML输入限制是交互提示,服务端仍校验真实资料/空间名称。 */
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
// busy 是本次 render 的状态值,限制后续渲染后的重复提交;不是同步 ref 互斥或持久操作幂等键。
if (busy) return;
const data = new FormData(event.currentTarget);
setBusy(true);
setError("");
try {
// 设置模式只提交display_name/username,避免将整个profile(含只读信息)作为修改payload。
if (settings) {
const input = { display_name: String(data.get("display_name")).trim(), username: String(data.get("username")).trim() };
const updated = demo ? { ...profile, ...input } : await api<Profile>("profile", { method: "PATCH", body: JSON.stringify(input) });
// 宿主回调也在 try 内;若回调抛错,会显示失败,但已成功的服务端修改不会因此回滚。
onProfile(updated);
} else {
const name = String(data.get("name")).trim();
const created = demo ? { id: crypto.randomUUID(), name, role: "admin" as const } : await api<Workspace>("workspaces", { method: "POST", body: JSON.stringify({ name }) });
onWorkspace(created);
}
onClose();
} catch (e) { setError(errorText(e)); }
finally { setBusy(false); }
}
// 请求没有本地 AbortController/结果代次标记;受控 Dialog 阻止普通关闭,父级强制卸载仍由宿主管理。
// 保存中禁用取消/提交,并拒绝Dialog的关闭状态变化;请求未结束不假装已成功关闭。
return (
<Dialog open onOpenChange={(open) => { if (!open && !busy) onClose(); }}>
<DialogContent className={styles.settingsPanel}>
<DialogHeader>
<DialogTitle>{settings ? "账户设置" : "新建工作空间"}</DialogTitle>
<DialogDescription>{settings ? "管理你的个人资料。" : "为相关的图谱和资料创建一个空间。"}</DialogDescription>
</DialogHeader>
<form className={styles.settingsForm} onSubmit={submit}>
{settings ? (
<>
<div className={styles.settingsIdentity}><UserAvatar name={name} url={profile.avatar_url} large /><span>{profile.email || profile.username}</span></div>
<label>显示名称<input name="display_name" defaultValue={profile.display_name || ""} maxLength={80} /></label>
<label>用户名<input name="username" defaultValue={profile.username} pattern="[a-z0-9][a-z0-9_-]{2,39}" minLength={3} maxLength={40} required /></label>
</>
) : <label>空间名称<input name="name" placeholder="例如:我的创作空间" maxLength={50} required autoFocus /></label>}
{error && <p className={styles.error} role="alert">{error}</p>}
<div className={styles.settingsActions}>
<button type="button" onClick={onClose} disabled={busy}>取消</button>
<button type="submit" disabled={busy}>{busy ? "正在保存…" : settings ? "保存" : "创建空间"}</button>
</div>
</form>
</DialogContent>
</Dialog>
);
}