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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user