Pipeline (脚本→资产→故事板→视频→拼接): - Stage1 render real script shots + wire 确认脚本→adopt (advance stage) - Stage2 add person/scene AI-生成 buttons + clickable category tabs - Stage4 auto-poll videos to completion + per-segment upload + real frame thumbnails + download - Stage5 real timeline editor: clips undo/redo/split/copy/delete/drag-reorder/zoom, subtitle style + per-clip text editor, transition select (xfade preview), BGM upload + volume, save draft, export-with-save → shows/download final MP4 - embedded asset URLs everywhere (beat assets pagination) UX: re-entry guard in action() (no double-submit anywhere) + greyed :disabled styles for btn-aigen/chat-mode/pill-cta/tl-action so generate buttons visibly disable while generating. Also includes prior uncommitted frontend work: settings preferences/sessions/avatar, asset delete, account/team/products pages, fonts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
693 lines
31 KiB
TypeScript
693 lines
31 KiB
TypeScript
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: <UserIcon /> },
|
||
{ key: "security", label: "安全", icon: <ShieldCheck />, badge: "3 设备" },
|
||
{ key: "notify", label: "通知", icon: <Bell />, badge: "4/4" },
|
||
],
|
||
},
|
||
{
|
||
group: "偏好",
|
||
items: [
|
||
{ key: "pref", label: "创作默认", icon: <Sliders /> },
|
||
{ key: "display", label: "显示", icon: <Monitor /> },
|
||
],
|
||
},
|
||
];
|
||
|
||
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<string, boolean>,
|
||
appearance: "system",
|
||
language: "zh",
|
||
density: "standard",
|
||
};
|
||
|
||
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,
|
||
}: {
|
||
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;
|
||
}) {
|
||
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
|
||
? (initialSection as SectionKey)
|
||
: "profile";
|
||
const [section, setSection] = useState<SectionKey>(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<Record<string, boolean>>(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<UserPreference["creation_defaults"]>) {
|
||
onSavePreferences?.({ creation_defaults: { template, duration, subtitle, bgm, transition, ...patch } });
|
||
}
|
||
function saveDisplay(patch: Partial<UserPreference["display"]>) {
|
||
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<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]);
|
||
|
||
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<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]);
|
||
|
||
return (
|
||
<section className="settings-page">
|
||
<div className="page-head">
|
||
<div>
|
||
<h1>设置</h1>
|
||
<div className="sub"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
|
||
</div>
|
||
<div className="actions">
|
||
<button className="btn" type="button" onClick={resetProfile} disabled={savingProfile}>取消</button>
|
||
<button className="btn btn-primary" type="button" onClick={handleSaveProfile} disabled={savingProfile}>
|
||
<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>
|
||
</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) => (
|
||
<a
|
||
key={item.key}
|
||
href={`#sec-${item.key}`}
|
||
className={section === item.key ? "active" : ""}
|
||
role="tab"
|
||
aria-selected={section === item.key}
|
||
onClick={(event) => {
|
||
event.preventDefault();
|
||
setSection(item.key);
|
||
}}
|
||
>
|
||
{item.icon}
|
||
<span>{item.label}</span>
|
||
{item.badge ? <span className="nav-badge">{item.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">{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) => setName(event.target.value)} /></div>
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="lbl">登录邮箱</div>
|
||
<div className="val">
|
||
<input className="input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onNotify?.(email ? `已向 ${email} 发送验证邮件` : "请先填写邮箱")}>验证</button>
|
||
</div>
|
||
</div>
|
||
<div className="form-row">
|
||
<div className="lbl">手机号</div>
|
||
<div className="val">
|
||
<input className="input" value={phone} onChange={(event) => setPhone(event.target.value)} placeholder="138****8000" />
|
||
<button className="btn btn-ghost btn-sm" type="button" onClick={() => { if (phone.trim()) { onSaveProfile({ phone: phone.trim() }); onNotify?.("手机号已更新"); } else { onNotify?.("请先填写新手机号"); } }}>更换</button>
|
||
</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">管理团队 →</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={twoFactor} onChange={(v) => { setTwoFactor(v); onSavePreferences?.({ two_factor_enabled: 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={!!notify[row.key]} onChange={(next) => { const merged = { ...notify, [row.key]: next }; setNotify(merged); onSavePreferences?.({ notify: merged }); }} />
|
||
<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 ${template === choice.v ? "selected" : ""}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => { setTemplate(choice.v); saveCreation({ 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 ${duration === d ? "selected" : ""}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => { setDuration(d); saveCreation({ 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 ${subtitle === choice.v ? "selected" : ""}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => { setSubtitle(choice.v); saveCreation({ 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={bgm} onChange={(event) => { setBgm(event.target.value); saveCreation({ 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={transition} onChange={(event) => { setTransition(event.target.value); saveCreation({ 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={appearance} onChange={(event) => { setAppearance(event.target.value); saveDisplay({ 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={language} onChange={(event) => { setLanguage(event.target.value); saveDisplay({ 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={density} onChange={(event) => { setDensity(event.target.value); saveDisplay({ 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("")}>
|
||
<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>
|
||
</TeamModal>
|
||
</section>
|
||
);
|
||
}
|