Files
tyx_AI_xhs/apps/web/src/account-settings.tsx
T

274 lines
12 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 { type FormEvent, useEffect, useId, useState } from "react";
import "./account-settings.css";
interface SettingsPayload {
account: { email: string; status: "active" };
csrf_token: string;
local_data: {
backup_enabled: false;
capacity_status: "normal" | "warning" | "critical" | "full" | "unavailable";
hard_limit_bytes: number;
location: "configured_local_data_root";
managed_content_bytes: number;
migration_supported: false;
};
profile: { creator_name: string; social_id: string };
}
function formatBytes(bytes: number) {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
return `${Math.max(0, bytes)} B`;
}
function capacityLabel(status: SettingsPayload["local_data"]["capacity_status"]) {
if (status === "full") return "本机容量已满";
if (status === "unavailable") return "本机数据暂不可写";
if (status === "critical") return "本机容量接近上限";
if (status === "warning") return "本机容量需要关注";
return "本机容量正常";
}
export function AccountSettingsPage() {
const creatorNameId = useId();
const socialId = useId();
const confirmationId = useId();
const deletionCodeId = useId();
const [settings, setSettings] = useState<SettingsPayload>();
const [creatorName, setCreatorName] = useState("");
const [socialHandle, setSocialHandle] = useState("");
const [loadingError, setLoadingError] = useState(false);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState(false);
const [saved, setSaved] = useState(false);
const [deletionOpen, setDeletionOpen] = useState(false);
const [deletionId, setDeletionId] = useState<string>();
const [deletionCode, setDeletionCode] = useState("");
const [confirmation, setConfirmation] = useState("");
const [sendingCode, setSendingCode] = useState(false);
const [deleting, setDeleting] = useState(false);
const [deletionError, setDeletionError] = useState(false);
useEffect(() => {
let active = true;
void fetch("/api/v1/account/settings", { credentials: "same-origin" })
.then(async (response) => {
if (!response.ok) throw new Error("settings_load_failed");
return response.json() as Promise<SettingsPayload>;
})
.then((payload) => {
if (!active) return;
setSettings(payload);
setCreatorName(payload.profile.creator_name);
setSocialHandle(payload.profile.social_id);
})
.catch(() => {
if (active) setLoadingError(true);
});
return () => { active = false; };
}, []);
async function saveProfile(event: FormEvent) {
event.preventDefault();
if (!settings || saving || !creatorName.trim() || !socialHandle.trim()) return;
setSaving(true);
setSaveError(false);
setSaved(false);
try {
const response = await fetch("/api/v1/account/settings/profile", {
body: JSON.stringify({ creator_name: creatorName, social_id: socialHandle }),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRF-Token": settings.csrf_token },
method: "PUT",
});
if (!response.ok) throw new Error("profile_save_failed");
setSaved(true);
} catch {
setSaveError(true);
} finally {
setSaving(false);
}
}
async function sendDeletionCode() {
if (!settings || sendingCode || deleting) return;
setSendingCode(true);
setDeletionError(false);
try {
const response = await fetch("/api/v1/account/deletion/send", {
credentials: "same-origin",
headers: { "X-CSRF-Token": settings.csrf_token },
method: "POST",
});
const body = await response.json() as { deletion_id?: string };
if (!response.ok || !body.deletion_id) throw new Error("deletion_code_failed");
setDeletionId(body.deletion_id);
} catch {
setDeletionError(true);
} finally {
setSendingCode(false);
}
}
async function completeDeletion(event: FormEvent) {
event.preventDefault();
if (!settings || !deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting) return;
setDeleting(true);
setDeletionError(false);
try {
const response = await fetch("/api/v1/account/deletion/complete", {
body: JSON.stringify({ confirmation, deletion_id: deletionId, verification_code: deletionCode }),
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""),
"X-CSRF-Token": settings.csrf_token,
},
method: "POST",
});
if (!response.ok) throw new Error("account_deletion_failed");
window.location.assign("/");
} catch {
setDeletionError(true);
setDeleting(false);
}
}
function closeDeletion() {
if (deleting) return;
setDeletionOpen(false);
setDeletionId(undefined);
setDeletionCode("");
setConfirmation("");
setDeletionError(false);
}
if (!settings && !loadingError) {
return <main className="settings-loading" aria-live="polite">正在读取设置</main>;
}
if (!settings) {
return (
<main className="settings-loading">
<p role="alert">设置暂时无法读取。</p>
<button onClick={() => window.location.reload()} type="button">重新读取</button>
</main>
);
}
const capacityPercent = Math.min(100, Math.round((settings.local_data.managed_content_bytes / settings.local_data.hard_limit_bytes) * 100));
return (
<main className="settings-page">
<header className="settings-header">
<a className="settings-brand" href="/app">DADA</a>
<nav aria-label="账号导航">
<a href="/app">创作工作台</a>
<span aria-current="page">设置</span>
</nav>
</header>
<div className="settings-title">
<p>ACCOUNT / LOCAL DATA</p>
<h1>设置与本机数据</h1>
</div>
<section className="settings-section" aria-labelledby="profile-title">
<div className="settings-section-heading">
<h2 id="profile-title">个人资料</h2>
<p>用于生成内容中的署名信息。</p>
</div>
<form className="settings-form" onSubmit={saveProfile}>
<label htmlFor={creatorNameId}>创作署名</label>
<input id={creatorNameId} maxLength={80} onChange={(event) => setCreatorName(event.target.value)} value={creatorName} />
<label htmlFor={socialId}>社交 ID</label>
<input id={socialId} maxLength={80} onChange={(event) => setSocialHandle(event.target.value)} value={socialHandle} />
{saveError ? <p className="settings-error" role="alert">保存失败,当前输入已保留,请重试。</p> : null}
{saved ? <p className="settings-saved" role="status">资料已保存。</p> : null}
<button className="settings-primary" disabled={saving || !creatorName.trim() || !socialHandle.trim()} type="submit">
{saving ? "保存中" : "保存资料"}
</button>
</form>
</section>
<section className="settings-section" aria-labelledby="account-title">
<div className="settings-section-heading">
<h2 id="account-title">账号</h2>
<p>邮箱仅用于验证码认证。</p>
</div>
<dl className="settings-definition">
<div><dt>邮箱</dt><dd>{settings.account.email}</dd></div>
<div><dt>状态</dt><dd>正常</dd></div>
<div><dt>登录方式</dt><dd>邮箱验证码</dd></div>
</dl>
</section>
<section className="settings-section settings-local" aria-labelledby="local-title">
<div className="settings-section-heading">
<h2 id="local-title">本机数据</h2>
<p>逻辑位置:Dada 配置的本机数据目录</p>
</div>
<div className="settings-capacity" data-status={settings.local_data.capacity_status}>
<div>
<strong>{capacityLabel(settings.local_data.capacity_status)}</strong>
<span>{formatBytes(settings.local_data.managed_content_bytes)} / {formatBytes(settings.local_data.hard_limit_bytes)}</span>
</div>
<div className="settings-capacity-track" role="progressbar" aria-label="本机数据容量" aria-valuemax={100} aria-valuemin={0} aria-valuenow={capacityPercent}>
<span style={{ width: `${capacityPercent}%` }} />
</div>
</div>
<p className="settings-fixed-notice">测试数据仅保存在本机,不自动备份,也不会迁移到正式系统。</p>
<p className="settings-detail">数据依赖当前 Windows 用户登录和文件系统权限,不提供 Dada 应用层加密或云备份。机器损坏、重装或删除本机数据目录后无法恢复。</p>
</section>
<section className="settings-danger" aria-labelledby="danger-title">
<div>
<h2 id="danger-title">注销账号</h2>
<p>注销会立即删除账号资料、未使用点数和全部作品,并使所有会话失效。</p>
</div>
<button className="settings-danger-button" onClick={() => setDeletionOpen(true)} type="button">注销账号</button>
</section>
{deletionOpen ? (
<div className="settings-dialog-backdrop">
<section
aria-labelledby="delete-dialog-title"
aria-modal="true"
className="settings-dialog"
onKeyDown={(event) => {
if (event.key === "Escape" && !deleting) closeDeletion();
}}
role="dialog"
>
<header>
<div>
<p>DANGER ZONE</p>
<h2 id="delete-dialog-title">确认注销账号</h2>
</div>
<button aria-label="关闭注销确认" autoFocus disabled={deleting} onClick={closeDeletion} type="button">×</button>
</header>
<div className="settings-dialog-body">
<p className="settings-dialog-warning">账号、资料、项目、图片、坐标、成品和未使用点数将立即删除且不可恢复。</p>
<p>匿名生成与点数事件最多保留 180 天,只保留随机主体、时间、模型、结果、错误类别和点数变化;不会保留邮箱或作品内容。</p>
<button className="settings-code-button" disabled={sendingCode || deleting || Boolean(deletionId)} onClick={sendDeletionCode} type="button">
{sendingCode ? "发送中" : deletionId ? "验证码已发送" : "获取注销验证码"}
</button>
<form onSubmit={completeDeletion}>
<label htmlFor={deletionCodeId}>注销验证码</label>
<input disabled={deleting || !deletionId} id={deletionCodeId} inputMode="numeric" maxLength={6} onChange={(event) => setDeletionCode(event.target.value.replace(/\D/g, ""))} value={deletionCode} />
<label htmlFor={confirmationId}>确认词</label>
<input disabled={deleting} id={confirmationId} onChange={(event) => setConfirmation(event.target.value)} placeholder="输入:注销账号" value={confirmation} />
{deletionError ? <p className="settings-error" role="alert">注销请求未完成,请核对验证码后重试。</p> : null}
<button className="settings-delete-confirm" disabled={!deletionId || deletionCode.length !== 6 || confirmation !== "注销账号" || deleting} type="submit">
{deleting ? "注销处理中" : "永久注销"}
</button>
</form>
</div>
</section>
</div>
) : null}
</main>
);
}