Dark模式 oss存储
Deploy dev / deploy (push) Successful in 1m16s

This commit is contained in:
Azmat@qq.com
2026-09-24 18:06:00 +08:00
parent ceb5163881
commit 5dbb240b59
46 changed files with 3491 additions and 350 deletions
+12 -3
View File
@@ -20,7 +20,7 @@ const TABS: { k: Tab; label: string; title: string; note: string }[] = [
{ k: "mine", label: "我的模特", title: "我的模特", note: "维护可复用的品牌模特与人物参考资产" }
];
const PAGE_SIZE = 20;
const PAGE_SIZE = 24;
type Preview = { src: string; kind: "image"; name: string };
@@ -294,7 +294,7 @@ function modelTags(m: ModelEntity): string[] {
}
export function ModelsPage({ onNotify, onBillingChanged }: {
onNotify?: (type: "success" | "error", text: string) => void;
onNotify?: (type: "success" | "error" | "info", text: string) => void;
onBillingChanged?: () => void;
}) {
const [tab, setTab] = useState<Tab>("all");
@@ -313,6 +313,14 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
const fileRef = useRef<HTMLInputElement | null>(null);
const tabCopy = TABS.find((item) => item.k === tab) || TABS[0];
const exitEdit = () => { setEditMode(false); setSelected(new Set()); };
const enterEdit = () => {
setEditMode(true);
// 全部列表把官方模板排在前面,第一页往往一张都删不了,底栏会一直停在 0。
if (items.length > 0 && items.every((m) => m.is_official) && tab !== "mine") {
setTab("mine");
onNotify?.("info", "官方模特不能删除,已切换到我的模特");
}
};
const toggleSelect = (id: string) => setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
@@ -415,7 +423,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
<p>维护可复用的品牌模特与人物参考资产</p>
</div>
<div className="ml-actions">
<button className={`ml-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : setEditMode(true))}>
<button className={`ml-ghost${editMode ? " is-on" : ""}`} type="button" onClick={() => (editMode ? exitEdit() : enterEdit())}>
<Settings2 />
<span>{editMode ? "完成" : "管理模特"}</span>
</button>
@@ -483,6 +491,7 @@ export function ModelsPage({ onNotify, onBillingChanged }: {
const onCardClick = () => {
if (editMode) {
if (selectable) toggleSelect(m.id);
else onNotify?.("info", "官方模特不能删除");
return;
}
setDetail(m);
+1 -1
View File
@@ -941,7 +941,7 @@ export function OmniHistoryPage({
<div className="omni-history-actions">
<button
type="button"
aria-label="删除项目"
aria-label="删除会话"
onClick={(event) => {
event.stopPropagation();
setDeleteTarget(item);
+21 -1
View File
@@ -297,7 +297,7 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
</div>
{loading && products.length === 0 ? (
<SkeletonGrid count={8} />
<ProductGridSkeleton />
) : pageItems.length === 0 ? (
<div className="pl-empty" role="status">
<PackageX />
@@ -381,6 +381,26 @@ export function ProductsPage({ products, projects = [], loading = false, navigat
);
}
function ProductGridSkeleton() {
return (
<div className="pl-grid pl-skeleton-grid" aria-busy="true" aria-label="正在加载商品">
{Array.from({ length: 6 }).map((_, index) => (
<article className="pl-card pl-skeleton-card" key={index} aria-hidden="true">
<div className="pl-skeleton-thumb skeleton-block" />
<div className="pl-meta pl-skeleton-meta">
<span className="pl-skeleton-line pl-skeleton-line--title skeleton-block" />
<span className="pl-skeleton-line pl-skeleton-line--copy skeleton-block" />
<div className="pl-skeleton-tags">
<span className="pl-skeleton-pill skeleton-block" />
<span className="pl-skeleton-pill pl-skeleton-pill--short skeleton-block" />
</div>
</div>
</article>
))}
</div>
);
}
export function ProductCard({ product, coverUrl = "", videoCount = 0, onOpen, onOpenVideos, editMode = false, selected = false, onDelete }: { product: Product; coverUrl?: string; videoCount?: number; onOpen: () => void; onOpenVideos?: () => void; editMode?: boolean; selected?: boolean; onDelete?: () => void }) {
const mock = productCover(product.title);
const assetCount = product.images?.length || 0;
+131 -97
View File
@@ -4,6 +4,7 @@ import {
Bell,
KeyRound,
LogOut,
Monitor,
Shield,
Upload,
User as UserIcon,
@@ -12,16 +13,18 @@ import type { LoginSession, Team, User, UserPreference } from "../types";
import { TeamModal } from "../components/overlays";
import { CustomSelect } from "../components/custom-select";
import { useFileDrop } from "../components/use-file-drop";
import { applyTheme, normalizeAppearance, readStoredAppearance } from "../theme";
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
// 可导航(UI 可见)的分区。pref(创作默认)/ display(显示)已隐藏入口,故不在此列。
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify"];
// 可导航分区。pref(创作默认)仍隐藏;display(显示)开放外观切换。
const SECTION_KEYS: SectionKey[] = ["profile", "security", "notify", "display"];
const NAV: Array<{ key: SectionKey; label: string; icon: ReactNode }> = [
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
{ key: "security", label: "安全", icon: <Shield /> },
{ key: "notify", label: "通知", icon: <Bell /> },
{ key: "display", label: "显示", icon: <Monitor /> },
];
function SettingRow({ title, hint, stack, children }: { title: string; hint?: string; stack?: boolean; children: ReactNode }) {
@@ -174,7 +177,7 @@ export function SettingsPage({
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;
onNotify?: (type: "success" | "error" | "info", text: string) => void;
onLogout?: () => void | Promise<void>;
}) {
const normalizedInitial = (SECTION_KEYS as readonly string[]).includes(initialSection)
@@ -199,7 +202,10 @@ export function SettingsPage({
transition: cd?.transition ?? DEFAULT_PREFS.transition,
twoFactor: !!preferences?.two_factor_enabled,
notify: { ...DEFAULT_PREFS.notify, ...(preferences?.notify || {}) },
appearance: dp?.appearance ?? DEFAULT_PREFS.appearance,
// preferences 尚未加载时用本地已选外观,避免一进设置就被默认 system 盖掉
appearance: !preferences
? readStoredAppearance()
: (dp?.appearance ?? DEFAULT_PREFS.appearance),
language: dp?.language ?? DEFAULT_PREFS.language,
density: dp?.density ?? DEFAULT_PREFS.density,
};
@@ -210,18 +216,19 @@ export function SettingsPage({
const [draft, setDraft] = useState<TrackedState>(baselineFromProps);
const [saving, setSaving] = useState(false);
// 后端 preferences / user 到达或刷新时,把新值同时灌进 baseline 与 draft(用户未改时跟随后端)
// 外观即时生效(含后端 preferences 到达 / 放弃修改回滚)
useEffect(() => {
setBaseline(baselineFromProps);
setDraft(baselineFromProps);
}, [baselineFromProps]);
applyTheme(normalizeAppearance(draft.appearance));
}, [draft.appearance]);
// 单字段 draft 更新器
const patchDraft = useCallback(<K extends keyof TrackedState>(key: K, value: TrackedState[K]) => {
setDraft((prev) => ({ ...prev, [key]: value }));
}, []);
// 已保存基线的 ref,自动保存时避免连点用到过期 baseline
const baselineRef = useRef(baseline);
baselineRef.current = baseline;
const draftRef = useRef(draft);
draftRef.current = draft;
const saveSeq = useRef(0);
// ─── 聚合脏字段集 + 涉及分区集(顶栏计数 / nav dirty-dot / beforeunload 全由此驱动) ───
// ─── 聚合脏字段集(文本框编辑中仍可能短暂脏;选项类会立即落盘) ───
const dirtyFields = useMemo<Array<keyof TrackedState>>(() => {
const keys = Object.keys(FIELD_SECTION) as Array<keyof TrackedState>;
return keys.filter((key) => {
@@ -239,6 +246,97 @@ export function SettingsPage({
const dirtyCount = dirtyFields.length;
const isDirty = dirtyCount > 0;
// 后端 preferences / user 到达或刷新时同步;保存中或本地有未落盘编辑时不覆盖 draft
useEffect(() => {
if (saving || isDirty) return;
setBaseline(baselineFromProps);
setDraft(baselineFromProps);
}, [baselineFromProps, saving, isDirty]);
// 把 next 相对 baseline 的变更拆成 profile / preferences 提交;成功后升基线
const persist = useCallback(async (next: TrackedState) => {
const base = baselineRef.current;
const profilePayload: { name?: string; email?: string; phone?: string } = {};
if (next.name !== base.name) profilePayload.name = next.name.trim();
if (next.email !== base.email) profilePayload.email = next.email.trim();
if (next.phone !== base.phone) profilePayload.phone = next.phone.trim();
const prefPayload: Partial<UserPreference> = {};
const creationKeys: Array<keyof TrackedState> = ["template", "duration", "subtitle", "bgm", "transition"];
if (creationKeys.some((key) => next[key] !== base[key])) {
prefPayload.creation_defaults = {
template: next.template,
duration: next.duration,
subtitle: next.subtitle,
bgm: next.bgm,
transition: next.transition,
};
}
const displayKeys: Array<keyof TrackedState> = ["appearance", "language", "density"];
if (displayKeys.some((key) => next[key] !== base[key])) {
prefPayload.display = { appearance: next.appearance, language: next.language, density: next.density };
}
if (!notifyEqual(next.notify, base.notify)) prefPayload.notify = { ...next.notify };
if (next.twoFactor !== base.twoFactor) prefPayload.two_factor_enabled = next.twoFactor;
const hasProfile = Object.keys(profilePayload).length > 0;
const hasPref = Object.keys(prefPayload).length > 0;
if (!hasProfile && !hasPref) {
setBaseline(next);
return;
}
const seq = ++saveSeq.current;
setSaving(true);
try {
const tasks: Array<Promise<unknown>> = [];
if (hasProfile) tasks.push(Promise.resolve(onSaveProfile(profilePayload)));
if (hasPref && onSavePreferences) {
tasks.push(
Promise.resolve(onSavePreferences(prefPayload)).then((result) => {
if (result == null) throw new Error("偏好设置保存失败,请重试");
return result;
}),
);
}
await Promise.all(tasks);
if (seq !== saveSeq.current) return;
baselineRef.current = next;
setBaseline(next);
} catch (error) {
if (seq !== saveSeq.current) return;
// 失败不推进 baseline,回滚 draft 并恢复主题
const restored = baselineRef.current;
draftRef.current = restored;
setDraft(restored);
applyTheme(normalizeAppearance(restored.appearance));
onNotify?.(
"error",
error instanceof Error ? error.message : "设置保存失败,请重试",
);
} finally {
if (seq === saveSeq.current) setSaving(false);
}
}, [onSaveProfile, onSavePreferences, onNotify]);
// 选项类:改完立即存
const commitField = useCallback(<K extends keyof TrackedState>(key: K, value: TrackedState[K]) => {
const next = { ...draftRef.current, [key]: value };
draftRef.current = next;
setDraft(next);
if (key === "appearance") applyTheme(normalizeAppearance(value as string));
void persist(next);
}, [persist]);
// 文本框:只改 draft,失焦再存
const patchDraft = useCallback(<K extends keyof TrackedState>(key: K, value: TrackedState[K]) => {
setDraft((prev) => ({ ...prev, [key]: value }));
}, []);
const saveDraftNow = useCallback(() => {
void persist(draftRef.current);
}, [persist]);
// 个人信息 · 受控输入(draft 派生)
const name = draft.name;
const email = draft.email;
@@ -271,55 +369,6 @@ export function SettingsPage({
[notifyOnCount, sessions.length],
);
// ─── 取消:draft 回退到 baseline(放弃所有未保存改动) ───
function discardChanges() {
if (!isDirty) return;
setDraft(baseline);
onNotify?.("已放弃未保存的改动");
}
// ─── 统一保存:把所有脏字段拆成 profile / preferences 两个 payload 提交,成功后把 draft 升为新基线 ───
async function handleSaveAll() {
if (!isDirty || saving) return;
setSaving(true);
try {
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 {
setSaving(false);
}
}
function openPasswordModal() {
setOldPassword("");
setNewPassword("");
@@ -381,32 +430,13 @@ 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="settings-inner">
<div className="page-head">
<div>
<h1>设置</h1>
<div className="sub">个人信息、偏好、通知与安全</div>
</div>
<div className="actions">
<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 className="sub">个人信息、偏好、通知与安全 · 修改后自动保存</div>
</div>
</div>
@@ -449,7 +479,7 @@ export function SettingsPage({
</div>
</SettingRow>
<SettingRow title="显示名称" hint="用于团队和项目协作中展示">
<input className="st-input" value={name} onChange={(event) => patchDraft("name", event.target.value)} />
<input className="st-input" value={name} onChange={(event) => patchDraft("name", event.target.value)} onBlur={saveDraftNow} />
</SettingRow>
<SettingRow title="登录邮箱" hint="仅做记录用,不用于登录">
<input
@@ -458,13 +488,15 @@ export function SettingsPage({
value={email}
placeholder="邮箱验证未启用"
onChange={(event) => patchDraft("email", event.target.value)}
onBlur={saveDraftNow}
/>
</SettingRow>
<SettingRow title="手机号" hint="保存所有更改时一并提交">
<SettingRow title="手机号" hint="修改后自动保存">
<input
className="st-input"
value={phone}
onChange={(event) => patchDraft("phone", event.target.value)}
onBlur={saveDraftNow}
placeholder="138****8000"
/>
</SettingRow>
@@ -499,7 +531,7 @@ export function SettingsPage({
</div>
</SettingRow>
<SettingRow title="两步验证" hint="推荐开启短信 + Authenticator">
<Switch checked={draft.twoFactor} onChange={(v) => patchDraft("twoFactor", v)} />
<Switch checked={draft.twoFactor} onChange={(v) => commitField("twoFactor", v)} />
</SettingRow>
<SettingRow stack title="在用设备" hint="真实登录会话 · 每次登录记录设备 UA / IP">
<div className="device-list">
@@ -541,7 +573,7 @@ export function SettingsPage({
<section className="pane" aria-label="通知">
{NOTIFY_ROWS.map((row) => (
<SettingRow key={row.key} title={row.title} hint={row.sub?.replace(/^\/\/\s*/, "")}>
<Switch checked={!!draft.notify[row.key]} onChange={(next) => patchDraft("notify", { ...draft.notify, [row.key]: next })} />
<Switch checked={!!draft.notify[row.key]} onChange={(next) => commitField("notify", { ...draft.notify, [row.key]: next })} />
</SettingRow>
))}
<div className="pane-desc" style={{ margin: "18px 0 4px" }}>消息中心分类</div>
@@ -549,7 +581,7 @@ export function SettingsPage({
<SettingRow key={row.key} title={row.title} hint={row.hint}>
<Switch
checked={!draft.notify[`mute-${row.key}`]}
onChange={(show) => patchDraft("notify", { ...draft.notify, [`mute-${row.key}`]: !show })}
onChange={(show) => commitField("notify", { ...draft.notify, [`mute-${row.key}`]: !show })}
/>
</SettingRow>
))}
@@ -571,7 +603,7 @@ export function SettingsPage({
className={`pref-choice ${draft.template === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => patchDraft("template", choice.v)}
onClick={() => commitField("template", choice.v)}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
@@ -590,7 +622,7 @@ export function SettingsPage({
className={`dur-chip ${draft.duration === d ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => patchDraft("duration", d)}
onClick={() => commitField("duration", d)}
>
{d}s
</span>
@@ -609,7 +641,7 @@ export function SettingsPage({
className={`pref-choice ${draft.subtitle === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => patchDraft("subtitle", choice.v)}
onClick={() => commitField("subtitle", choice.v)}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
@@ -621,7 +653,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl">默认 BGM 库</div>
<div className="val">
<CustomSelect fill value={draft.bgm} onChange={(next) => patchDraft("bgm", next)} options={[
<CustomSelect fill value={draft.bgm} onChange={(next) => commitField("bgm", next)} options={[
{ value: "kapian", label: "抖音 Top10 卡点曲库" },
{ value: "emotion", label: "情绪向 · 治愈/悬念" },
{ value: "urban", label: "都市电子 · 通勤场景" },
@@ -632,7 +664,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl">默认转场</div>
<div className="val">
<CustomSelect fill value={draft.transition} onChange={(next) => patchDraft("transition", next)} options={[
<CustomSelect fill value={draft.transition} onChange={(next) => commitField("transition", next)} options={[
{ value: "none", label: "无转场" },
{ value: "fade", label: "淡入淡出 · 0.3s" },
{ value: "slide", label: "滑动 · 0.3s" },
@@ -651,6 +683,8 @@ export function SettingsPage({
</section>
)}
{section === "display" && (
<section className="pane" aria-label="显示">
<h3>显示</h3>
@@ -659,17 +693,17 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl">外观</div>
<div className="val">
<CustomSelect fill value={draft.appearance} onChange={(next) => patchDraft("appearance", next)} options={[
<CustomSelect fill value={draft.appearance} onChange={(next) => commitField("appearance", next)} options={[
{ value: "system", label: "跟随系统" },
{ value: "light", label: "浅色" },
{ value: "dark", label: "深色(V2)", disabled: true },
{ value: "dark", label: "深色" },
]} />
</div>
</div>
<div className="form-row">
<div className="lbl">语言</div>
<div className="val">
<CustomSelect fill value={draft.language} onChange={(next) => patchDraft("language", next)} options={[
<CustomSelect fill value={draft.language} onChange={(next) => commitField("language", next)} options={[
{ value: "zh", label: "简体中文" },
{ value: "en", label: "English(V2)", disabled: true },
]} />
@@ -678,7 +712,7 @@ export function SettingsPage({
<div className="form-row">
<div className="lbl">表格密度</div>
<div className="val">
<CustomSelect fill value={draft.density} onChange={(next) => patchDraft("density", next)} options={[
<CustomSelect fill value={draft.density} onChange={(next) => commitField("density", next)} options={[
{ value: "compact", label: "紧凑" },
{ value: "standard", label: "标准" },
{ value: "loose", label: "宽松" },
@@ -813,7 +847,7 @@ export function SettingsPage({
<div className="li">项目、资产、团队成员与余额数据都会保留</div>
<div className="li">仅影响当前浏览器会话,不会下线其他设备</div>
</div>
{isDirty ? <div className="logout-unsaved-note">当前有 {dirtyCount} 项未保存的设置变更,退出后这些变更不会保存。</div> : null}
{isDirty ? <div className="logout-unsaved-note">还有 {dirtyCount} 项未落盘的修改(通常是正在编辑的文本),退出后不会保存。</div> : null}
</TeamModal>
</section>
);