import { useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent, ReactNode } from "react"; import { Bell, KeyRound, LogOut, Monitor, ShieldCheck, Sliders, Smartphone, 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"; const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string; icon: ReactNode; badge?: string }> }> = [ { group: "个人", items: [ { key: "profile", label: "个人信息", icon: }, { key: "security", label: "安全", icon: , badge: "3 设备" }, { key: "notify", label: "通知", icon: , badge: "4/4" }, ], }, { group: "偏好", items: [ { key: "pref", label: "创作默认", icon: }, { key: "display", 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", }; 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, }: { 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; }) { const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey) ? (initialSection as SectionKey) : "profile"; const [section, setSection] = useState(normalizedInitial); const [modal, setModal] = useState<"" | "avatar" | "logout" | "password">(""); // 个人信息 · 受控输入(初值取真实用户数据) const [name, setName] = useState(user.username || ""); const [email, setEmail] = useState(user.email || ""); const [phone, setPhone] = useState(""); const [savingProfile, setSavingProfile] = useState(false); // 偏好 · 服务端持久化(从后端 preferences 注入初值,改动即 PUT 回后端) const [template, setTemplate] = useState(DEFAULT_PREFS.template); const [duration, setDuration] = useState(DEFAULT_PREFS.duration); const [subtitle, setSubtitle] = useState(DEFAULT_PREFS.subtitle); const [bgm, setBgm] = useState(DEFAULT_PREFS.bgm); const [transition, setTransition] = useState(DEFAULT_PREFS.transition); const [twoFactor, setTwoFactor] = useState(DEFAULT_PREFS.twoFactor); const [notify, setNotify] = useState>(DEFAULT_PREFS.notify); const [appearance, setAppearance] = useState(DEFAULT_PREFS.appearance); const [language, setLanguage] = useState(DEFAULT_PREFS.language); const [density, setDensity] = useState(DEFAULT_PREFS.density); // 后端 preferences 到达时注入(覆盖默认值,缺字段回退默认) useEffect(() => { if (!preferences) return; const cd = preferences.creation_defaults || {}; setTemplate(cd.template ?? DEFAULT_PREFS.template); setDuration(cd.duration ?? DEFAULT_PREFS.duration); setSubtitle(cd.subtitle ?? DEFAULT_PREFS.subtitle); setBgm(cd.bgm ?? DEFAULT_PREFS.bgm); setTransition(cd.transition ?? DEFAULT_PREFS.transition); setTwoFactor(!!preferences.two_factor_enabled); setNotify({ ...DEFAULT_PREFS.notify, ...(preferences.notify || {}) }); const dp = preferences.display || {}; setAppearance(dp.appearance ?? DEFAULT_PREFS.appearance); setLanguage(dp.language ?? DEFAULT_PREFS.language); setDensity(dp.density ?? DEFAULT_PREFS.density); }, [preferences]); // 当前 creation_defaults / display 快照(配合 [key]:value 即时持久化单字段) function saveCreation(patch: Partial) { onSavePreferences?.({ creation_defaults: { template, duration, subtitle, bgm, transition, ...patch } }); } function saveDisplay(patch: Partial) { onSavePreferences?.({ display: { appearance, language, density, ...patch } }); } // 改密 · 受控输入 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]); function resetProfile() { setName(user.username || ""); setEmail(user.email || ""); setPhone(""); } async function handleSaveProfile() { if (savingProfile) return; setSavingProfile(true); try { await onSaveProfile({ name: name.trim(), email: email.trim(), phone: phone.trim() }); } finally { setSavingProfile(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]); return (

设置

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

个人信息

// 头像、姓名、联系方式 · 邮箱用于接收通知
头像
{avatarChar}
显示名称*
setName(event.target.value)} />
登录邮箱
setEmail(event.target.value)} />
手机号
setPhone(event.target.value)} placeholder="138****8000" />
所属团队
// 一人一团队
{team.name} 超管 · 创建者 管理团队 →
用户 ID
// 不可改
{user.id}
)} {section === "security" && (

安全

// 登录密码、双因素、在用设备
登录密码
●●●●●●●●●● 上次修改 2026-04-12
两步验证
// 推荐开启
{ setTwoFactor(v); onSavePreferences?.({ two_factor_enabled: 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}
{ const merged = { ...notify, [row.key]: next }; setNotify(merged); onSavePreferences?.({ notify: merged }); }} /> {row.channels}
))}
)} {section === "pref" && (

创作默认

// 新建项目时的预填值,可在向导中改
默认模板
{TEMPLATE_CHOICES.map((choice) => (
{ setTemplate(choice.v); saveCreation({ template: choice.v }); }} >
{choice.t}
{choice.d}
))}
默认时长档
{DURATIONS.map((d) => ( { setDuration(d); saveCreation({ duration: d }); }} > {d}s ))}
// 60s = 4 段 × 15s
默认字幕样式
{SUBTITLE_CHOICES.map((choice) => (
{ setSubtitle(choice.v); saveCreation({ 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,再次使用需要重新登录。

项目、资产、团队成员与余额数据都会保留
仅影响当前浏览器会话,不会下线其他设备
); }