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: }, { key: "security", label: "安全", icon: }, { key: "notify", label: "通知", icon: }, ], }, // 「偏好」组(创作默认 / 显示)已隐藏 —— 入口移除,对应面板因导航不可达而不再显示。 ]; 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: "站内 · 短信" }, { key: "n-login", title: "异地登录告警", channels: "短信" }, ]; // ─── 偏好默认值 · 与后端 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, "n-login": true } as Record, 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; // display · 显示 appearance: string; language: string; density: string; }; // 脏字段 → 所属 section(顶栏计数 + nav dirty-dot 用) const FIELD_SECTION: Record = { 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, b: Record): 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 ( ); } 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) => void | Promise; onRevokeSession?: (id: string) => void | Promise; onRevokeOthers?: () => void | Promise; onSaveProfile: (payload: { name?: string; phone?: string; email?: string }) => void | Promise; onChangePassword: (payload: { old_password: string; new_password: string }) => void | Promise; onUploadAvatar: (formData: FormData) => void | Promise; onResetAvatar?: () => void | Promise; onNotify?: (text: string) => void; onLogout?: () => void | Promise; }) { const normalizedInitial = (SECTION_KEYS as readonly string[]).includes(initialSection) ? (initialSection as SectionKey) : "profile"; const [section, setSection] = useState(normalizedInitial); const [modal, setModal] = useState<"" | "avatar" | "logout" | "password">(""); // ─── 统一保存:已保存基线(baseline)从真实用户/后端 preferences 注入 ─── const baselineFromProps = useMemo(() => { 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(baselineFromProps); const [draft, setDraft] = useState(baselineFromProps); const [saving, setSaving] = useState(false); // 后端 preferences / user 到达或刷新时,把新值同时灌进 baseline 与 draft(用户未改时跟随后端) useEffect(() => { setBaseline(baselineFromProps); setDraft(baselineFromProps); }, [baselineFromProps]); // 单字段 draft 更新器 const patchDraft = useCallback((key: K, value: TrackedState[K]) => { setDraft((prev) => ({ ...prev, [key]: value })); }, []); // ─── 聚合脏字段集 + 涉及分区集(顶栏计数 / nav dirty-dot / beforeunload 全由此驱动) ─── const dirtyFields = useMemo>(() => { const keys = Object.keys(FIELD_SECTION) as Array; return keys.filter((key) => { if (key === "notify") return !notifyEqual(draft.notify, baseline.notify); return draft[key] !== baseline[key]; }); }, [draft, baseline]); const dirtySections = useMemo>(() => { const set = new Set(); 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(null); const [avatarFile, setAvatarFile] = useState(null); const [avatarPreview, setAvatarPreview] = useState(""); 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 = {}; const creationKeys: Array = ["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 = ["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> = []; 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) { 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 (

设置

// 个人信息 · 偏好 · 通知 · 安全
{/* 左侧 nav */} {/* 右侧内容 */}
{section === "profile" && (

个人信息

// 头像、姓名、联系方式 · 邮箱用于接收通知
头像
{avatarChar}
显示名称*
patchDraft("name", event.target.value)} />
登录邮箱
patchDraft("email", event.target.value)} />
手机号
patchDraft("phone", event.target.value)} placeholder="138****8000" /> // 在「保存所有变更」中一并提交
所属团队
// 一人一团队
{team.name} 超管 · 创建者 管理团队 →
用户 ID
// 不可改
{user.id}
)} {section === "security" && (

安全

// 登录密码、双因素、在用设备
登录密码
●●●●●●●●●● 上次修改 2026-04-12
两步验证
// 推荐开启
patchDraft("twoFactor", v)} /> 短信 + Authenticator

在用设备

// 真实登录会话 · 每次登录记录设备 UA / IP
{sessions.length === 0 ? (
// 暂无其他登录会话记录
) : ( sessions.map((s) => { const isPhone = /iphone|android|mobile/i.test(s.user_agent); return (
{isPhone ? ( ) : ( )}
{deviceName(s.user_agent)}{s.is_current ? CURRENT : null}
// {s.ip_address || "未知 IP"} · {new Date(s.last_seen_at || s.created_at).toLocaleString("zh-CN")}
{s.is_current ? 当前会话 : }
); }) )}
)} {section === "notify" && (

通知

// 邮件、短信、站内提示开关
{NOTIFY_ROWS.map((row) => (
{row.title}{row.sub ?
{row.sub}
: null}
patchDraft("notify", { ...draft.notify, [row.key]: next })} /> {row.channels}
))}
)} {section === "pref" && (

创作默认

// 新建项目时的预填值,可在向导中改
默认模板
{TEMPLATE_CHOICES.map((choice) => (
patchDraft("template", choice.v)} >
{choice.t}
{choice.d}
))}
默认时长档
{DURATIONS.map((d) => ( patchDraft("duration", d)} > {d}s ))}
// 60s = 4 段 × 15s
默认字幕样式
{SUBTITLE_CHOICES.map((choice) => (
patchDraft("subtitle", choice.v)} >
{choice.t}
{choice.d}
))}
默认 BGM 库
默认转场
导出水印
// VIP 可关闭
右下角 · Airshelf 升级 VIP →
)} {section === "display" && (

显示

// 界面外观与语言
外观
语言
表格密度
)}
// Airshelf · v2.1 · build 20260521
{/* 上传头像 modal · 选图 → FormData(file) → onUploadAvatar */} } close={() => setModal("")} footer={ } >
{avatarPreview ? 头像预览 : avatarChar}
{avatarFile ? avatarFile.name : "当前头像 · 默认"}
{avatarFile ? `// ${(avatarFile.size / 1024).toFixed(0)} KB · 已选择` : "// 系统生成 · 取姓氏首字"}
fileInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); fileInputRef.current?.click(); } }} >
点击选择 · 图片文件
JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256
最大 2 MB · 长宽比建议 1:1 · 系统会自动裁切为圆形
不要上传含他人肖像的图片,违规可能导致账号封停
{/* 修改密码 modal · 原密码 + 新密码(≥8)→ onChangePassword */} } close={() => setModal("")} footer={ } >
setOldPassword(event.target.value)} placeholder="输入当前密码" /> {pwSubmitted && !oldPassword ? 请输入原密码 : null}
setNewPassword(event.target.value)} placeholder="至少 8 位" /> {pwTooShort || (pwSubmitted && newPassword.length < 8) ? 新密码至少 8 位 : // 建议混合字母、数字与符号}
{/* 退出登录确认 modal · 仅视觉还原,无后端接入 */} } close={() => setModal("")} footer={ } >

确认后将退出当前设备上的 Airshelf,再次使用需要重新登录。

项目、资产、团队成员与余额数据都会保留
仅影响当前浏览器会话,不会下线其他设备
{isDirty ?
当前有 {dirtyCount} 项未保存的设置变更,退出后这些变更不会保存。
: null}
); }