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>
This commit is contained in:
seaislee1209
2026-06-05 16:20:10 +08:00
co-authored by Claude Opus 4.8
parent 603584b46b
commit 099bf0e6aa
11 changed files with 761 additions and 305 deletions
+254 -27
View File
@@ -1,7 +1,8 @@
import { useMemo, useState } from "react";
import type { ReactNode } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent, ReactNode } from "react";
import {
Bell,
KeyRound,
LogOut,
Monitor,
ShieldCheck,
@@ -64,6 +65,46 @@ const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: s
{ 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">
@@ -73,20 +114,141 @@ function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?:
);
}
export function SettingsPage({ user, team, initialSection = "profile" }: { user: User; team: Team; initialSection?: string }) {
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">("");
const [modal, setModal] = useState<"" | "avatar" | "logout" | "password">("");
const [template, setTemplate] = useState("pain");
const [duration, setDuration] = useState("60");
const [subtitle, setSubtitle] = useState("big-variety");
const [twoFactor, setTwoFactor] = useState(false);
const [notify, setNotify] = useState<Record<string, boolean>>({ "n-export": true, "n-fail": true, "n-quota": true, "n-login": true });
// 个人信息 · 受控输入(初值取真实用户数据)
const [name, setName] = useState(user.username || "");
const [email, setEmail] = useState(user.email || "");
const [phone, setPhone] = useState("");
const [savingProfile, setSavingProfile] = useState(false);
const avatarChar = useMemo(() => (user.username || "李").slice(0, 1).toUpperCase(), [user.username]);
// 偏好 · 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">
@@ -96,8 +258,8 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="sub"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
</div>
<div className="actions">
<button className="btn" type="button" disabled>取消</button>
<button className="btn btn-primary" type="button" disabled>
<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>
@@ -150,7 +312,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="avatar-edit">
<div className="av-big">{avatarChar}</div>
<div className="av-actions">
<button className="btn btn-sm" type="button" onClick={() => setModal("avatar")}>上传新头像</button>
<button className="btn btn-sm" type="button" onClick={openAvatarModal}>上传新头像</button>
<button className="btn btn-ghost btn-sm" type="button">恢复默认</button>
</div>
</div>
@@ -158,19 +320,19 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
</div>
<div className="form-row">
<div className="lbl">显示名称<span className="req">*</span></div>
<div className="val"><input className="input" defaultValue={user.username} /></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" defaultValue={user.email || ""} />
<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" defaultValue="138****8000" />
<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>
@@ -199,7 +361,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<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 }}>修改</button>
<button className="btn btn-sm" type="button" style={{ marginLeft: 10 }} onClick={openPasswordModal}>修改</button>
</div>
</div>
<div className="form-row">
@@ -358,7 +520,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="form-row">
<div className="lbl">外观</div>
<div className="val">
<select className="select" defaultValue="system">
<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>
@@ -368,7 +530,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="form-row">
<div className="lbl">语言</div>
<div className="val">
<select className="select" defaultValue="zh">
<select className="select" value={language} onChange={(event) => setLanguage(event.target.value)}>
<option value="zh">简体中文</option>
<option value="en" disabled>English(V2)</option>
</select>
@@ -377,7 +539,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
<div className="form-row">
<div className="lbl">表格密度</div>
<div className="val">
<select className="select" defaultValue="standard">
<select className="select" value={density} onChange={(event) => setDensity(event.target.value)}>
<option value="compact">紧凑</option>
<option value="standard">标准</option>
<option value="loose">宽松</option>
@@ -391,7 +553,7 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
</main>
</div>
{/* 上传头像 modal · 仅视觉还原,无后端接入 */}
{/* 上传头像 modal · 选图 → FormData(file) → onUploadAvatar */}
<TeamModal
open={modal === "avatar"}
title="上传头像"
@@ -399,25 +561,46 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
icon={<Upload size={16} />}
close={() => setModal("")}
footer={
<button className="btn btn-primary" type="button" onClick={() => setModal("")}>
<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">{avatarChar}</div>
<div className="av-up-preview">
{avatarPreview ? <img src={avatarPreview} alt="头像预览" /> : avatarChar}
</div>
<div className="av-up-preview-meta">
<div className="t">当前头像 · 默认</div>
<div className="d">// 系统生成 · 取姓氏首字</div>
<div className="t">{avatarFile ? avatarFile.name : "当前头像 · 默认"}</div>
<div className="d">{avatarFile ? `// ${(avatarFile.size / 1024).toFixed(0)} KB · 已选择` : "// 系统生成 · 取姓氏首字"}</div>
</div>
</div>
<div className="upload-zone" role="button" tabIndex={0} aria-label="点击或拖入图片上传">
<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>
<div><strong>点击选择</strong> · 图片文件</div>
<span className="uz-hint">JPG / PNG / WebP · ≤ 2 MB · 推荐 256 × 256</span>
</div>
@@ -427,6 +610,50 @@ export function SettingsPage({ user, team, initialSection = "profile" }: { user:
</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"}