feat(core): Wave 3 batch4 — 外壳账户菜单 + 设置dirty引擎 + 商品创建抽屉多图
全局外壳(sub-agent):侧栏 .user 点击弹账户菜单(头像+店名+用户名[非邮箱,认证定调]+
个人设置/消息/团队/消费/退出),复用现成 .shell-account-menu CSS,点外部/ESC 收起
设置页(sub-agent):dirty-state 引擎(baseline+draft diff 13字段,顶栏「保存所有变更」
无改动 disabled、改动激活+N项计数、统一保存、beforeunload、nav dirty-dot);修一处类型错(|| {} → 可选链)
商品创建抽屉(sub-agent):主图多图≤5+.pf-grid缩略网格+单张删、submit主图/卖点必填校验+收编bulletDraft、
上传区拖拽变橙、卖点字重对齐;内联 style 抽进 css
验证: tsc 0 · build 0 · 走查 账户菜单5项+ESC、设置保存按钮无改动disabled、抽屉multiple+pf-grid 全过 · 0 console error
⚠️ 外壳:顶栏头像菜单接线需改 App.tsx/pipeline.tsx(超出单文件范围),侧栏入口已全可用。移交监督。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
da8c6a70a7
commit
378b0a350c
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChangeEvent, ReactNode } from "react";
|
||||
import {
|
||||
Bell,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Monitor,
|
||||
ShieldCheck,
|
||||
Sliders,
|
||||
Smartphone,
|
||||
Upload,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
@@ -16,13 +15,15 @@ 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 }> }> = [
|
||||
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify", "pref", "display"];
|
||||
|
||||
const NAV: Array<{ group: string; items: Array<{ key: SectionKey; label: string; icon: ReactNode }> }> = [
|
||||
{
|
||||
group: "个人",
|
||||
items: [
|
||||
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
||||
{ key: "security", label: "安全", icon: <ShieldCheck />, badge: "3 设备" },
|
||||
{ key: "notify", label: "通知", icon: <Bell />, badge: "4/4" },
|
||||
{ key: "security", label: "安全", icon: <ShieldCheck /> },
|
||||
{ key: "notify", label: "通知", icon: <Bell /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -81,6 +82,50 @@ const DEFAULT_PREFS = {
|
||||
density: "standard",
|
||||
};
|
||||
|
||||
// ─── 可统一保存的脏字段全集(draft 与 baseline 逐字段 diff,聚合驱动顶栏/nav/beforeunload) ───
|
||||
// 不含:头像(FormData 文件操作)/ 改密 / 会话下线 —— 这些是即时副作用,各自有独立确认,不计入脏字段。
|
||||
type TrackedState = {
|
||||
// profile · 个人信息
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
// pref · 创作默认
|
||||
template: string;
|
||||
duration: string;
|
||||
subtitle: string;
|
||||
bgm: string;
|
||||
transition: string;
|
||||
// security · 安全(两步验证)
|
||||
twoFactor: boolean;
|
||||
// notify · 通知开关
|
||||
notify: Record<string, boolean>;
|
||||
// display · 显示
|
||||
appearance: string;
|
||||
language: string;
|
||||
density: string;
|
||||
};
|
||||
|
||||
// 脏字段 → 所属 section(顶栏计数 + nav dirty-dot 用)
|
||||
const FIELD_SECTION: Record<keyof TrackedState, SectionKey> = {
|
||||
name: "profile",
|
||||
email: "profile",
|
||||
phone: "profile",
|
||||
template: "pref",
|
||||
duration: "pref",
|
||||
subtitle: "pref",
|
||||
bgm: "pref",
|
||||
transition: "pref",
|
||||
twoFactor: "security",
|
||||
notify: "notify",
|
||||
appearance: "display",
|
||||
language: "display",
|
||||
density: "display",
|
||||
};
|
||||
|
||||
function notifyEqual(a: Record<string, boolean>, b: Record<string, boolean>): boolean {
|
||||
return NOTIFY_ROWS.every((row) => !!a[row.key] === !!b[row.key]);
|
||||
}
|
||||
|
||||
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
|
||||
return (
|
||||
<label className="switch">
|
||||
@@ -121,54 +166,71 @@ export function SettingsPage({
|
||||
onNotify?: (text: string) => void;
|
||||
onLogout?: () => void | Promise<void>;
|
||||
}) {
|
||||
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
|
||||
const normalizedInitial = (SECTION_KEYS as readonly string[]).includes(initialSection)
|
||||
? (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);
|
||||
// ─── 统一保存:已保存基线(baseline)从真实用户/后端 preferences 注入 ───
|
||||
const baselineFromProps = useMemo<TrackedState>(() => {
|
||||
const cd = preferences?.creation_defaults;
|
||||
const dp = preferences?.display;
|
||||
return {
|
||||
name: user.username || "",
|
||||
email: user.email || "",
|
||||
phone: "",
|
||||
template: cd?.template ?? DEFAULT_PREFS.template,
|
||||
duration: cd?.duration ?? DEFAULT_PREFS.duration,
|
||||
subtitle: cd?.subtitle ?? DEFAULT_PREFS.subtitle,
|
||||
bgm: cd?.bgm ?? DEFAULT_PREFS.bgm,
|
||||
transition: cd?.transition ?? DEFAULT_PREFS.transition,
|
||||
twoFactor: !!preferences?.two_factor_enabled,
|
||||
notify: { ...DEFAULT_PREFS.notify, ...(preferences?.notify || {}) },
|
||||
appearance: dp?.appearance ?? DEFAULT_PREFS.appearance,
|
||||
language: dp?.language ?? DEFAULT_PREFS.language,
|
||||
density: dp?.density ?? DEFAULT_PREFS.density,
|
||||
};
|
||||
}, [user.username, user.email, preferences]);
|
||||
|
||||
// 偏好 · 服务端持久化(从后端 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);
|
||||
// baseline = 已保存值;draft = 当前编辑值。diff(draft, baseline) = 脏字段。
|
||||
const [baseline, setBaseline] = useState<TrackedState>(baselineFromProps);
|
||||
const [draft, setDraft] = useState<TrackedState>(baselineFromProps);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 后端 preferences 到达时注入(覆盖默认值,缺字段回退默认)
|
||||
// 后端 preferences / user 到达或刷新时,把新值同时灌进 baseline 与 draft(用户未改时跟随后端)
|
||||
useEffect(() => {
|
||||
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]);
|
||||
setBaseline(baselineFromProps);
|
||||
setDraft(baselineFromProps);
|
||||
}, [baselineFromProps]);
|
||||
|
||||
// 当前 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 } });
|
||||
}
|
||||
// 单字段 draft 更新器
|
||||
const patchDraft = useCallback(<K extends keyof TrackedState>(key: K, value: TrackedState[K]) => {
|
||||
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// ─── 聚合脏字段集 + 涉及分区集(顶栏计数 / nav dirty-dot / beforeunload 全由此驱动) ───
|
||||
const dirtyFields = useMemo<Array<keyof TrackedState>>(() => {
|
||||
const keys = Object.keys(FIELD_SECTION) as Array<keyof TrackedState>;
|
||||
return keys.filter((key) => {
|
||||
if (key === "notify") return !notifyEqual(draft.notify, baseline.notify);
|
||||
return draft[key] !== baseline[key];
|
||||
});
|
||||
}, [draft, baseline]);
|
||||
|
||||
const dirtySections = useMemo<Set<SectionKey>>(() => {
|
||||
const set = new Set<SectionKey>();
|
||||
dirtyFields.forEach((key) => set.add(FIELD_SECTION[key]));
|
||||
return set;
|
||||
}, [dirtyFields]);
|
||||
|
||||
const dirtyCount = dirtyFields.length;
|
||||
const isDirty = dirtyCount > 0;
|
||||
|
||||
// 个人信息 · 受控输入(draft 派生)
|
||||
const name = draft.name;
|
||||
const email = draft.email;
|
||||
const phone = draft.phone;
|
||||
|
||||
// 改密 · 受控输入
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
@@ -186,20 +248,63 @@ export function SettingsPage({
|
||||
|
||||
const avatarChar = useMemo(() => (name || user.username || "李").slice(0, 1).toUpperCase(), [name, user.username]);
|
||||
|
||||
function resetProfile() {
|
||||
setName(user.username || "");
|
||||
setEmail(user.email || "");
|
||||
setPhone("");
|
||||
onNotify?.("已恢复为已保存的资料");
|
||||
// ─── nav badge · 由 state 计算(通知开启数 / 设备数),不写死 ───
|
||||
const notifyOnCount = useMemo(() => NOTIFY_ROWS.filter((row) => !!draft.notify[row.key]).length, [draft.notify]);
|
||||
const navBadge = useCallback(
|
||||
(key: SectionKey): string | null => {
|
||||
if (key === "notify") return `${notifyOnCount}/${NOTIFY_ROWS.length}`;
|
||||
if (key === "security") return `${sessions.length} 设备`;
|
||||
return null;
|
||||
},
|
||||
[notifyOnCount, sessions.length],
|
||||
);
|
||||
|
||||
// ─── 取消:draft 回退到 baseline(放弃所有未保存改动) ───
|
||||
function discardChanges() {
|
||||
if (!isDirty) return;
|
||||
setDraft(baseline);
|
||||
onNotify?.("已放弃未保存的改动");
|
||||
}
|
||||
|
||||
async function handleSaveProfile() {
|
||||
if (savingProfile) return;
|
||||
setSavingProfile(true);
|
||||
// ─── 统一保存:把所有脏字段拆成 profile / preferences 两个 payload 提交,成功后把 draft 升为新基线 ───
|
||||
async function handleSaveAll() {
|
||||
if (!isDirty || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSaveProfile({ name: name.trim(), email: email.trim(), phone: phone.trim() });
|
||||
const profilePayload: { name?: string; email?: string; phone?: string } = {};
|
||||
if (draft.name !== baseline.name) profilePayload.name = draft.name.trim();
|
||||
if (draft.email !== baseline.email) profilePayload.email = draft.email.trim();
|
||||
if (draft.phone !== baseline.phone) profilePayload.phone = draft.phone.trim();
|
||||
|
||||
const prefPayload: Partial<UserPreference> = {};
|
||||
const creationKeys: Array<keyof TrackedState> = ["template", "duration", "subtitle", "bgm", "transition"];
|
||||
if (creationKeys.some((key) => draft[key] !== baseline[key])) {
|
||||
prefPayload.creation_defaults = {
|
||||
template: draft.template,
|
||||
duration: draft.duration,
|
||||
subtitle: draft.subtitle,
|
||||
bgm: draft.bgm,
|
||||
transition: draft.transition,
|
||||
};
|
||||
}
|
||||
const displayKeys: Array<keyof TrackedState> = ["appearance", "language", "density"];
|
||||
if (displayKeys.some((key) => draft[key] !== baseline[key])) {
|
||||
prefPayload.display = { appearance: draft.appearance, language: draft.language, density: draft.density };
|
||||
}
|
||||
if (!notifyEqual(draft.notify, baseline.notify)) prefPayload.notify = { ...draft.notify };
|
||||
if (draft.twoFactor !== baseline.twoFactor) prefPayload.two_factor_enabled = draft.twoFactor;
|
||||
|
||||
const tasks: Array<Promise<unknown>> = [];
|
||||
if (Object.keys(profilePayload).length > 0) tasks.push(Promise.resolve(onSaveProfile(profilePayload)));
|
||||
if (Object.keys(prefPayload).length > 0 && onSavePreferences) tasks.push(Promise.resolve(onSavePreferences(prefPayload)));
|
||||
await Promise.all(tasks);
|
||||
|
||||
// 提交成功 → draft 升为新基线,脏集清空
|
||||
const savedCount = dirtyCount;
|
||||
setBaseline(draft);
|
||||
onNotify?.(`${savedCount} 项已保存`);
|
||||
} finally {
|
||||
setSavingProfile(false);
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,6 +364,17 @@ export function SettingsPage({
|
||||
return () => URL.revokeObjectURL(avatarPreview);
|
||||
}, [avatarPreview]);
|
||||
|
||||
// ─── 离开页面前提醒:有未保存改动时弹浏览器原生确认 ───
|
||||
useEffect(() => {
|
||||
if (!isDirty) return;
|
||||
const handler = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [isDirty]);
|
||||
|
||||
return (
|
||||
<section className="settings-page">
|
||||
<div className="page-head">
|
||||
@@ -267,10 +383,11 @@ export function SettingsPage({
|
||||
<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}>
|
||||
<button className="btn" type="button" onClick={discardChanges} disabled={!isDirty || saving}>取消</button>
|
||||
<button className="btn btn-primary" type="button" onClick={handleSaveAll} disabled={!isDirty || saving}>
|
||||
<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>
|
||||
保存所有变更
|
||||
{isDirty ? <span className="save-count">· {dirtyCount} 项</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,24 +398,28 @@ export function SettingsPage({
|
||||
{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>
|
||||
))}
|
||||
{group.items.map((item) => {
|
||||
const badge = navBadge(item.key);
|
||||
const dirty = dirtySections.has(item.key);
|
||||
return (
|
||||
<a
|
||||
key={item.key}
|
||||
href={`#sec-${item.key}`}
|
||||
className={`${section === item.key ? "active" : ""}${dirty ? " has-changes" : ""}`}
|
||||
role="tab"
|
||||
aria-selected={section === item.key}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setSection(item.key);
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
{badge ? <span className="nav-badge">{badge}</span> : null}
|
||||
<span className="nav-dot" aria-hidden="true" />
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div className="nav-h" style={{ marginTop: 16 }}>账号</div>
|
||||
@@ -329,20 +450,20 @@ export function SettingsPage({
|
||||
</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 className="val"><input className="input" value={name} onChange={(event) => patchDraft("name", 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)} />
|
||||
<input className="input" type="email" value={email} onChange={(event) => patchDraft("email", event.target.value)} />
|
||||
<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" onClick={() => { if (phone.trim()) { onSaveProfile({ phone: phone.trim() }); onNotify?.("手机号已更新"); } else { onNotify?.("请先填写新手机号"); } }}>更换</button>
|
||||
<input className="input" value={phone} onChange={(event) => patchDraft("phone", event.target.value)} placeholder="138****8000" />
|
||||
<span className="switch-note">// 在「保存所有变更」中一并提交</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
@@ -376,7 +497,7 @@ export function SettingsPage({
|
||||
<div className="form-row">
|
||||
<div className="lbl">两步验证<div className="lbl-sub">// 推荐开启</div></div>
|
||||
<div className="val">
|
||||
<Switch checked={twoFactor} onChange={(v) => { setTwoFactor(v); onSavePreferences?.({ two_factor_enabled: v }); }} />
|
||||
<Switch checked={draft.twoFactor} onChange={(v) => patchDraft("twoFactor", v)} />
|
||||
<span className="switch-note">短信 + Authenticator</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -425,7 +546,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) => { const merged = { ...notify, [row.key]: next }; setNotify(merged); onSavePreferences?.({ notify: merged }); }} />
|
||||
<Switch checked={!!draft.notify[row.key]} onChange={(next) => patchDraft("notify", { ...draft.notify, [row.key]: next })} />
|
||||
<span className="switch-note">{row.channels}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,10 +566,10 @@ export function SettingsPage({
|
||||
{TEMPLATE_CHOICES.map((choice) => (
|
||||
<div
|
||||
key={choice.v}
|
||||
className={`pref-choice ${template === choice.v ? "selected" : ""}`}
|
||||
className={`pref-choice ${draft.template === choice.v ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => { setTemplate(choice.v); saveCreation({ template: choice.v }); }}
|
||||
onClick={() => patchDraft("template", choice.v)}
|
||||
>
|
||||
<div className="t">{choice.t}</div>
|
||||
<div className="d">{choice.d}</div>
|
||||
@@ -464,10 +585,10 @@ export function SettingsPage({
|
||||
{DURATIONS.map((d) => (
|
||||
<span
|
||||
key={d}
|
||||
className={`dur-chip ${duration === d ? "selected" : ""}`}
|
||||
className={`dur-chip ${draft.duration === d ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => { setDuration(d); saveCreation({ duration: d }); }}
|
||||
onClick={() => patchDraft("duration", d)}
|
||||
>
|
||||
{d}s
|
||||
</span>
|
||||
@@ -483,10 +604,10 @@ export function SettingsPage({
|
||||
{SUBTITLE_CHOICES.map((choice) => (
|
||||
<div
|
||||
key={choice.v}
|
||||
className={`pref-choice ${subtitle === choice.v ? "selected" : ""}`}
|
||||
className={`pref-choice ${draft.subtitle === choice.v ? "selected" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => { setSubtitle(choice.v); saveCreation({ subtitle: choice.v }); }}
|
||||
onClick={() => patchDraft("subtitle", choice.v)}
|
||||
>
|
||||
<div className="t">{choice.t}</div>
|
||||
<div className="d">{choice.d}</div>
|
||||
@@ -498,7 +619,7 @@ export function SettingsPage({
|
||||
<div className="form-row">
|
||||
<div className="lbl">默认 BGM 库</div>
|
||||
<div className="val">
|
||||
<select className="select" value={bgm} onChange={(event) => { setBgm(event.target.value); saveCreation({ bgm: event.target.value }); }}>
|
||||
<select className="select" value={draft.bgm} onChange={(event) => patchDraft("bgm", event.target.value)}>
|
||||
<option value="kapian">抖音 Top10 卡点曲库</option>
|
||||
<option value="emotion">情绪向 · 治愈/悬念</option>
|
||||
<option value="urban">都市电子 · 通勤场景</option>
|
||||
@@ -509,7 +630,7 @@ export function SettingsPage({
|
||||
<div className="form-row">
|
||||
<div className="lbl">默认转场</div>
|
||||
<div className="val">
|
||||
<select className="select" value={transition} onChange={(event) => { setTransition(event.target.value); saveCreation({ transition: event.target.value }); }}>
|
||||
<select className="select" value={draft.transition} onChange={(event) => patchDraft("transition", event.target.value)}>
|
||||
<option value="none">无转场</option>
|
||||
<option value="fade">淡入淡出 · 0.3s</option>
|
||||
<option value="slide">滑动 · 0.3s</option>
|
||||
@@ -536,7 +657,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); saveDisplay({ appearance: event.target.value }); }}>
|
||||
<select className="select" value={draft.appearance} onChange={(event) => patchDraft("appearance", event.target.value)}>
|
||||
<option value="system">跟随系统</option>
|
||||
<option value="light">浅色</option>
|
||||
<option value="dark" disabled>深色(V2)</option>
|
||||
@@ -546,7 +667,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); saveDisplay({ language: event.target.value }); }}>
|
||||
<select className="select" value={draft.language} onChange={(event) => patchDraft("language", event.target.value)}>
|
||||
<option value="zh">简体中文</option>
|
||||
<option value="en" disabled>English(V2)</option>
|
||||
</select>
|
||||
@@ -555,7 +676,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); saveDisplay({ density: event.target.value }); }}>
|
||||
<select className="select" value={draft.density} onChange={(event) => patchDraft("density", event.target.value)}>
|
||||
<option value="compact">紧凑</option>
|
||||
<option value="standard">标准</option>
|
||||
<option value="loose">宽松</option>
|
||||
@@ -689,6 +810,7 @@ export function SettingsPage({
|
||||
<div className="li">项目、资产、团队成员与余额数据都会保留</div>
|
||||
<div className="li">仅影响当前浏览器会话,不会下线其他设备</div>
|
||||
</div>
|
||||
{isDirty ? <div className="logout-unsaved-note">当前有 {dirtyCount} 项未保存的设置变更,退出后这些变更不会保存。</div> : null}
|
||||
</TeamModal>
|
||||
</section>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user