feat(core/frontend): pipeline stage editor (burn-in controls) + double-submit guard & button greying

Pipeline (脚本→资产→故事板→视频→拼接):
- Stage1 render real script shots + wire 确认脚本→adopt (advance stage)
- Stage2 add person/scene AI-生成 buttons + clickable category tabs
- Stage4 auto-poll videos to completion + per-segment upload + real frame thumbnails + download
- Stage5 real timeline editor: clips undo/redo/split/copy/delete/drag-reorder/zoom,
  subtitle style + per-clip text editor, transition select (xfade preview),
  BGM upload + volume, save draft, export-with-save → shows/download final MP4
- embedded asset URLs everywhere (beat assets pagination)

UX: re-entry guard in action() (no double-submit anywhere) + greyed :disabled
styles for btn-aigen/chat-mode/pill-cta/tl-action so generate buttons visibly
disable while generating.

Also includes prior uncommitted frontend work: settings preferences/sessions/avatar,
asset delete, account/team/products pages, fonts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-09 14:46:42 +08:00
co-authored by Claude Opus 4.8
parent 92826dec14
commit 0873e724bf
29 changed files with 2393 additions and 423 deletions
+102 -89
View File
@@ -11,7 +11,7 @@ import {
Upload,
User as UserIcon,
} from "lucide-react";
import type { Team, User } from "../types";
import type { LoginSession, Team, User, UserPreference } from "../types";
import { TeamModal } from "../components/overlays";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
@@ -52,11 +52,13 @@ const SUBTITLE_CHOICES = [
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" },
];
// 从 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: "站内 · 邮件 · 短信" },
@@ -65,46 +67,20 @@ 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 = {
// ─── 偏好默认值 · 与后端 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 },
notify: { "n-export": true, "n-fail": true, "n-quota": true, "n-login": true } as Record<string, boolean>,
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">
@@ -118,16 +94,30 @@ export function SettingsPage({
user,
team,
initialSection = "profile",
preferences,
sessions = [],
onSavePreferences,
onRevokeSession,
onRevokeOthers,
onSaveProfile,
onChangePassword,
onUploadAvatar,
onResetAvatar,
onNotify,
}: {
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;
}) {
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
? (initialSection as SectionKey)
@@ -141,26 +131,42 @@ export function SettingsPage({
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);
// 偏好 · 服务端持久化(从后端 preferences 注入初值,改动即 PUT 回后端)
const [template, setTemplate] = useState(DEFAULT_PREFS.template);
const [duration, setDuration] = useState(DEFAULT_PREFS.duration);
const [subtitle, setSubtitle] = useState(DEFAULT_PREFS.subtitle);
const [bgm, setBgm] = useState(DEFAULT_PREFS.bgm);
const [transition, setTransition] = useState(DEFAULT_PREFS.transition);
const [twoFactor, setTwoFactor] = useState(DEFAULT_PREFS.twoFactor);
const [notify, setNotify] = useState<Record<string, boolean>>(DEFAULT_PREFS.notify);
const [appearance, setAppearance] = useState(DEFAULT_PREFS.appearance);
const [language, setLanguage] = useState(DEFAULT_PREFS.language);
const [density, setDensity] = useState(DEFAULT_PREFS.density);
// 偏好改动即写回 localStorage(不调后端)
// 后端 preferences 到达时注入(覆盖默认值,缺字段回退默认)
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]);
if (!preferences) return;
const cd = preferences.creation_defaults || {};
setTemplate(cd.template ?? DEFAULT_PREFS.template);
setDuration(cd.duration ?? DEFAULT_PREFS.duration);
setSubtitle(cd.subtitle ?? DEFAULT_PREFS.subtitle);
setBgm(cd.bgm ?? DEFAULT_PREFS.bgm);
setTransition(cd.transition ?? DEFAULT_PREFS.transition);
setTwoFactor(!!preferences.two_factor_enabled);
setNotify({ ...DEFAULT_PREFS.notify, ...(preferences.notify || {}) });
const dp = preferences.display || {};
setAppearance(dp.appearance ?? DEFAULT_PREFS.appearance);
setLanguage(dp.language ?? DEFAULT_PREFS.language);
setDensity(dp.density ?? DEFAULT_PREFS.density);
}, [preferences]);
// 当前 creation_defaults / display 快照(配合 [key]:value 即时持久化单字段)
function saveCreation(patch: Partial<UserPreference["creation_defaults"]>) {
onSavePreferences?.({ creation_defaults: { template, duration, subtitle, bgm, transition, ...patch } });
}
function saveDisplay(patch: Partial<UserPreference["display"]>) {
onSavePreferences?.({ display: { appearance, language, density, ...patch } });
}
// 改密 · 受控输入
const [oldPassword, setOldPassword] = useState("");
@@ -313,7 +319,7 @@ export function SettingsPage({
<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>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onResetAvatar?.()}>恢复默认</button>
</div>
</div>
</div>
@@ -326,14 +332,14 @@ export function SettingsPage({
<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>
<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) => setPhone(event.target.value)} placeholder="138****8000" />
<button className="btn btn-ghost btn-sm" type="button">更换</button>
<button className="btn btn-ghost btn-sm" type="button" onClick={() => { if (phone.trim()) { onSaveProfile({ phone: phone.trim() }); onNotify?.("手机号已更新"); } else { onNotify?.("请先填写新手机号"); } }}>更换</button>
</div>
</div>
<div className="form-row">
@@ -367,36 +373,43 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl">两步验证<div className="lbl-sub">// 推荐开启</div></div>
<div className="val">
<Switch checked={twoFactor} onChange={setTwoFactor} />
<Switch checked={twoFactor} onChange={(v) => { setTwoFactor(v); onSavePreferences?.({ two_factor_enabled: v }); }} />
<span className="switch-note">短信 + Authenticator</span>
</div>
</div>
<h3 className="sub-head">在用设备</h3>
<div className="pane-desc">// 不在此列表上的设备登录会触发短信告警</div>
<div className="pane-desc">// 真实登录会话 · 每次登录记录设备 UA / IP</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>
))}
{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">下线所有其他设备</button>
<button className="btn" type="button" onClick={() => onRevokeOthers?.()} disabled={sessions.length <= 1}>下线所有其他设备</button>
</div>
</section>
)}
@@ -409,7 +422,7 @@ export function SettingsPage({
<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 }))} />
<Switch checked={!!notify[row.key]} onChange={(next) => { const merged = { ...notify, [row.key]: next }; setNotify(merged); onSavePreferences?.({ notify: merged }); }} />
<span className="switch-note">{row.channels}</span>
</div>
</div>
@@ -432,7 +445,7 @@ export function SettingsPage({
className={`pref-choice ${template === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setTemplate(choice.v)}
onClick={() => { setTemplate(choice.v); saveCreation({ template: choice.v }); }}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
@@ -451,7 +464,7 @@ export function SettingsPage({
className={`dur-chip ${duration === d ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setDuration(d)}
onClick={() => { setDuration(d); saveCreation({ duration: d }); }}
>
{d}s
</span>
@@ -470,7 +483,7 @@ export function SettingsPage({
className={`pref-choice ${subtitle === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setSubtitle(choice.v)}
onClick={() => { setSubtitle(choice.v); saveCreation({ subtitle: choice.v }); }}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
@@ -482,7 +495,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl">默认 BGM 库</div>
<div className="val">
<select className="select" defaultValue="kapian">
<select className="select" value={bgm} onChange={(event) => { setBgm(event.target.value); saveCreation({ bgm: event.target.value }); }}>
<option value="kapian">抖音 Top10 卡点曲库</option>
<option value="emotion">情绪向 · 治愈/悬念</option>
<option value="urban">都市电子 · 通勤场景</option>
@@ -493,7 +506,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl">默认转场</div>
<div className="val">
<select className="select" defaultValue="fade">
<select className="select" value={transition} onChange={(event) => { setTransition(event.target.value); saveCreation({ transition: event.target.value }); }}>
<option value="none">无转场</option>
<option value="fade">淡入淡出 · 0.3s</option>
<option value="slide">滑动 · 0.3s</option>
@@ -520,7 +533,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl">外观</div>
<div className="val">
<select className="select" value={appearance} onChange={(event) => setAppearance(event.target.value)}>
<select className="select" value={appearance} onChange={(event) => { setAppearance(event.target.value); saveDisplay({ appearance: event.target.value }); }}>
<option value="system">跟随系统</option>
<option value="light">浅色</option>
<option value="dark" disabled>深色(V2)</option>
@@ -530,7 +543,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl">语言</div>
<div className="val">
<select className="select" value={language} onChange={(event) => setLanguage(event.target.value)}>
<select className="select" value={language} onChange={(event) => { setLanguage(event.target.value); saveDisplay({ language: event.target.value }); }}>
<option value="zh">简体中文</option>
<option value="en" disabled>English(V2)</option>
</select>
@@ -539,7 +552,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl">表格密度</div>
<div className="val">
<select className="select" value={density} onChange={(event) => setDensity(event.target.value)}>
<select className="select" value={density} onChange={(event) => { setDensity(event.target.value); saveDisplay({ density: event.target.value }); }}>
<option value="compact">紧凑</option>
<option value="standard">标准</option>
<option value="loose">宽松</option>