App.tsx: thread saveProfile/changePassword/uploadAvatar/generateImages handlers + assets prop to pages. - settings.tsx: profile save / password modal / avatar upload wired; notification/theme prefs -> localStorage - library.tsx + product-detail: asset thumbnails + grids render real TOS preview_url - ai-tools ImageWorkbenchPage: 生成图片 wired to /api/ai/generate-image, renders returned assets - pipeline.tsx stage2-5: base_assets/storyboard/video_segments(adopted_asset)/timeline(clips/subtitles/bgm) rendered from real project data; graceful empty states - types.ts: +VideoSegment.adopted_asset, +Timeline.subtitle_tracks/bgm_tracks verified: tsc --noEmit clean; screenshots confirm pipeline stages 2-5 + product-detail render real data+images (demo asset object_keys re-pointed to image objects so thumbnails resolve) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
680 lines
28 KiB
TypeScript
680 lines
28 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 { Team, User } 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"];
|
||
|
||
const DEVICES: Array<{ name: string; meta: string; current?: boolean; phone?: boolean }> = [
|
||
{ name: "MacBook Pro · Chrome", meta: "// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42", current: true },
|
||
{ name: "iPhone 15 · Safari", meta: "// 上海 · 2026-05-20 21:43", phone: true },
|
||
{ name: "Windows · Edge", meta: "// 杭州 · 2026-05-18 09:12" },
|
||
];
|
||
|
||
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: "短信" },
|
||
];
|
||
|
||
// ─── 偏好持久化 · 后端无字段,纯本地 localStorage ───
|
||
const PREFS_KEY = "airshelf_settings_prefs";
|
||
|
||
type Prefs = {
|
||
template: string;
|
||
duration: string;
|
||
subtitle: string;
|
||
twoFactor: boolean;
|
||
notify: Record<string, boolean>;
|
||
appearance: string;
|
||
language: string;
|
||
density: string;
|
||
};
|
||
|
||
const DEFAULT_PREFS: Prefs = {
|
||
template: "pain",
|
||
duration: "60",
|
||
subtitle: "big-variety",
|
||
twoFactor: false,
|
||
notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true },
|
||
appearance: "system",
|
||
language: "zh",
|
||
density: "standard",
|
||
};
|
||
|
||
function loadPrefs(): Prefs {
|
||
try {
|
||
const raw = localStorage.getItem(PREFS_KEY);
|
||
if (!raw) return DEFAULT_PREFS;
|
||
const parsed = JSON.parse(raw) as Partial<Prefs>;
|
||
return {
|
||
...DEFAULT_PREFS,
|
||
...parsed,
|
||
notify: { ...DEFAULT_PREFS.notify, ...(parsed.notify ?? {}) },
|
||
};
|
||
} catch {
|
||
return DEFAULT_PREFS;
|
||
}
|
||
}
|
||
|
||
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",
|
||
onSaveProfile,
|
||
onChangePassword,
|
||
onUploadAvatar,
|
||
}: {
|
||
user: User;
|
||
team: Team;
|
||
initialSection?: string;
|
||
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>;
|
||
}) {
|
||
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);
|
||
|
||
// 偏好 · localStorage 持久化(读 localStorage 初始化)
|
||
const initialPrefs = useMemo(() => loadPrefs(), []);
|
||
const [template, setTemplate] = useState(initialPrefs.template);
|
||
const [duration, setDuration] = useState(initialPrefs.duration);
|
||
const [subtitle, setSubtitle] = useState(initialPrefs.subtitle);
|
||
const [twoFactor, setTwoFactor] = useState(initialPrefs.twoFactor);
|
||
const [notify, setNotify] = useState<Record<string, boolean>>(initialPrefs.notify);
|
||
const [appearance, setAppearance] = useState(initialPrefs.appearance);
|
||
const [language, setLanguage] = useState(initialPrefs.language);
|
||
const [density, setDensity] = useState(initialPrefs.density);
|
||
|
||
// 偏好改动即写回 localStorage(不调后端)
|
||
useEffect(() => {
|
||
const prefs: Prefs = { template, duration, subtitle, twoFactor, notify, appearance, language, density };
|
||
try {
|
||
localStorage.setItem(PREFS_KEY, JSON.stringify(prefs));
|
||
} catch {
|
||
/* localStorage 不可用时静默降级 */
|
||
}
|
||
}, [template, duration, subtitle, twoFactor, notify, appearance, language, density]);
|
||
|
||
// 改密 · 受控输入
|
||
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">恢复默认</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">验证</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">更换</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={setTwoFactor} />
|
||
<span className="switch-note">短信 + Authenticator</span>
|
||
</div>
|
||
</div>
|
||
|
||
<h3 className="sub-head">在用设备</h3>
|
||
<div className="pane-desc">// 不在此列表上的设备登录会触发短信告警</div>
|
||
<div className="device-list">
|
||
{DEVICES.map((device) => (
|
||
<div className="device-row" key={device.name}>
|
||
<div className="ic">
|
||
{device.phone ? (
|
||
<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">{device.name}{device.current ? <span className="tag-cur">CURRENT</span> : null}</div>
|
||
<div className="meta">{device.meta}</div>
|
||
</div>
|
||
<div className="spacer" />
|
||
{device.current
|
||
? <span className="row-note">当前会话</span>
|
||
: <button className="btn btn-ghost btn-sm" type="button">下线</button>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
<div style={{ marginTop: 14 }}>
|
||
<button className="btn" type="button">下线所有其他设备</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) => setNotify((prev) => ({ ...prev, [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 ${template === choice.v ? "selected" : ""}`}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => setTemplate(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)}
|
||
>
|
||
{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)}
|
||
>
|
||
<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" defaultValue="kapian">
|
||
<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" defaultValue="fade">
|
||
<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)}>
|
||
<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)}>
|
||
<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)}>
|
||
<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>
|
||
);
|
||
}
|