Files
yingqing/core/frontend/src/routes/settings.tsx
T
2026-08-18 18:32:19 +08:00

824 lines
36 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 { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent, ReactNode } from "react";
import {
Bell,
KeyRound,
LogOut,
ShieldCheck,
Upload,
User as UserIcon,
} from "lucide-react";
import type { LoginSession, Team, User, UserPreference } from "../types";
import { TeamModal } from "../components/overlays";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
// 可导航(UI 可见)的分区。pref(创作默认)/ display(显示)已隐藏入口,故不在此列。
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify"];
const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string; icon: ReactNode }> }> = [
{
group: "个人",
items: [
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
{ key: "security", label: "安全", icon: <ShieldCheck /> },
{ key: "notify", label: "通知", icon: <Bell /> },
],
},
// 「偏好」组(创作默认 / 显示)已隐藏 —— 入口移除,对应面板因导航不可达而不再显示。
];
const TEMPLATE_CHOICES = [
{ v: "pain", t: "痛点种草", d: "// 30s 默认档" },
{ v: "unbox", t: "开箱测评", d: "// 45s 默认档" },
{ v: "compare", t: "对比展示", d: "// 45s 默认档" },
{ v: "howto", t: "教程演示", d: "// 60s 默认档" },
{ v: "drama", t: "剧情带货", d: "// 60s 默认档" },
];
const SUBTITLE_CHOICES = [
{ v: "big-variety", t: "大字综艺", d: "// 抖音热门" },
{ v: "clean-ec", t: "简洁电商", d: "// 信息清晰" },
{ v: "premium", t: "高级排版", d: "// 居中衬线" },
{ v: "bullet", t: "弹幕轻量", d: "// 滚动出现" },
{ v: "emphasis", t: "强调爆款", d: "// 高对比" },
];
const DURATIONS = ["30", "45", "60"];
// 从 User-Agent 提取「系统 · 浏览器」可读名
function deviceName(ua: string): string {
if (!ua) return "未知设备";
const os = /Windows/i.test(ua) ? "Windows" : /Mac OS X|Macintosh/i.test(ua) ? "macOS" : /iPhone|iPad/i.test(ua) ? "iOS" : /Android/i.test(ua) ? "Android" : /Linux/i.test(ua) ? "Linux" : "设备";
const browser = /Edg/i.test(ua) ? "Edge" : /Chrome/i.test(ua) ? "Chrome" : /Safari/i.test(ua) ? "Safari" : /Firefox/i.test(ua) ? "Firefox" : ua.slice(0, 24);
return `${os} · ${browser}`;
}
// 仅保留已真实接入「站内通知」的行;邮箱/短信/异地登录告警渠道未接入,隐藏对应行。
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内" },
{ key: "n-fail", title: "任务失败告警", channels: "站内" },
{ key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内" },
// n-login(异地登录告警·短信渠道)未接入,已移除。
];
// ─── 偏好默认值 · 与后端 UserPreference 默认一致(后端到达前的占位) ───
const DEFAULT_PREFS = {
template: "pain",
duration: "60",
subtitle: "big-variety",
bgm: "kapian",
transition: "fade",
twoFactor: false,
notify: { "n-export": true, "n-fail": true, "n-quota": true } as Record<string, boolean>,
appearance: "system",
language: "zh",
density: "standard",
};
// ─── 可统一保存的脏字段全集(draft 与 baseline 逐字段 diff,聚合驱动顶栏/nav/beforeunload) ───
// 不含:头像(FormData 文件操作)/ 改密 / 会话下线 —— 这些是即时副作用,各自有独立确认,不计入脏字段。
type TrackedState = {
// profile · 个人信息
name: string;
email: string;
phone: string;
// pref · 创作默认
template: string;
duration: string;
subtitle: string;
bgm: string;
transition: string;
// security · 安全(两步验证)
twoFactor: boolean;
// notify · 通知开关
notify: Record<string, boolean>;
// display · 显示
appearance: string;
language: string;
density: string;
};
// 脏字段 → 所属 section(顶栏计数 + nav dirty-dot 用)
const FIELD_SECTION: Record<keyof TrackedState, SectionKey> = {
name: "profile",
email: "profile",
phone: "profile",
template: "pref",
duration: "pref",
subtitle: "pref",
bgm: "pref",
transition: "pref",
twoFactor: "security",
notify: "notify",
appearance: "display",
language: "display",
density: "display",
};
function notifyEqual(a: Record<string, boolean>, b: Record<string, boolean>): boolean {
return NOTIFY_ROWS.every((row) => !!a[row.key] === !!b[row.key]);
}
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
return (
<label className="switch">
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange?.(event.target.checked)} />
<span className="slider" />
</label>
);
}
export function SettingsPage({
user,
team,
initialSection = "profile",
preferences,
sessions = [],
onSavePreferences,
onRevokeSession,
onRevokeOthers,
onSaveProfile,
onChangePassword,
onUploadAvatar,
onResetAvatar,
onNotify,
onLogout,
}: {
user: User;
team: Team;
initialSection?: string;
preferences?: UserPreference | null;
sessions?: LoginSession[];
onSavePreferences?: (payload: Partial<UserPreference>) => void | Promise<unknown>;
onRevokeSession?: (id: string) => void | Promise<unknown>;
onRevokeOthers?: () => void | Promise<unknown>;
onSaveProfile: (payload: { name?: string; phone?: string; email?: string }) => void | Promise<unknown>;
onChangePassword: (payload: { old_password: string; new_password: string }) => void | Promise<unknown>;
onUploadAvatar: (formData: FormData) => void | Promise<unknown>;
onResetAvatar?: () => void | Promise<unknown>;
onNotify?: (text: string) => void;
onLogout?: () => void | Promise<void>;
}) {
const normalizedInitial = (SECTION_KEYS as readonly string[]).includes(initialSection)
? (initialSection as SectionKey)
: "profile";
const [section, setSection] = useState<SectionKey>(normalizedInitial);
const [modal, setModal] = useState<"" | "avatar" | "logout" | "password">("");
// ─── 统一保存:已保存基线(baseline)从真实用户/后端 preferences 注入 ───
const baselineFromProps = useMemo<TrackedState>(() => {
const cd = preferences?.creation_defaults;
const dp = preferences?.display;
return {
// 显示名称存在后端 first_name(UserSerializer 暴露),username 不随保存变;优先读 first_name 回退 username
name: (user as { first_name?: string }).first_name || user.username || "",
email: user.email || "",
phone: "",
template: cd?.template ?? DEFAULT_PREFS.template,
duration: cd?.duration ?? DEFAULT_PREFS.duration,
subtitle: cd?.subtitle ?? DEFAULT_PREFS.subtitle,
bgm: cd?.bgm ?? DEFAULT_PREFS.bgm,
transition: cd?.transition ?? DEFAULT_PREFS.transition,
twoFactor: !!preferences?.two_factor_enabled,
notify: { ...DEFAULT_PREFS.notify, ...(preferences?.notify || {}) },
appearance: dp?.appearance ?? DEFAULT_PREFS.appearance,
language: dp?.language ?? DEFAULT_PREFS.language,
density: dp?.density ?? DEFAULT_PREFS.density,
};
}, [user, preferences]);
// baseline = 已保存值;draft = 当前编辑值。diff(draft, baseline) = 脏字段。
const [baseline, setBaseline] = useState<TrackedState>(baselineFromProps);
const [draft, setDraft] = useState<TrackedState>(baselineFromProps);
const [saving, setSaving] = useState(false);
// 后端 preferences / user 到达或刷新时,把新值同时灌进 baseline 与 draft(用户未改时跟随后端)
useEffect(() => {
setBaseline(baselineFromProps);
setDraft(baselineFromProps);
}, [baselineFromProps]);
// 单字段 draft 更新器
const patchDraft = useCallback(<K extends keyof TrackedState>(key: K, value: TrackedState[K]) => {
setDraft((prev) => ({ ...prev, [key]: value }));
}, []);
// ─── 聚合脏字段集 + 涉及分区集(顶栏计数 / nav dirty-dot / beforeunload 全由此驱动) ───
const dirtyFields = useMemo<Array<keyof TrackedState>>(() => {
const keys = Object.keys(FIELD_SECTION) as Array<keyof TrackedState>;
return keys.filter((key) => {
if (key === "notify") return !notifyEqual(draft.notify, baseline.notify);
return draft[key] !== baseline[key];
});
}, [draft, baseline]);
const dirtySections = useMemo<Set<SectionKey>>(() => {
const set = new Set<SectionKey>();
dirtyFields.forEach((key) => set.add(FIELD_SECTION[key]));
return set;
}, [dirtyFields]);
const dirtyCount = dirtyFields.length;
const isDirty = dirtyCount > 0;
// 个人信息 · 受控输入(draft 派生)
const name = draft.name;
const email = draft.email;
const phone = draft.phone;
// 改密 · 受控输入
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [pwSubmitted, setPwSubmitted] = useState(false);
const [savingPassword, setSavingPassword] = useState(false);
const pwTooShort = newPassword.length > 0 && newPassword.length < 8;
const pwReady = oldPassword.length > 0 && newPassword.length >= 8;
// 头像 · 文件选择 + 本地预览
const fileInputRef = useRef<HTMLInputElement>(null);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>("");
const [savingAvatar, setSavingAvatar] = useState(false);
const avatarChar = useMemo(() => (name || user.username || "李").slice(0, 1).toUpperCase(), [name, user.username]);
// ─── nav badge · 由 state 计算(通知开启数 / 设备数),不写死 ───
const notifyOnCount = useMemo(() => NOTIFY_ROWS.filter((row) => !!draft.notify[row.key]).length, [draft.notify]);
const navBadge = useCallback(
(key: SectionKey): string | null => {
if (key === "notify") return `${notifyOnCount}/${NOTIFY_ROWS.length}`;
if (key === "security") return `${sessions.length} 设备`;
return null;
},
[notifyOnCount, sessions.length],
);
// ─── 取消:draft 回退到 baseline(放弃所有未保存改动) ───
function discardChanges() {
if (!isDirty) return;
setDraft(baseline);
onNotify?.("已放弃未保存的改动");
}
// ─── 统一保存:把所有脏字段拆成 profile / preferences 两个 payload 提交,成功后把 draft 升为新基线 ───
async function handleSaveAll() {
if (!isDirty || saving) return;
setSaving(true);
try {
const profilePayload: { name?: string; email?: string; phone?: string } = {};
if (draft.name !== baseline.name) profilePayload.name = draft.name.trim();
if (draft.email !== baseline.email) profilePayload.email = draft.email.trim();
if (draft.phone !== baseline.phone) profilePayload.phone = draft.phone.trim();
const prefPayload: Partial<UserPreference> = {};
const creationKeys: Array<keyof TrackedState> = ["template", "duration", "subtitle", "bgm", "transition"];
if (creationKeys.some((key) => draft[key] !== baseline[key])) {
prefPayload.creation_defaults = {
template: draft.template,
duration: draft.duration,
subtitle: draft.subtitle,
bgm: draft.bgm,
transition: draft.transition,
};
}
const displayKeys: Array<keyof TrackedState> = ["appearance", "language", "density"];
if (displayKeys.some((key) => draft[key] !== baseline[key])) {
prefPayload.display = { appearance: draft.appearance, language: draft.language, density: draft.density };
}
if (!notifyEqual(draft.notify, baseline.notify)) prefPayload.notify = { ...draft.notify };
if (draft.twoFactor !== baseline.twoFactor) prefPayload.two_factor_enabled = draft.twoFactor;
const tasks: Array<Promise<unknown>> = [];
if (Object.keys(profilePayload).length > 0) tasks.push(Promise.resolve(onSaveProfile(profilePayload)));
if (Object.keys(prefPayload).length > 0 && onSavePreferences) tasks.push(Promise.resolve(onSavePreferences(prefPayload)));
await Promise.all(tasks);
// 提交成功 → draft 升为新基线,脏集清空
const savedCount = dirtyCount;
setBaseline(draft);
onNotify?.(`${savedCount} 项已保存`);
} finally {
setSaving(false);
}
}
function openPasswordModal() {
setOldPassword("");
setNewPassword("");
setPwSubmitted(false);
setModal("password");
}
async function handleChangePassword() {
setPwSubmitted(true);
if (!pwReady || savingPassword) return;
setSavingPassword(true);
try {
await onChangePassword({ old_password: oldPassword, new_password: newPassword });
setModal("");
setOldPassword("");
setNewPassword("");
setPwSubmitted(false);
} finally {
setSavingPassword(false);
}
}
function openAvatarModal() {
setAvatarFile(null);
setAvatarPreview("");
setModal("avatar");
}
function onPickAvatar(event: ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
if (!file) return;
setAvatarFile(file);
setAvatarPreview(URL.createObjectURL(file));
}
async function handleUploadAvatar() {
if (!avatarFile || savingAvatar) return;
setSavingAvatar(true);
try {
const fd = new FormData();
fd.append("file", avatarFile);
await onUploadAvatar(fd);
setModal("");
setAvatarFile(null);
setAvatarPreview("");
} finally {
setSavingAvatar(false);
}
}
// 预览 URL 在切换/卸载时释放,避免内存泄漏
useEffect(() => {
if (!avatarPreview) return;
return () => URL.revokeObjectURL(avatarPreview);
}, [avatarPreview]);
// ─── 离开页面前提醒:有未保存改动时弹浏览器原生确认 ───
useEffect(() => {
if (!isDirty) return;
const handler = (event: BeforeUnloadEvent) => {
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, [isDirty]);
return (
<section className="settings-page">
<div className="page-head">
<div>
<h1>设置</h1>
<div className="sub">个人信息、偏好、通知与安全</div>
</div>
<div className="actions">
<button className="btn" type="button" onClick={discardChanges} disabled={!isDirty || saving}>取消</button>
<button className="btn btn-primary" type="button" onClick={handleSaveAll} disabled={!isDirty || saving}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
保存所有更改
{isDirty ? <span className="save-count">· {dirtyCount} </span> : null}
</button>
</div>
</div>
<div className="settings-grid">
{/* 左侧 nav */}
<aside className="settings-nav" role="tablist" aria-label="设置分区">
{NAV.map((group, gi) => (
<div key={group.group}>
<div className="nav-h" style={gi > 0 ? { marginTop: 16 } : undefined}>{group.group}</div>
{group.items.map((item) => {
const badge = navBadge(item.key);
const dirty = dirtySections.has(item.key);
return (
<a
key={item.key}
href={`#sec-${item.key}`}
className={`${section === item.key ? "active" : ""}${dirty ? " has-changes" : ""}`}
role="tab"
aria-selected={section === item.key}
onClick={(event) => {
event.preventDefault();
setSection(item.key);
}}
>
{item.icon}
<span>{item.label}</span>
{badge ? <span className="nav-badge">{badge}</span> : null}
<span className="nav-dot" aria-hidden="true" />
</a>
);
})}
</div>
))}
<div className="nav-h" style={{ marginTop: 16 }}>账号</div>
<button className="logout-pill" type="button" onClick={() => setModal("logout")}>
<LogOut />
<span>退出登录</span>
</button>
</aside>
{/* 右侧内容 */}
<main>
{section === "profile" && (
<section className="pane" aria-label="个人信息">
<h3>个人信息</h3>
<div className="pane-desc">// 头像、姓名、联系方式</div>
<div className="form-row">
<div className="lbl">头像</div>
<div className="val">
<div className="avatar-edit">
<div className="av-big">{user.avatar_url ? <img src={user.avatar_url} alt="头像" /> : avatarChar}</div>
<div className="av-actions">
<button className="btn btn-sm" type="button" onClick={openAvatarModal}>上传新头像</button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onResetAvatar?.()}>恢复默认</button>
</div>
</div>
</div>
</div>
<div className="form-row">
<div className="lbl">显示名称<span className="req">*</span></div>
<div className="val"><input className="input" value={name} onChange={(event) => patchDraft("name", event.target.value)} /></div>
</div>
<div className="form-row">
<div className="lbl">登录邮箱<div className="lbl-sub">// 仅做记录用</div></div>
<div className="val">
<input className="input" type="email" value={email} onChange={(event) => patchDraft("email", event.target.value)} />
{/* 邮件服务未接入,验证功能暂不可用,入口已隐藏 */}
<span className="switch-note">// 邮件验证未启用</span>
</div>
</div>
<div className="form-row">
<div className="lbl">手机号</div>
<div className="val">
<input className="input" value={phone} onChange={(event) => patchDraft("phone", event.target.value)} placeholder="138****8000" />
<span className="switch-note">// 在「保存所有更改」中一并提交</span>
</div>
</div>
<div className="form-row">
<div className="lbl">所属团队<div className="lbl-sub">// 一人一团队</div></div>
<div className="val">
<span className="static">{team.name}</span>
<span className="role-tag"><span className="dot" />超管 · 创建者</span>
<a
href="/team"
className="row-link"
onClick={(event) => {
event.preventDefault();
window.history.pushState(null, "", "/team");
window.dispatchEvent(new PopStateEvent("popstate"));
}}
>
管理团队
</a>
</div>
</div>
<div className="form-row">
<div className="lbl">用户 ID<div className="lbl-sub">// 不可改</div></div>
<div className="val"><span className="static mono">{user.id}</span></div>
</div>
</section>
)}
{section === "security" && (
<section className="pane" aria-label="安全">
<h3>安全</h3>
<div className="pane-desc">// 登录密码、双因素、在用设备</div>
<div className="form-row">
<div className="lbl">登录密码</div>
<div className="val">
<span className="static mono">●●●●●●●●●●</span>
<span className="row-note" style={{ marginLeft: "auto" }}>上次修改 2026-04-12</span>
<button className="btn btn-sm" type="button" style={{ marginLeft: 10 }} onClick={openPasswordModal}>修改</button>
</div>
</div>
<div className="form-row">
<div className="lbl">两步验证<div className="lbl-sub">// 推荐开启</div></div>
<div className="val">
<Switch checked={draft.twoFactor} onChange={(v) => patchDraft("twoFactor", v)} />
<span className="switch-note">短信 + Authenticator</span>
</div>
</div>
<h3 className="sub-head">在用设备</h3>
<div className="pane-desc">// 真实登录会话 · 每次登录记录设备 UA / IP</div>
<div className="device-list">
{sessions.length === 0 ? (
<div className="device-row"><div className="meta" style={{ padding: "8px 0" }}>// 暂无其他登录会话记录</div></div>
) : (
sessions.map((s) => {
const isPhone = /iphone|android|mobile/i.test(s.user_agent);
return (
<div className="device-row" key={s.id}>
<div className="ic">
{isPhone ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
)}
</div>
<div>
<div className="nm">{deviceName(s.user_agent)}{s.is_current ? <span className="tag-cur">CURRENT</span> : null}</div>
<div className="meta">// {s.ip_address || "未知 IP"} · {new Date(s.last_seen_at || s.created_at).toLocaleString("zh-CN")}</div>
</div>
<div className="spacer" />
{s.is_current
? <span className="row-note">当前会话</span>
: <button className="btn btn-ghost btn-sm" type="button" onClick={() => onRevokeSession?.(s.id)}>下线</button>}
</div>
);
})
)}
</div>
<div style={{ marginTop: 14 }}>
<button className="btn" type="button" onClick={() => onRevokeOthers?.()} disabled={sessions.length <= 1}>下线所有其他设备</button>
</div>
</section>
)}
{section === "notify" && (
<section className="pane" aria-label="通知">
<h3>通知</h3>
<div className="pane-desc">// 站内通知开关 · 邮件/短信渠道未接入</div>
{NOTIFY_ROWS.map((row) => (
<div className="form-row" key={row.key}>
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
<div className="val">
<Switch checked={!!draft.notify[row.key]} onChange={(next) => patchDraft("notify", { ...draft.notify, [row.key]: next })} />
<span className="switch-note">{row.channels}</span>
</div>
</div>
))}
</section>
)}
{section === "pref" && (
<section className="pane" aria-label="创作默认">
<h3>创作默认</h3>
<div className="pane-desc">// 新建项目时的预填值,可在向导中改</div>
<div className="form-row row-top">
<div className="lbl">默认模板</div>
<div className="val">
<div className="pref-choices">
{TEMPLATE_CHOICES.map((choice) => (
<div
key={choice.v}
className={`pref-choice ${draft.template === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => patchDraft("template", choice.v)}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
</div>
))}
</div>
</div>
</div>
<div className="form-row">
<div className="lbl">默认时长档</div>
<div className="val">
<div className="duration-row">
{DURATIONS.map((d) => (
<span
key={d}
className={`dur-chip ${draft.duration === d ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => patchDraft("duration", d)}
>
{d}s
</span>
))}
</div>
<span className="switch-note" style={{ marginLeft: 10 }}>// 60s = 4 段 × 15s</span>
</div>
</div>
<div className="form-row row-top">
<div className="lbl">默认字幕样式</div>
<div className="val">
<div className="pref-choices">
{SUBTITLE_CHOICES.map((choice) => (
<div
key={choice.v}
className={`pref-choice ${draft.subtitle === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => patchDraft("subtitle", choice.v)}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
</div>
))}
</div>
</div>
</div>
<div className="form-row">
<div className="lbl">默认 BGM </div>
<div className="val">
<select className="select" value={draft.bgm} onChange={(event) => patchDraft("bgm", event.target.value)}>
<option value="kapian">抖音 Top10 卡点曲库</option>
<option value="emotion">情绪向 · 治愈/悬念</option>
<option value="urban">都市电子 · 通勤场景</option>
<option value="none"> BGM</option>
</select>
</div>
</div>
<div className="form-row">
<div className="lbl">默认转场</div>
<div className="val">
<select className="select" value={draft.transition} onChange={(event) => patchDraft("transition", event.target.value)}>
<option value="none">无转场</option>
<option value="fade">淡入淡出 · 0.3s</option>
<option value="slide">滑动 · 0.3s</option>
<option value="zoom">缩放 · 0.3s</option>
</select>
</div>
</div>
<div className="form-row">
<div className="lbl">导出水印<div className="lbl-sub">// VIP 可关闭</div></div>
<div className="val">
<Switch checked disabled />
<span className="switch-note">右下角 · Airshelf</span>
<a href="#account" className="row-link">升级 VIP </a>
</div>
</div>
</section>
)}
{section === "display" && (
<section className="pane" aria-label="显示">
<h3>显示</h3>
<div className="pane-desc">// 界面外观与语言</div>
<div className="form-row">
<div className="lbl">外观</div>
<div className="val">
<select className="select" value={draft.appearance} onChange={(event) => patchDraft("appearance", event.target.value)}>
<option value="system">跟随系统</option>
<option value="light">浅色</option>
<option value="dark" disabled>深色(V2)</option>
</select>
</div>
</div>
<div className="form-row">
<div className="lbl">语言</div>
<div className="val">
<select className="select" value={draft.language} onChange={(event) => patchDraft("language", event.target.value)}>
<option value="zh">简体中文</option>
<option value="en" disabled>English(V2)</option>
</select>
</div>
</div>
<div className="form-row">
<div className="lbl">表格密度</div>
<div className="val">
<select className="select" value={draft.density} onChange={(event) => patchDraft("density", event.target.value)}>
<option value="compact">紧凑</option>
<option value="standard">标准</option>
<option value="loose">宽松</option>
</select>
</div>
</div>
</section>
)}
<div className="settings-foot">// Airshelf · v2.1 · build 20260521</div>
</main>
</div>
{/* 上传头像 modal · 选图 → FormData(file) → onUploadAvatar */}
<TeamModal
open={modal === "avatar"}
title="上传头像"
subtitle="// 用于个人主页、评论与团队展示"
icon={<Upload size={16} />}
close={() => setModal("")}
footer={
<button className="btn btn-primary" type="button" onClick={handleUploadAvatar} disabled={!avatarFile || savingAvatar}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
确认使用
</button>
}
>
<div className="av-up-preview-row">
<div className="av-up-preview">
{avatarPreview ? <img src={avatarPreview} alt="头像预览" /> : avatarChar}
</div>
<div className="av-up-preview-meta">
<div className="t">{avatarFile ? avatarFile.name : "当前头像 · 默认"}</div>
<div className="d">{avatarFile ? `// ${(avatarFile.size / 1024).toFixed(0)} KB · 已选择` : "// 系统生成 · 取姓氏首字"}</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={onPickAvatar}
/>
<div
className="upload-zone"
role="button"
tabIndex={0}
aria-label="点击选择图片上传"
onClick={() => fileInputRef.current?.click()}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
fileInputRef.current?.click();
}
}}
>
<span className="uz-ic">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
</span>
<div><strong>点击选择</strong> · 图片文件</div>
<span className="uz-hint">JPG / PNG / WebP · 2 MB · 推荐 256 × 256</span>
</div>
<div className="av-up-rules">
<div className="li">最大 2 MB · 长宽比建议 1:1 · 系统会自动裁切为圆形</div>
<div className="li">不要上传含他人肖像的图片,违规可能导致账号封停</div>
</div>
</TeamModal>
{/* 修改密码 modal · 原密码 + 新密码(≥8)→ onChangePassword */}
<TeamModal
open={modal === "password"}
title="修改登录密码"
subtitle="// CHANGE PASSWORD"
icon={<KeyRound size={16} />}
close={() => setModal("")}
footer={
<button className="btn btn-primary" type="button" onClick={handleChangePassword} disabled={!pwReady || savingPassword}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12l5 5L20 6" /></svg>
确认修改
</button>
}
>
<div className="field">
<label className="field-label" htmlFor="pw-old">原密码<span className="req">*</span></label>
<input
id="pw-old"
className="input"
type="password"
autoComplete="current-password"
value={oldPassword}
onChange={(event) => setOldPassword(event.target.value)}
placeholder="输入当前密码"
/>
{pwSubmitted && !oldPassword ? <span className="field-hint pw-err">请输入原密码</span> : null}
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label className="field-label" htmlFor="pw-new">新密码<span className="req">*</span></label>
<input
id="pw-new"
className="input"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
placeholder="至少 8 位"
/>
{pwTooShort || (pwSubmitted && newPassword.length < 8)
? <span className="field-hint pw-err">新密码至少 8 </span>
: <span className="field-hint">// 建议混合字母、数字与符号</span>}
</div>
</TeamModal>
{/* 退出登录确认 modal · 仅视觉还原,无后端接入 */}
<TeamModal
open={modal === "logout"}
title="退出当前账号"
subtitle="// LOG OUT CURRENT SESSION"
icon={<LogOut size={16} />}
close={() => setModal("")}
footer={
<button className="btn btn-primary" type="button" onClick={() => { setModal(""); void onLogout?.(); }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /></svg>
确认退出
</button>
}
>
<p className="logout-confirm-copy">确认后将退出当前设备上的 Airshelf,再次使用需要重新登录。</p>
<div className="logout-confirm-points">
<div className="li">项目、资产、团队成员与余额数据都会保留</div>
<div className="li">仅影响当前浏览器会话,不会下线其他设备</div>
</div>
{isDirty ? <div className="logout-unsaved-note">当前有 {dirtyCount} 项未保存的设置变更,退出后这些变更不会保存。</div> : null}
</TeamModal>
</section>
);
}