seaislee1209 099bf0e6aa feat(core/frontend): wire settings/avatar/image-gen + real data render (library/product-detail/pipeline)
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>
2026-06-05 16:20:10 +08:00

680 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}