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:
co-authored by
Claude Opus 4.8
parent
603584b46b
commit
099bf0e6aa
@@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user