视频/工作台/商品库(ZWQ): - row19 视频阶段移除已取消的【上传视频】按钮 (pipeline.tsx) - row20 工作台统计栏(总项目/进行中/成片)点击跳转视频项目对应 tab (dashboard/projects/route-config/App) - row21 最新项目跳转改用真实 projectId, 不再跳到最近打开项目 (dashboard.tsx) - row22+PMC28 网格/列表视图模式持久化(localStorage), 跨菜单记住用户选择 (projects/ai-tools + 新 hook use-view-mode.ts) - row24 已有三视图的演员/模特直接复用, 不再强制点AI生成三视图 (pipeline.tsx) 图片生成/设置(PMC): - row27 任务中心: 6个进行中状态补全归类, 不再把生成中误判为失败栏 (stage-config.ts) - row29 头像上传500: avatar_url 存公读直链替代超长预签名URL(URLField max_length=200溢出) (accounts/views.py, 需部署) - row30 显示名保存后回显: 改读 first_name 回退 username, 修刷新仍旧名 (settings.tsx) - row31/32 隐藏【创作默认】【显示】两个设置模块 (settings.tsx) 平台套图(YYX): - row3 图片预览铺满占位 (ai-tools-page.css) 团队: - PMC25 充值/邀请成员/设置月限额三弹窗禁止点遮罩误关 (team.tsx) 含平台套图线上提示词优化(services.py/views.py/api.ts, 前序会话遗留一并提交) 构建: tsc --noEmit=0, npm run build=0 (1755模块); 后端 py_compile OK 注: row29 头像/后端项需部署生效 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
812 lines
35 KiB
TypeScript
812 lines
35 KiB
TypeScript
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: "站内 · 短信" },
|
||
{ 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",
|
||
};
|
||
|
||
// ─── 可统一保存的脏字段全集(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"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></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">{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>
|
||
<div className="val">
|
||
<input className="input" type="email" value={email} onChange={(event) => patchDraft("email", 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) => 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">管理团队 →</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>
|
||
);
|
||
}
|