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,8 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { AlertCircle, Check, Info } from "lucide-react";
|
import { AlertCircle, Check, Info, LogOut } from "lucide-react";
|
||||||
import { IconKitSvg } from "./IconKitSvg";
|
import { IconKitSvg } from "./IconKitSvg";
|
||||||
import { useBodyScrollLock } from "./overlays";
|
import { useBodyScrollLock } from "./overlays";
|
||||||
|
import { api, setToken } from "../api";
|
||||||
import type { Product, Project, Team, User } from "../types";
|
import type { Product, Project, Team, User } from "../types";
|
||||||
import type { Notice, Page } from "../routes/route-config";
|
import type { Notice, Page } from "../routes/route-config";
|
||||||
|
|
||||||
@@ -96,6 +98,93 @@ function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: (
|
|||||||
|
|
||||||
type Navigate = (page: Page) => void;
|
type Navigate = (page: Page) => void;
|
||||||
|
|
||||||
|
// 账户下拉菜单 —— 忠实搬设计稿 shell.js 的 accountMenuHtml + toggleAccountMenu 定位逻辑。
|
||||||
|
// 触发器(侧栏 .user / 顶栏头像)传入自己的 DOMRect 当锚点;菜单 fixed 定位、右对齐锚点、
|
||||||
|
// 越界自动翻到锚点上方,并钳进视口。点外部 / Esc 收起。各项 navigate,退出走 logout。
|
||||||
|
// 认证定调:头像下方第二行展示「用户名」(user.username),本项目不用邮箱。
|
||||||
|
const ACCOUNT_ITEMS: { act: Page; icon: string; label: string }[] = [
|
||||||
|
{ act: "settings", icon: "settings", label: "个人设置" },
|
||||||
|
{ act: "messages", icon: "bell", label: "消息中心" },
|
||||||
|
{ act: "team", icon: "users", label: "团队管理" },
|
||||||
|
{ act: "account", icon: "creditCard", label: "消费与余额" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function AccountMenu({ anchorRect, onClose, navigate, logout, user, team }: {
|
||||||
|
anchorRect: DOMRect;
|
||||||
|
onClose: () => void;
|
||||||
|
navigate: Navigate;
|
||||||
|
logout: () => void;
|
||||||
|
user: User;
|
||||||
|
team: Team | null;
|
||||||
|
}) {
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [pos, setPos] = useState<{ left: number; top: number }>({ left: anchorRect.left, top: anchorRect.bottom + 8 });
|
||||||
|
|
||||||
|
// 量出真实菜单尺寸后再定位(右对齐锚点、上下翻转防溢出),与 V1 toggleAccountMenu 一致
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const menu = menuRef.current;
|
||||||
|
const width = menu?.offsetWidth || 232;
|
||||||
|
const height = menu?.offsetHeight || 260;
|
||||||
|
let left = anchorRect.right - width;
|
||||||
|
if (left < 12) left = anchorRect.left;
|
||||||
|
left = Math.min(Math.max(12, left), window.innerWidth - width - 12);
|
||||||
|
let top = anchorRect.bottom + 8;
|
||||||
|
if (top + height > window.innerHeight - 12) top = Math.max(12, anchorRect.top - height - 8);
|
||||||
|
setPos({ left, top });
|
||||||
|
}, [anchorRect]);
|
||||||
|
|
||||||
|
// 点菜单外部 / Esc → 收起(锚点本身由触发器的 toggle 处理,避免点锚点立刻又被关掉)
|
||||||
|
useEffect(() => {
|
||||||
|
const onDown = (event: MouseEvent) => {
|
||||||
|
const target = event.target as Node;
|
||||||
|
if (menuRef.current?.contains(target)) return;
|
||||||
|
if ((target as Element).closest?.(".user, .topbar-avatar")) return;
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); };
|
||||||
|
document.addEventListener("mousedown", onDown);
|
||||||
|
document.addEventListener("keydown", onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", onDown);
|
||||||
|
document.removeEventListener("keydown", onKey);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const avatar = (team?.name || user.username || "A").slice(0, 1).toUpperCase();
|
||||||
|
const go = (page: Page) => { onClose(); navigate(page); };
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className="shell-account-menu show"
|
||||||
|
id="shell-account-menu"
|
||||||
|
role="menu"
|
||||||
|
aria-label="账户菜单"
|
||||||
|
style={{ left: pos.left, top: pos.top }}
|
||||||
|
>
|
||||||
|
<div className="shell-account-head">
|
||||||
|
<span className="av">{avatar}</span>
|
||||||
|
<span>
|
||||||
|
<span className="nm">{team?.name || user.username}</span>
|
||||||
|
<span className="mail">{user.username}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{ACCOUNT_ITEMS.map((item) => (
|
||||||
|
<button key={item.act} type="button" role="menuitem" onClick={() => go(item.act)}>
|
||||||
|
<IconKitSvg name={item.icon} />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="sep" />
|
||||||
|
<button type="button" role="menuitem" onClick={() => { onClose(); logout(); }}>
|
||||||
|
<LogOut size={14} />
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
|
type NavDef = { id: string; page: Page; label: string; icon: string; badge?: number };
|
||||||
|
|
||||||
const NAV: NavDef[] = [
|
const NAV: NavDef[] = [
|
||||||
@@ -130,7 +219,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
|
|||||||
settingsNotify: "settings"
|
settingsNotify: "settings"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal }: {
|
export function Sidebar({ page, navigate, user, team, products, projects, productTotal, projectTotal, logout }: {
|
||||||
page: Page;
|
page: Page;
|
||||||
navigate: Navigate;
|
navigate: Navigate;
|
||||||
user: User;
|
user: User;
|
||||||
@@ -139,6 +228,9 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
|||||||
projects: Project[];
|
projects: Project[];
|
||||||
productTotal?: number;
|
productTotal?: number;
|
||||||
projectTotal?: number;
|
projectTotal?: number;
|
||||||
|
// 退出登录。父级(App.tsx)有完整 logout 闭包时传入;未传时用自带兜底
|
||||||
|
// (best-effort 调登出接口 + 清 token + 跳 /login),保证侧栏账户菜单的"退出"今天就可用。
|
||||||
|
logout?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const activeNav = PAGE_TO_NAV[page];
|
const activeNav = PAGE_TO_NAV[page];
|
||||||
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
|
// 徽标用后端真实总数(分页 count),不用已加载的页内条数
|
||||||
@@ -164,6 +256,20 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
|||||||
return () => document.removeEventListener("keydown", onKey);
|
return () => document.removeEventListener("keydown", onKey);
|
||||||
}, [mobileNavOpen]);
|
}, [mobileNavOpen]);
|
||||||
|
|
||||||
|
// 账户下拉菜单:点侧栏 .user 用其 DOMRect 当锚点展开,再次点击收起(toggle)。
|
||||||
|
const [accountAnchor, setAccountAnchor] = useState<DOMRect | null>(null);
|
||||||
|
const toggleAccountMenu = (event: ReactMouseEvent<HTMLElement>) => {
|
||||||
|
const rect = event.currentTarget.getBoundingClientRect();
|
||||||
|
setAccountAnchor((prev) => (prev ? null : rect));
|
||||||
|
};
|
||||||
|
// 退出兜底:父级未传 logout 时,自行 best-effort 调登出接口 + 清 token + 跳登录页。
|
||||||
|
const handleLogout = () => {
|
||||||
|
if (logout) { logout(); return; }
|
||||||
|
void api.logout().catch(() => undefined);
|
||||||
|
setToken(null);
|
||||||
|
window.location.href = "/login";
|
||||||
|
};
|
||||||
|
|
||||||
// 命令面板:Ctrl/Cmd K 开关,点搜索框打开
|
// 命令面板:Ctrl/Cmd K 开关,点搜索框打开
|
||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
const openCommandPalette = () => setPaletteOpen(true);
|
const openCommandPalette = () => setPaletteOpen(true);
|
||||||
@@ -225,13 +331,32 @@ export function Sidebar({ page, navigate, user, team, products, projects, produc
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="aside-foot">
|
<div className="aside-foot">
|
||||||
<div className="user">
|
<div
|
||||||
|
className="user"
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={accountAnchor !== null}
|
||||||
|
title="账户"
|
||||||
|
onClick={toggleAccountMenu}
|
||||||
|
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); toggleAccountMenu(event as unknown as ReactMouseEvent<HTMLElement>); } }}
|
||||||
|
>
|
||||||
<div className="av">{avatar}</div>
|
<div className="av">{avatar}</div>
|
||||||
<div className="em">{team?.name || user.username}</div>
|
<div className="em">{team?.name || user.username}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} />
|
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} navigate={navigate} />
|
||||||
|
{accountAnchor && (
|
||||||
|
<AccountMenu
|
||||||
|
anchorRect={accountAnchor}
|
||||||
|
onClose={() => setAccountAnchor(null)}
|
||||||
|
navigate={navigate}
|
||||||
|
logout={handleLogout}
|
||||||
|
user={user}
|
||||||
|
team={team}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import type { ChangeEvent } from "react";
|
import type { ChangeEvent, DragEvent } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useBodyScrollLock } from "./overlays";
|
import { useBodyScrollLock } from "./overlays";
|
||||||
import type { Product } from "../types";
|
import type { Product } from "../types";
|
||||||
@@ -7,6 +7,9 @@ import "../product-create-page.css";
|
|||||||
|
|
||||||
export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
export const PC_CAT_OPTIONS = ["美妆个护", "服饰内衣", "食品饮料", "家居家电", "数码 3C", "个护清洁", "运动户外", "母婴亲子"];
|
||||||
|
|
||||||
|
// 主图上限(对齐 V1 #pc-drawer:PF_MAX = 5)
|
||||||
|
const PF_MAX = 5;
|
||||||
|
|
||||||
export type ProductCreatePayload = {
|
export type ProductCreatePayload = {
|
||||||
title: string;
|
title: string;
|
||||||
category: string;
|
category: string;
|
||||||
@@ -14,6 +17,11 @@ export type ProductCreatePayload = {
|
|||||||
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
selling_points?: Array<{ title: string; detail: string; sort_order: number }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 抽屉内主图条目:保留原始 File 逐张随商品上传,url 仅作缩略预览
|
||||||
|
type PfImage = { id: string; file: File; url: string };
|
||||||
|
|
||||||
|
const pfUid = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
|
||||||
|
|
||||||
// 新建商品抽屉 · 商品库与新建项目向导共用同一实现,保证两处「新建商品」是同一流程。
|
// 新建商品抽屉 · 商品库与新建项目向导共用同一实现,保证两处「新建商品」是同一流程。
|
||||||
// onCreate 落库(商品库),onUploadImage 把主图随商品持久化,onCreated 让调用方接管成功后行为
|
// onCreate 落库(商品库),onUploadImage 把主图随商品持久化,onCreated 让调用方接管成功后行为
|
||||||
// (商品库弹「继续创建/去新建项目」,向导自动选中新商品)。
|
// (商品库弹「继续创建/去新建项目」,向导自动选中新商品)。
|
||||||
@@ -31,12 +39,25 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
|||||||
const [target, setTarget] = useState("");
|
const [target, setTarget] = useState("");
|
||||||
const [bullets, setBullets] = useState<string[]>([]);
|
const [bullets, setBullets] = useState<string[]>([]);
|
||||||
const [bulletDraft, setBulletDraft] = useState("");
|
const [bulletDraft, setBulletDraft] = useState("");
|
||||||
const [imagePreview, setImagePreview] = useState("");
|
const [images, setImages] = useState<PfImage[]>([]);
|
||||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
const [dragOver, setDragOver] = useState(false);
|
||||||
const [titleError, setTitleError] = useState(false);
|
const [titleError, setTitleError] = useState(false);
|
||||||
const [showGuide, setShowGuide] = useState(false);
|
const [showGuide, setShowGuide] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
// 抽屉内就地 toast(组件无全局 Shell.toast,这里自给一条,镜像 V1 反馈文案)
|
||||||
|
const [toast, setToast] = useState<{ title: string; sub: string } | null>(null);
|
||||||
const imgInputRef = useRef<HTMLInputElement | null>(null);
|
const imgInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const toastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
function flash(title: string, sub: string) {
|
||||||
|
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||||
|
setToast({ title, sub });
|
||||||
|
toastTimer.current = setTimeout(() => setToast(null), 2600);
|
||||||
|
}
|
||||||
|
// 卸载时清掉 toast 定时器 + 释放残留的预览 objectURL
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (toastTimer.current) clearTimeout(toastTimer.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
@@ -44,18 +65,51 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
|||||||
setTarget("");
|
setTarget("");
|
||||||
setBullets([]);
|
setBullets([]);
|
||||||
setBulletDraft("");
|
setBulletDraft("");
|
||||||
setImagePreview("");
|
setImages((list) => { list.forEach((item) => URL.revokeObjectURL(item.url)); return []; });
|
||||||
setImageFile(null);
|
setDragOver(false);
|
||||||
setTitleError(false);
|
setTitleError(false);
|
||||||
}
|
}
|
||||||
// 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开)
|
// 每次打开抽屉时复位表单(含商品库「继续创建商品」二次打开)
|
||||||
useEffect(() => { if (open) resetForm(); }, [open]);
|
useEffect(() => { if (open) resetForm(); }, [open]);
|
||||||
|
|
||||||
|
// 多图收编:超限/计数 toast 对齐 V1 pfAdd()
|
||||||
|
function addImages(fileList: FileList | File[] | null) {
|
||||||
|
if (!fileList) return;
|
||||||
|
setImages((list) => {
|
||||||
|
const room = PF_MAX - list.length;
|
||||||
|
if (room <= 0) { flash("已达上限", `${PF_MAX} / ${PF_MAX} 张`); return list; }
|
||||||
|
const incoming = Array.from(fileList).filter((file) => file.type.startsWith("image/")).slice(0, room);
|
||||||
|
if (incoming.length === 0) return list;
|
||||||
|
const next = [...list, ...incoming.map((file) => ({ id: pfUid(), file, url: URL.createObjectURL(file) }))];
|
||||||
|
flash("已上传", `+ ${incoming.length} 张 · 共 ${next.length} / ${PF_MAX}`);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
function pickImage(event: ChangeEvent<HTMLInputElement>) {
|
function pickImage(event: ChangeEvent<HTMLInputElement>) {
|
||||||
const file = event.target.files?.[0];
|
addImages(event.target.files);
|
||||||
if (file) { setImagePreview(URL.createObjectURL(file)); setImageFile(file); }
|
|
||||||
event.target.value = "";
|
event.target.value = "";
|
||||||
}
|
}
|
||||||
|
function removeImage(id: string) {
|
||||||
|
setImages((list) => {
|
||||||
|
const hit = list.find((item) => item.id === id);
|
||||||
|
if (hit) URL.revokeObjectURL(hit.url);
|
||||||
|
return list.filter((item) => item.id !== id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function openPicker() {
|
||||||
|
if (images.length >= PF_MAX) { flash("已达上限", `${PF_MAX} / ${PF_MAX} 张`); return; }
|
||||||
|
imgInputRef.current?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拖拽上传:dragover 高亮边框,drop 收图(对齐 V1 pfZone 事件)
|
||||||
|
function onZoneDragOver(event: DragEvent<HTMLDivElement>) { event.preventDefault(); setDragOver(true); }
|
||||||
|
function onZoneDragLeave(event: DragEvent<HTMLDivElement>) { event.preventDefault(); setDragOver(false); }
|
||||||
|
function onZoneDrop(event: DragEvent<HTMLDivElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setDragOver(false);
|
||||||
|
if (event.dataTransfer?.files?.length) addImages(event.dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
function addBullet() {
|
function addBullet() {
|
||||||
const value = bulletDraft.trim();
|
const value = bulletDraft.trim();
|
||||||
if (!value) return;
|
if (!value) return;
|
||||||
@@ -68,24 +122,32 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
|||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
const name = title.trim();
|
const name = title.trim();
|
||||||
if (!name) { setTitleError(true); return; } // 空名:必填校验,不静默无反应
|
if (!name) { setTitleError(true); flash("请填写商品名称", "必填项"); return; } // 空名:必填校验,不静默无反应
|
||||||
|
if (images.length === 0) { flash("请上传商品主图", "至少 1 张 · 必填"); return; }
|
||||||
|
// 提交时自动收编未回车的 bulletDraft,避免用户输入了卖点却被丢弃
|
||||||
|
const pending = bulletDraft.trim();
|
||||||
|
const finalBullets = pending ? [...bullets, pending] : bullets;
|
||||||
|
if (finalBullets.length === 0) { flash("请填写核心卖点", "至少 1 条 · 回车确认"); return; }
|
||||||
|
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const file = imageFile;
|
const files = images.map((item) => item.file);
|
||||||
const created = await onCreate({
|
const created = await onCreate({
|
||||||
title: name,
|
title: name,
|
||||||
category: category || catOptions[0],
|
category: category || catOptions[0],
|
||||||
target_audience: target.trim() || undefined,
|
target_audience: target.trim() || undefined,
|
||||||
selling_points: bullets.map((item, index) => ({ title: item, detail: item, sort_order: index }))
|
selling_points: finalBullets.map((item, index) => ({ title: item, detail: item, sort_order: index }))
|
||||||
});
|
});
|
||||||
close();
|
close();
|
||||||
if (!created) return;
|
if (!created) return;
|
||||||
// 把抽屉里选的主图随商品一起上传持久化(否则卡片/详情看不到图)
|
// 把抽屉里选的主图逐张随商品一起上传持久化(否则卡片/详情看不到图)
|
||||||
if (file && onUploadImage) {
|
if (files.length && onUploadImage) {
|
||||||
const fd = new FormData();
|
for (let i = 0; i < files.length; i += 1) {
|
||||||
fd.append("file", file);
|
const fd = new FormData();
|
||||||
fd.append("name", `${name}-主图`);
|
fd.append("file", files[i]);
|
||||||
await onUploadImage(created.id, fd);
|
fd.append("name", `${name}-主图${files.length > 1 ? `-${i + 1}` : ""}`);
|
||||||
|
await onUploadImage(created.id, fd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onCreated?.(created);
|
onCreated?.(created);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,8 +170,8 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
|||||||
<div className="form-card">
|
<div className="form-card">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label">商品名称<span className="req">*</span></label>
|
<label className="field-label">商品名称<span className="req">*</span></label>
|
||||||
<input className="input" value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} style={titleError ? { borderColor: "var(--accent-crimson, #c43d3d)" } : undefined} />
|
<input className={`input${titleError ? " is-error" : ""}`} value={title} onChange={(event) => { setTitle(event.target.value); if (titleError) setTitleError(false); }} placeholder="请输入商品名称(必填)" maxLength={100} aria-invalid={titleError} />
|
||||||
{titleError && <div style={{ color: "var(--accent-crimson, #c43d3d)", fontSize: 12, marginTop: 4 }}>请先填写商品名称</div>}
|
{titleError && <div className="pc-err-note">请先填写商品名称</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field-row">
|
<div className="field-row">
|
||||||
@@ -127,20 +189,23 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
|||||||
|
|
||||||
<div className="field">
|
<div className="field">
|
||||||
<label className="field-label">商品主图<span className="req">*</span></label>
|
<label className="field-label">商品主图<span className="req">*</span></label>
|
||||||
|
<input ref={imgInputRef} type="file" accept="image/*" multiple hidden onChange={pickImage} />
|
||||||
<div className="pf-upload-row">
|
<div className="pf-upload-row">
|
||||||
<div className="pf-upload-zone" role="button" tabIndex={0} onClick={() => imgInputRef.current?.click()} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); imgInputRef.current?.click(); } }}>
|
<div
|
||||||
<input ref={imgInputRef} type="file" accept="image/*" hidden onChange={pickImage} />
|
className={`pf-upload-zone${dragOver ? " is-dragover" : ""}`}
|
||||||
{imagePreview ? (
|
role="button"
|
||||||
<img src={imagePreview} alt="商品主图预览" style={{ maxWidth: "100%", maxHeight: 120, borderRadius: 8, objectFit: "cover" }} />
|
tabIndex={0}
|
||||||
) : (
|
onClick={openPicker}
|
||||||
<>
|
onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openPicker(); } }}
|
||||||
<div className="uz-ic">
|
onDragOver={onZoneDragOver}
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
|
onDragLeave={onZoneDragLeave}
|
||||||
</div>
|
onDrop={onZoneDrop}
|
||||||
<div className="uz-t">点击上传或<strong>拖拽图片</strong>到此处</div>
|
>
|
||||||
<div className="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
|
<div 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 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12" /></svg>
|
||||||
)}
|
</div>
|
||||||
|
<div className="uz-t">点击上传或<strong>拖拽图片</strong>到此处</div>
|
||||||
|
<div className="uz-d">// 支持 JPG、PNG 格式,建议尺寸 800×800 以上,大小不超过 10MB</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pf-example">
|
<div className="pf-example">
|
||||||
<div className="ex-h">示例图</div>
|
<div className="ex-h">示例图</div>
|
||||||
@@ -152,10 +217,19 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
|||||||
<div className="ex-d">优质的商品图有助于生成更好的素材效果</div>
|
<div className="ex-d">优质的商品图有助于生成更好的素材效果</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pf-grid" />
|
<div className="pf-grid">
|
||||||
|
{images.map((item) => (
|
||||||
|
<div className="pf-thumb" key={item.id}>
|
||||||
|
<img src={item.url} alt="商品主图预览" />
|
||||||
|
<button className="pf-x" type="button" title="删除" aria-label="删除该图" onClick={() => removeImage(item.id)}>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"><path d="M4 4l8 8M12 4l-8 8" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="field" style={{ marginBottom: 0 }}>
|
<div className="field pc-field-last">
|
||||||
<label className="field-label">核心卖点<span className="req">*</span></label>
|
<label className="field-label">核心卖点<span className="req">*</span></label>
|
||||||
<ul className="bullet-list">
|
<ul className="bullet-list">
|
||||||
{bullets.map((bullet, index) => (
|
{bullets.map((bullet, index) => (
|
||||||
@@ -177,7 +251,7 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showGuide && (
|
{showGuide && (
|
||||||
<div className="pc-guide-note" style={{ padding: "10px 14px", margin: "0 16px 8px", background: "var(--black-alpha-4)", borderRadius: 8, fontSize: 13, lineHeight: 1.7, color: "var(--black-alpha-72)" }}>
|
<div className="pc-guide-note">
|
||||||
<strong>// 建好商品的 3 步</strong><br />
|
<strong>// 建好商品的 3 步</strong><br />
|
||||||
① 填写商品名称 + 品类(必填,用于脚本/素材生成)<br />
|
① 填写商品名称 + 品类(必填,用于脚本/素材生成)<br />
|
||||||
② 上传清晰主图(800×800 以上),便于 AI 出图更准<br />
|
② 上传清晰主图(800×800 以上),便于 AI 出图更准<br />
|
||||||
@@ -195,6 +269,13 @@ export function ProductCreateDrawer({ open, close, onCreate, onUploadImage, onCr
|
|||||||
{saving ? "创建中…" : "创建商品"}
|
{saving ? "创建中…" : "创建商品"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{toast && (
|
||||||
|
<div className="pc-toast" role="status" aria-live="polite">
|
||||||
|
<span className="ic-t"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12l5 5L20 6" /></svg></span>
|
||||||
|
<div className="txt">{toast.title}<span className="mono">// {toast.sub}</span></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</>,
|
</>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -339,6 +339,8 @@
|
|||||||
min-height: 180px;
|
min-height: 180px;
|
||||||
}
|
}
|
||||||
.pc-drawer .form-card .pf-upload-zone:hover { border-color: var(--heat); background: var(--heat-8); }
|
.pc-drawer .form-card .pf-upload-zone:hover { border-color: var(--heat); background: var(--heat-8); }
|
||||||
|
/* 拖拽悬停:边框/底色变橙(对齐 V1 pfZone dragover) */
|
||||||
|
.pc-drawer .form-card .pf-upload-zone.is-dragover { border-color: var(--heat); background: var(--heat-8); }
|
||||||
.pc-drawer .form-card .pf-upload-zone .uz-ic {
|
.pc-drawer .form-card .pf-upload-zone .uz-ic {
|
||||||
width: 44px; height: 44px;
|
width: 44px; height: 44px;
|
||||||
margin: 0 auto 10px;
|
margin: 0 auto 10px;
|
||||||
@@ -353,7 +355,7 @@
|
|||||||
.pc-drawer .form-card .pf-upload-zone .uz-t strong { color: var(--heat); font-weight: 600; }
|
.pc-drawer .form-card .pf-upload-zone .uz-t strong { color: var(--heat); font-weight: 600; }
|
||||||
.pc-drawer .form-card .pf-upload-zone .uz-d {
|
.pc-drawer .form-card .pf-upload-zone .uz-d {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-family: var(--font-mono); font-size: 12px;
|
font-family: var(--font-mono); font-size: 11.5px;
|
||||||
color: var(--black-alpha-48); letter-spacing: .02em;
|
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||||
}
|
}
|
||||||
/* 示例图 · 纵向卡片 */
|
/* 示例图 · 纵向卡片 */
|
||||||
@@ -418,13 +420,13 @@
|
|||||||
border: 1px solid var(--border-faint);
|
border: 1px solid var(--border-faint);
|
||||||
border-radius: var(--r-sm);
|
border-radius: var(--r-sm);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px; color: var(--heat); font-weight: 600;
|
font-size: 11px; color: var(--heat); font-weight: 700;
|
||||||
display: grid; place-items: center; flex-shrink: 0;
|
display: grid; place-items: center; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.pc-drawer .form-card .bullet-list .bl-text { flex: 1; color: var(--accent-black); }
|
.pc-drawer .form-card .bullet-list .bl-text { flex: 1; color: var(--accent-black); }
|
||||||
.pc-drawer .form-card .bullet-list .bl-input {
|
.pc-drawer .form-card .bullet-list .bl-input {
|
||||||
flex: 1; background: transparent; border: 0; outline: none;
|
flex: 1; background: transparent; border: 0; outline: none;
|
||||||
font-size: 14px; color: var(--accent-black); font-family: inherit;
|
font-size: 13.5px; color: var(--accent-black); font-family: inherit;
|
||||||
}
|
}
|
||||||
.pc-drawer .form-card .bullet-list .bl-x {
|
.pc-drawer .form-card .bullet-list .bl-x {
|
||||||
width: 22px; height: 22px;
|
width: 22px; height: 22px;
|
||||||
@@ -438,3 +440,81 @@
|
|||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; }
|
.pc-drawer .drawer-b .pf-upload-row { grid-template-columns: 1fr; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── 主图缩略网格 · 多图上传(对齐 V1 #pc-drawer .pf-thumb/.pf-x) ─── */
|
||||||
|
.pc-drawer .form-card .pf-thumb {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background: var(--background-lighter);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
position: relative; overflow: hidden; cursor: pointer;
|
||||||
|
}
|
||||||
|
.pc-drawer .form-card .pf-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.pc-drawer .form-card .pf-thumb .pf-x {
|
||||||
|
position: absolute; top: 4px; right: 4px;
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
background: rgba(0, 0, 0, .7); color: var(--accent-white);
|
||||||
|
border: 0; border-radius: 50%; cursor: pointer;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
opacity: 0; transition: opacity var(--t-base);
|
||||||
|
}
|
||||||
|
.pc-drawer .form-card .pf-thumb:hover .pf-x,
|
||||||
|
.pc-drawer .form-card .pf-thumb:focus-within .pf-x { opacity: 1; }
|
||||||
|
.pc-drawer .form-card .pf-thumb .pf-x svg { width: 11px; height: 11px; }
|
||||||
|
|
||||||
|
/* ─── 校验态 · 必填红字 / 输入框红框(原内联 style 抽成类) ─── */
|
||||||
|
.pc-drawer .form-card .input.is-error,
|
||||||
|
.pc-drawer .form-card .input.is-error:focus {
|
||||||
|
border-color: var(--accent-crimson);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--crimson-bd);
|
||||||
|
}
|
||||||
|
.pc-drawer .pc-err-note {
|
||||||
|
color: var(--accent-crimson);
|
||||||
|
font-size: 12px; margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 末尾字段去底距(替代 inline margin-bottom:0) */
|
||||||
|
.pc-drawer .form-card .field.pc-field-last { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* ─── 使用指南内联说明(原内联 style 抽成类) ─── */
|
||||||
|
.pc-drawer .pc-guide-note {
|
||||||
|
padding: 10px 14px;
|
||||||
|
margin: 0 16px 8px;
|
||||||
|
background: var(--black-alpha-4);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: 13px; line-height: 1.7;
|
||||||
|
color: var(--black-alpha-72);
|
||||||
|
}
|
||||||
|
.pc-drawer .pc-guide-note strong { color: var(--accent-black); font-weight: 600; }
|
||||||
|
|
||||||
|
/* ─── 抽屉内就地 toast(组件无全局 Shell.toast;镜像 .toast 视觉,自给一条) ─── */
|
||||||
|
.pc-drawer .pc-toast {
|
||||||
|
position: absolute; right: 24px; bottom: 80px;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
max-width: 340px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border-faint);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
box-shadow: var(--shadow-floating, 0 8px 24px rgba(0, 0, 0, .1));
|
||||||
|
animation: pc-toast-in .18s ease-out;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
@keyframes pc-toast-in {
|
||||||
|
from { opacity: 0; transform: translateY(6px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
.pc-drawer .pc-toast .ic-t {
|
||||||
|
width: 24px; height: 24px; flex-shrink: 0;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
background: var(--heat-12); color: var(--heat);
|
||||||
|
border: 1px solid var(--heat-20);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
.pc-drawer .pc-toast .ic-t svg { width: 13px; height: 13px; }
|
||||||
|
.pc-drawer .pc-toast .txt { font-size: 13px; color: var(--accent-black); font-weight: 500; }
|
||||||
|
.pc-drawer .pc-toast .txt .mono {
|
||||||
|
display: block; margin-top: 2px;
|
||||||
|
font-family: var(--font-mono); font-size: 12px;
|
||||||
|
color: var(--black-alpha-48); letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 type { ChangeEvent, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sliders,
|
Sliders,
|
||||||
Smartphone,
|
|
||||||
Upload,
|
Upload,
|
||||||
User as UserIcon,
|
User as UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -16,13 +15,15 @@ import { TeamModal } from "../components/overlays";
|
|||||||
|
|
||||||
type SectionKey = "profile" | "security" | "notify" | "pref" | "display";
|
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: "个人",
|
group: "个人",
|
||||||
items: [
|
items: [
|
||||||
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
|
||||||
{ key: "security", label: "安全", icon: <ShieldCheck />, badge: "3 设备" },
|
{ key: "security", label: "安全", icon: <ShieldCheck /> },
|
||||||
{ key: "notify", label: "通知", icon: <Bell />, badge: "4/4" },
|
{ key: "notify", label: "通知", icon: <Bell /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -81,6 +82,50 @@ const DEFAULT_PREFS = {
|
|||||||
density: "standard",
|
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 }) {
|
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
|
||||||
return (
|
return (
|
||||||
<label className="switch">
|
<label className="switch">
|
||||||
@@ -121,54 +166,71 @@ export function SettingsPage({
|
|||||||
onNotify?: (text: string) => void;
|
onNotify?: (text: string) => void;
|
||||||
onLogout?: () => void | Promise<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)
|
? (initialSection as SectionKey)
|
||||||
: "profile";
|
: "profile";
|
||||||
const [section, setSection] = useState<SectionKey>(normalizedInitial);
|
const [section, setSection] = useState<SectionKey>(normalizedInitial);
|
||||||
const [modal, setModal] = useState<"" | "avatar" | "logout" | "password">("");
|
const [modal, setModal] = useState<"" | "avatar" | "logout" | "password">("");
|
||||||
|
|
||||||
// 个人信息 · 受控输入(初值取真实用户数据)
|
// ─── 统一保存:已保存基线(baseline)从真实用户/后端 preferences 注入 ───
|
||||||
const [name, setName] = useState(user.username || "");
|
const baselineFromProps = useMemo<TrackedState>(() => {
|
||||||
const [email, setEmail] = useState(user.email || "");
|
const cd = preferences?.creation_defaults;
|
||||||
const [phone, setPhone] = useState("");
|
const dp = preferences?.display;
|
||||||
const [savingProfile, setSavingProfile] = useState(false);
|
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 回后端)
|
// baseline = 已保存值;draft = 当前编辑值。diff(draft, baseline) = 脏字段。
|
||||||
const [template, setTemplate] = useState(DEFAULT_PREFS.template);
|
const [baseline, setBaseline] = useState<TrackedState>(baselineFromProps);
|
||||||
const [duration, setDuration] = useState(DEFAULT_PREFS.duration);
|
const [draft, setDraft] = useState<TrackedState>(baselineFromProps);
|
||||||
const [subtitle, setSubtitle] = useState(DEFAULT_PREFS.subtitle);
|
const [saving, setSaving] = useState(false);
|
||||||
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);
|
|
||||||
|
|
||||||
// 后端 preferences 到达时注入(覆盖默认值,缺字段回退默认)
|
// 后端 preferences / user 到达或刷新时,把新值同时灌进 baseline 与 draft(用户未改时跟随后端)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!preferences) return;
|
setBaseline(baselineFromProps);
|
||||||
const cd = preferences.creation_defaults || {};
|
setDraft(baselineFromProps);
|
||||||
setTemplate(cd.template ?? DEFAULT_PREFS.template);
|
}, [baselineFromProps]);
|
||||||
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 即时持久化单字段)
|
// 单字段 draft 更新器
|
||||||
function saveCreation(patch: Partial<UserPreference["creation_defaults"]>) {
|
const patchDraft = useCallback(<K extends keyof TrackedState>(key: K, value: TrackedState[K]) => {
|
||||||
onSavePreferences?.({ creation_defaults: { template, duration, subtitle, bgm, transition, ...patch } });
|
setDraft((prev) => ({ ...prev, [key]: value }));
|
||||||
}
|
}, []);
|
||||||
function saveDisplay(patch: Partial<UserPreference["display"]>) {
|
|
||||||
onSavePreferences?.({ display: { appearance, language, density, ...patch } });
|
// ─── 聚合脏字段集 + 涉及分区集(顶栏计数 / 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("");
|
const [oldPassword, setOldPassword] = useState("");
|
||||||
@@ -186,20 +248,63 @@ export function SettingsPage({
|
|||||||
|
|
||||||
const avatarChar = useMemo(() => (name || user.username || "李").slice(0, 1).toUpperCase(), [name, user.username]);
|
const avatarChar = useMemo(() => (name || user.username || "李").slice(0, 1).toUpperCase(), [name, user.username]);
|
||||||
|
|
||||||
function resetProfile() {
|
// ─── nav badge · 由 state 计算(通知开启数 / 设备数),不写死 ───
|
||||||
setName(user.username || "");
|
const notifyOnCount = useMemo(() => NOTIFY_ROWS.filter((row) => !!draft.notify[row.key]).length, [draft.notify]);
|
||||||
setEmail(user.email || "");
|
const navBadge = useCallback(
|
||||||
setPhone("");
|
(key: SectionKey): string | null => {
|
||||||
onNotify?.("已恢复为已保存的资料");
|
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() {
|
// ─── 统一保存:把所有脏字段拆成 profile / preferences 两个 payload 提交,成功后把 draft 升为新基线 ───
|
||||||
if (savingProfile) return;
|
async function handleSaveAll() {
|
||||||
setSavingProfile(true);
|
if (!isDirty || saving) return;
|
||||||
|
setSaving(true);
|
||||||
try {
|
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 {
|
} finally {
|
||||||
setSavingProfile(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,6 +364,17 @@ export function SettingsPage({
|
|||||||
return () => URL.revokeObjectURL(avatarPreview);
|
return () => URL.revokeObjectURL(avatarPreview);
|
||||||
}, [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 (
|
return (
|
||||||
<section className="settings-page">
|
<section className="settings-page">
|
||||||
<div className="page-head">
|
<div className="page-head">
|
||||||
@@ -267,10 +383,11 @@ export function SettingsPage({
|
|||||||
<div className="sub"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
|
<div className="sub"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button className="btn" type="button" onClick={resetProfile} disabled={savingProfile}>取消</button>
|
<button className="btn" type="button" onClick={discardChanges} disabled={!isDirty || saving}>取消</button>
|
||||||
<button className="btn btn-primary" type="button" onClick={handleSaveProfile} disabled={savingProfile}>
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,24 +398,28 @@ export function SettingsPage({
|
|||||||
{NAV.map((group, gi) => (
|
{NAV.map((group, gi) => (
|
||||||
<div key={group.group}>
|
<div key={group.group}>
|
||||||
<div className="nav-h" style={gi > 0 ? { marginTop: 16 } : undefined}>{group.group}</div>
|
<div className="nav-h" style={gi > 0 ? { marginTop: 16 } : undefined}>{group.group}</div>
|
||||||
{group.items.map((item) => (
|
{group.items.map((item) => {
|
||||||
<a
|
const badge = navBadge(item.key);
|
||||||
key={item.key}
|
const dirty = dirtySections.has(item.key);
|
||||||
href={`#sec-${item.key}`}
|
return (
|
||||||
className={section === item.key ? "active" : ""}
|
<a
|
||||||
role="tab"
|
key={item.key}
|
||||||
aria-selected={section === item.key}
|
href={`#sec-${item.key}`}
|
||||||
onClick={(event) => {
|
className={`${section === item.key ? "active" : ""}${dirty ? " has-changes" : ""}`}
|
||||||
event.preventDefault();
|
role="tab"
|
||||||
setSection(item.key);
|
aria-selected={section === item.key}
|
||||||
}}
|
onClick={(event) => {
|
||||||
>
|
event.preventDefault();
|
||||||
{item.icon}
|
setSection(item.key);
|
||||||
<span>{item.label}</span>
|
}}
|
||||||
{item.badge ? <span className="nav-badge">{item.badge}</span> : null}
|
>
|
||||||
<span className="nav-dot" aria-hidden="true" />
|
{item.icon}
|
||||||
</a>
|
<span>{item.label}</span>
|
||||||
))}
|
{badge ? <span className="nav-badge">{badge}</span> : null}
|
||||||
|
<span className="nav-dot" aria-hidden="true" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="nav-h" style={{ marginTop: 16 }}>账号</div>
|
<div className="nav-h" style={{ marginTop: 16 }}>账号</div>
|
||||||
@@ -329,20 +450,20 @@ export function SettingsPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">显示名称<span className="req">*</span></div>
|
<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>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">登录邮箱</div>
|
<div className="lbl">登录邮箱</div>
|
||||||
<div className="val">
|
<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>
|
<button className="btn btn-ghost btn-sm" type="button" onClick={() => onNotify?.(email ? `已向 ${email} 发送验证邮件` : "请先填写邮箱")}>验证</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">手机号</div>
|
<div className="lbl">手机号</div>
|
||||||
<div className="val">
|
<div className="val">
|
||||||
<input className="input" value={phone} onChange={(event) => setPhone(event.target.value)} placeholder="138****8000" />
|
<input className="input" value={phone} onChange={(event) => patchDraft("phone", 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>
|
<span className="switch-note">// 在「保存所有变更」中一并提交</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
@@ -376,7 +497,7 @@ export function SettingsPage({
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">两步验证<div className="lbl-sub">// 推荐开启</div></div>
|
<div className="lbl">两步验证<div className="lbl-sub">// 推荐开启</div></div>
|
||||||
<div className="val">
|
<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>
|
<span className="switch-note">短信 + Authenticator</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,7 +546,7 @@ export function SettingsPage({
|
|||||||
<div className="form-row" key={row.key}>
|
<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="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
|
||||||
<div className="val">
|
<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>
|
<span className="switch-note">{row.channels}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,10 +566,10 @@ export function SettingsPage({
|
|||||||
{TEMPLATE_CHOICES.map((choice) => (
|
{TEMPLATE_CHOICES.map((choice) => (
|
||||||
<div
|
<div
|
||||||
key={choice.v}
|
key={choice.v}
|
||||||
className={`pref-choice ${template === choice.v ? "selected" : ""}`}
|
className={`pref-choice ${draft.template === choice.v ? "selected" : ""}`}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => { setTemplate(choice.v); saveCreation({ template: choice.v }); }}
|
onClick={() => patchDraft("template", choice.v)}
|
||||||
>
|
>
|
||||||
<div className="t">{choice.t}</div>
|
<div className="t">{choice.t}</div>
|
||||||
<div className="d">{choice.d}</div>
|
<div className="d">{choice.d}</div>
|
||||||
@@ -464,10 +585,10 @@ export function SettingsPage({
|
|||||||
{DURATIONS.map((d) => (
|
{DURATIONS.map((d) => (
|
||||||
<span
|
<span
|
||||||
key={d}
|
key={d}
|
||||||
className={`dur-chip ${duration === d ? "selected" : ""}`}
|
className={`dur-chip ${draft.duration === d ? "selected" : ""}`}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => { setDuration(d); saveCreation({ duration: d }); }}
|
onClick={() => patchDraft("duration", d)}
|
||||||
>
|
>
|
||||||
{d}s
|
{d}s
|
||||||
</span>
|
</span>
|
||||||
@@ -483,10 +604,10 @@ export function SettingsPage({
|
|||||||
{SUBTITLE_CHOICES.map((choice) => (
|
{SUBTITLE_CHOICES.map((choice) => (
|
||||||
<div
|
<div
|
||||||
key={choice.v}
|
key={choice.v}
|
||||||
className={`pref-choice ${subtitle === choice.v ? "selected" : ""}`}
|
className={`pref-choice ${draft.subtitle === choice.v ? "selected" : ""}`}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => { setSubtitle(choice.v); saveCreation({ subtitle: choice.v }); }}
|
onClick={() => patchDraft("subtitle", choice.v)}
|
||||||
>
|
>
|
||||||
<div className="t">{choice.t}</div>
|
<div className="t">{choice.t}</div>
|
||||||
<div className="d">{choice.d}</div>
|
<div className="d">{choice.d}</div>
|
||||||
@@ -498,7 +619,7 @@ export function SettingsPage({
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">默认 BGM 库</div>
|
<div className="lbl">默认 BGM 库</div>
|
||||||
<div className="val">
|
<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="kapian">抖音 Top10 卡点曲库</option>
|
||||||
<option value="emotion">情绪向 · 治愈/悬念</option>
|
<option value="emotion">情绪向 · 治愈/悬念</option>
|
||||||
<option value="urban">都市电子 · 通勤场景</option>
|
<option value="urban">都市电子 · 通勤场景</option>
|
||||||
@@ -509,7 +630,7 @@ export function SettingsPage({
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">默认转场</div>
|
<div className="lbl">默认转场</div>
|
||||||
<div className="val">
|
<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="none">无转场</option>
|
||||||
<option value="fade">淡入淡出 · 0.3s</option>
|
<option value="fade">淡入淡出 · 0.3s</option>
|
||||||
<option value="slide">滑动 · 0.3s</option>
|
<option value="slide">滑动 · 0.3s</option>
|
||||||
@@ -536,7 +657,7 @@ export function SettingsPage({
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">外观</div>
|
<div className="lbl">外观</div>
|
||||||
<div className="val">
|
<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="system">跟随系统</option>
|
||||||
<option value="light">浅色</option>
|
<option value="light">浅色</option>
|
||||||
<option value="dark" disabled>深色(V2)</option>
|
<option value="dark" disabled>深色(V2)</option>
|
||||||
@@ -546,7 +667,7 @@ export function SettingsPage({
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">语言</div>
|
<div className="lbl">语言</div>
|
||||||
<div className="val">
|
<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="zh">简体中文</option>
|
||||||
<option value="en" disabled>English(V2)</option>
|
<option value="en" disabled>English(V2)</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -555,7 +676,7 @@ export function SettingsPage({
|
|||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="lbl">表格密度</div>
|
<div className="lbl">表格密度</div>
|
||||||
<div className="val">
|
<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="compact">紧凑</option>
|
||||||
<option value="standard">标准</option>
|
<option value="standard">标准</option>
|
||||||
<option value="loose">宽松</option>
|
<option value="loose">宽松</option>
|
||||||
@@ -689,6 +810,7 @@ export function SettingsPage({
|
|||||||
<div className="li">项目、资产、团队成员与余额数据都会保留</div>
|
<div className="li">项目、资产、团队成员与余额数据都会保留</div>
|
||||||
<div className="li">仅影响当前浏览器会话,不会下线其他设备</div>
|
<div className="li">仅影响当前浏览器会话,不会下线其他设备</div>
|
||||||
</div>
|
</div>
|
||||||
|
{isDirty ? <div className="logout-unsaved-note">当前有 {dirtyCount} 项未保存的设置变更,退出后这些变更不会保存。</div> : null}
|
||||||
</TeamModal>
|
</TeamModal>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
.settings-nav a .nav-dot { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--heat); display: none; }
|
.settings-nav a .nav-dot { position: absolute; right: 10px; top: 50%; transform: translateY(-50%); width: 6px; height: 6px; border-radius: 50%; background: var(--heat); display: none; }
|
||||||
.settings-nav a.has-changes .nav-dot { display: block; }
|
.settings-nav a.has-changes .nav-dot { display: block; }
|
||||||
.settings-nav a.active .nav-dot { right: -4px; }
|
.settings-nav a.active .nav-dot { right: -4px; }
|
||||||
|
/* 改过的区:nav 文字也带橙提示(active 时已是橙,此处管未选中区) */
|
||||||
|
.settings-nav a.has-changes:not(.active) > span:first-of-type { color: var(--heat); }
|
||||||
|
|
||||||
|
/* ─── 顶栏「保存所有变更」脏字段计数 · 主 CTA 内白字 mono ─── */
|
||||||
|
.page-head .actions .save-count { font-family: var(--font-mono); font-size: 11px; margin-left: 4px; opacity: .75; font-variant-numeric: tabular-nums; }
|
||||||
.settings-nav .logout-pill {
|
.settings-nav .logout-pill {
|
||||||
width: calc(100% - 24px);
|
width: calc(100% - 24px);
|
||||||
height: 38px;
|
height: 38px;
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Wave 3 batch4 walkthrough (scratch): 外壳账户菜单 + 设置dirty + 商品创建抽屉多图.
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const BASE = process.env.BASE || "http://localhost:5173";
|
||||||
|
const TOKEN = process.env.TOKEN || "";
|
||||||
|
const OUT = path.resolve("../../../_qa_shots/wave3-batch4");
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: "light" });
|
||||||
|
const seed = await ctx.newPage();
|
||||||
|
await seed.goto(BASE + "/", { waitUntil: "domcontentloaded" });
|
||||||
|
await seed.evaluate((t) => localStorage.setItem("airshelf_token", t), TOKEN);
|
||||||
|
await seed.close();
|
||||||
|
|
||||||
|
const r = { shell: {}, settings: {}, drawer: {}, consoleErrors: [] };
|
||||||
|
|
||||||
|
// 外壳账户菜单(任意页)
|
||||||
|
{
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("shell:" + m.text()); });
|
||||||
|
p.on("pageerror", (e) => r.consoleErrors.push("shell:PAGEERR:" + e.message));
|
||||||
|
await p.goto(BASE + "/dashboard", { waitUntil: "load" });
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
const user = p.locator(".user").first();
|
||||||
|
if (await user.count()) {
|
||||||
|
await user.click();
|
||||||
|
await p.waitForTimeout(350);
|
||||||
|
r.shell.menuShown = await p.locator(".shell-account-menu.show, .shell-account-menu").count();
|
||||||
|
r.shell.menuItems = await p.locator(".shell-account-menu button").count();
|
||||||
|
await p.screenshot({ path: path.join(OUT, "account-menu.png") });
|
||||||
|
await p.keyboard.press("Escape");
|
||||||
|
await p.waitForTimeout(300);
|
||||||
|
r.shell.closedByEsc = (await p.locator(".shell-account-menu.show").count()) === 0;
|
||||||
|
} else r.shell = "no .user";
|
||||||
|
await p.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 dirty 引擎
|
||||||
|
{
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("settings:" + m.text()); });
|
||||||
|
p.on("pageerror", (e) => r.consoleErrors.push("settings:PAGEERR:" + e.message));
|
||||||
|
await p.goto(BASE + "/settings", { waitUntil: "load" });
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
const saveBtn = p.locator("button:has-text('保存所有变更'), button:has-text('保存')").first();
|
||||||
|
r.settings.saveBtnDisabledInitially = await saveBtn.isDisabled().catch(() => "n/a");
|
||||||
|
// 改一个开关(找第一个 switch/checkbox)触发 dirty
|
||||||
|
const sw = p.locator(".switch input, input[type=checkbox]").first();
|
||||||
|
if (await sw.count()) {
|
||||||
|
await sw.click({ force: true });
|
||||||
|
await p.waitForTimeout(400);
|
||||||
|
r.settings.saveBtnEnabledAfterChange = !(await saveBtn.isDisabled().catch(() => true));
|
||||||
|
r.settings.dirtyCount = await p.locator(".save-count").count();
|
||||||
|
r.settings.navDirtyDot = await p.locator(".settings-nav a.has-changes, .nav-dot").count();
|
||||||
|
}
|
||||||
|
await p.screenshot({ path: path.join(OUT, "settings-dirty.png") });
|
||||||
|
await p.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 商品创建抽屉多图
|
||||||
|
{
|
||||||
|
const p = await ctx.newPage();
|
||||||
|
p.on("console", (m) => { if (m.type() === "error") r.consoleErrors.push("drawer:" + m.text()); });
|
||||||
|
p.on("pageerror", (e) => r.consoleErrors.push("drawer:PAGEERR:" + e.message));
|
||||||
|
await p.goto(BASE + "/products", { waitUntil: "load" });
|
||||||
|
await p.waitForTimeout(1500);
|
||||||
|
const newBtn = p.locator("button:has-text('新建商品'), button:has-text('新建')").first();
|
||||||
|
if (await newBtn.count()) {
|
||||||
|
await newBtn.click();
|
||||||
|
await p.waitForTimeout(500);
|
||||||
|
r.drawer.drawerShown = await p.locator(".drawer.show, .pc-drawer").count();
|
||||||
|
r.drawer.multipleInput = await p.locator("input[type=file][multiple]").count();
|
||||||
|
r.drawer.pfGrid = await p.locator(".pf-grid").count();
|
||||||
|
await p.screenshot({ path: path.join(OUT, "create-drawer.png") });
|
||||||
|
} else r.drawer = "no 新建商品 btn";
|
||||||
|
await p.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(JSON.stringify(r, null, 2));
|
||||||
|
fs.writeFileSync(path.join(OUT, "summary.json"), JSON.stringify(r, null, 2));
|
||||||
Reference in New Issue
Block a user