feat(core/frontend): P1 pixel restoration (settings/messages/wizard/product-create faithful; ai-tools draft)

- settings.tsx + settings-page.css: restore to settings.html (left-nav + sections), real user/team data
- messages.tsx + messages-page.css: rich inbox restore (filters/detail/props grid) on real notifications
- projects.tsx ProjectWizardPage + project-wizard-page.css: restore to projects-new.html
- products.tsx ProductCreateUploadPage + product-create-page.css: restore to product-create-v2 baseline
- ai-tools.tsx (AssetFactory/ImageWorkbench) + ai-tools-page.css: DRAFT unified studio shell;
  deviates from per-page baselines (image-optimize should be chat-stream; model-photo product+person picker)
  -> pending rework alongside P3 standalone image-gen decision
- shot-p1.mjs: playwright visual-parity capture (react vs exact baseline; uses 127.0.0.1 not localhost)
- verified: tsc --noEmit clean; screenshots confirm settings/wizard/product-create/messages faithful

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-05 14:25:19 +08:00
co-authored by Claude Opus 4.8
parent 25bf3293df
commit 78fd7ee13d
12 changed files with 3316 additions and 101 deletions
+447 -13
View File
@@ -1,18 +1,452 @@
import { useState } from "react";
import { LogOut, Upload } from "lucide-react";
import { useMemo, useState } from "react";
import type { ReactNode } from "react";
import {
Bell,
LogOut,
Monitor,
ShieldCheck,
Sliders,
Smartphone,
Upload,
User as UserIcon,
} from "lucide-react";
import type { Team, User } from "../types";
import { ConfirmModal, SettingRow, TeamModal } from "../components/overlays";
import { TeamModal } from "../components/overlays";
export function SettingsPage({ user, team, initialSection = "profile" }: { user: User; team: Team; initialSection?: string }) {
const [section, setSection] = useState(initialSection);
const [modal, setModal] = useState<"" | "avatar" | "logout">("");
const sections = [["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 }> }> = [
{
group: "个人",
items: [
{ key: "profile", label: "个人信息", icon: <UserIcon /> },
{ key: "security", label: "安全", icon: <ShieldCheck />, badge: "3 设备" },
{ key: "notify", label: "通知", icon: <Bell />, badge: "4/4" },
],
},
{
group: "偏好",
items: [
{ key: "pref", label: "创作默认", icon: <Sliders /> },
{ key: "display", label: "显示", icon: <Monitor /> },
],
},
];
const TEMPLATE_CHOICES = [
{ v: "pain", t: "痛点种草", d: "// 30s 默认档" },
{ v: "unbox", t: "开箱测评", d: "// 45s 默认档" },
{ v: "compare", t: "对比展示", d: "// 45s 默认档" },
{ v: "howto", t: "教程演示", d: "// 60s 默认档" },
{ v: "drama", t: "剧情带货", d: "// 60s 默认档" },
];
const SUBTITLE_CHOICES = [
{ v: "big-variety", t: "大字综艺", d: "// 抖音热门" },
{ v: "clean-ec", t: "简洁电商", d: "// 信息清晰" },
{ v: "premium", t: "高级排版", d: "// 居中衬线" },
{ v: "bullet", t: "弹幕轻量", d: "// 滚动出现" },
{ v: "emphasis", t: "强调爆款", d: "// 高对比" },
];
const DURATIONS = ["30", "45", "60"];
const DEVICES: Array<{ name: string; meta: string; current?: boolean; phone?: boolean }> = [
{ name: "MacBook Pro · Chrome", meta: "// 上海 · 2026-05-21 14:08 · IP 116.xxx.xxx.42", current: true },
{ name: "iPhone 15 · Safari", meta: "// 上海 · 2026-05-20 21:43", phone: true },
{ name: "Windows · Edge", meta: "// 杭州 · 2026-05-18 09:12" },
];
const NOTIFY_ROWS: Array<{ key: string; title: string; sub?: string; channels: string }> = [
{ key: "n-export", title: "项目完成通知", sub: "// 视频导出后", channels: "站内 · 邮件 · 短信" },
{ key: "n-fail", title: "任务失败告警", channels: "站内 · 邮件" },
{ key: "n-quota", title: "额度不足提醒", sub: "// 团队或个人剩余 < 20%", channels: "站内 · 短信" },
{ key: "n-login", title: "异地登录告警", channels: "短信" },
];
function Switch({ checked, disabled, onChange }: { checked: boolean; disabled?: boolean; onChange?: (next: boolean) => void }) {
return (
<>
<div className="page-head"><div><h1></h1><div className="sub"><span className="mono">// profile · security · notify · preference · display</span></div></div><div className="actions"><button className="btn" type="button">取消</button><button className="btn btn-primary" type="button">保存设置</button></div></div>
<div className="settings-layout"><aside className="settings-side">{sections.map(([key, label]) => <button className={section === key ? "active" : ""} type="button" key={key} onClick={() => setSection(key)}>{label}</button>)}<button className="logout-pill" type="button" onClick={() => setModal("logout")}><LogOut size={13} />退</button></aside><section className="settings-main">{section === "profile" && <div className="pane"><h3></h3><div className="profile-row"><div className="av-big">{user.username.slice(0, 1).toUpperCase()}</div><button className="btn btn-sm" type="button" onClick={() => setModal("avatar")}></button></div><div className="field"><label className="field-label"></label><input className="input" value={user.username} readOnly /></div><div className="field"><label className="field-label"></label><input className="input" value={user.email || ""} readOnly /></div><div className="field"><label className="field-label"></label><input className="input" value={team.name} readOnly /></div></div>}{section === "security" && <div className="pane"><h3></h3><SettingRow title="登录密码" desc="上次更新: 2026-05-28" action="修改" /><SettingRow title="双因素认证" desc="建议超管开启" toggle /></div>}{section === "notify" && <div className="pane"><h3></h3><SettingRow title="导出完成" desc="成片 / 套图完成后提醒" toggle checked /><SettingRow title="任务失败" desc="失败和扣费异常必须提醒" toggle checked /><SettingRow title="额度预警" desc="余额低于阈值时提醒" toggle checked /></div>}{section === "pref" && <div className="pane"><h3></h3><SettingRow title="自动水印" desc="VIP 可关闭" toggle checked /></div>}{section === "display" && <div className="pane"><h3></h3><div className="field"><label className="field-label"></label><select className="select"><option></option><option></option></select></div></div>}</section></div>
<TeamModal open={modal === "avatar"} title="上传头像" subtitle="// JPG / PNG / WebP" icon={<Upload size={16} />} close={() => setModal("")}><div className="upload-zone"><br /><span className="mono">// 头像上传接口待后端补充</span></div></TeamModal>
<ConfirmModal open={modal === "logout"} title="退出当前账号" detail="当前有未保存的设置变更时,退出后这些变更不会保存。" confirmText="退出" onCancel={() => setModal("")} onConfirm={() => setModal("")} />
</>
<label className="switch">
<input type="checkbox" checked={checked} disabled={disabled} onChange={(event) => onChange?.(event.target.checked)} />
<span className="slider" />
</label>
);
}
export function SettingsPage({ user, team, initialSection = "profile" }: { user: User; team: Team; initialSection?: string }) {
const normalizedInitial = (["profile", "security", "notify", "pref", "display"] as const).includes(initialSection as SectionKey)
? (initialSection as SectionKey)
: "profile";
const [section, setSection] = useState<SectionKey>(normalizedInitial);
const [modal, setModal] = useState<"" | "avatar" | "logout">("");
const [template, setTemplate] = useState("pain");
const [duration, setDuration] = useState("60");
const [subtitle, setSubtitle] = useState("big-variety");
const [twoFactor, setTwoFactor] = useState(false);
const [notify, setNotify] = useState<Record<string, boolean>>({ "n-export": true, "n-fail": true, "n-quota": true, "n-login": true });
const avatarChar = useMemo(() => (user.username || "李").slice(0, 1).toUpperCase(), [user.username]);
return (
<section className="settings-page">
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">// 个人信息 · 偏好 · 通知 · 安全</span></div>
</div>
<div className="actions">
<button className="btn" type="button" disabled></button>
<button className="btn btn-primary" type="button" disabled>
<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>
</button>
</div>
</div>
<div className="settings-grid">
{/* 左侧 nav */}
<aside className="settings-nav" role="tablist" aria-label="设置分区">
{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>
))}
</div>
))}
<div className="nav-h" style={{ marginTop: 16 }}></div>
<button className="logout-pill" type="button" onClick={() => setModal("logout")}>
<LogOut />
<span>退</span>
</button>
</aside>
{/* 右侧内容 */}
<main>
{section === "profile" && (
<section className="pane" aria-label="个人信息">
<h3></h3>
<div className="pane-desc">// 头像、姓名、联系方式 · 邮箱用于接收通知</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<div className="avatar-edit">
<div className="av-big">{avatarChar}</div>
<div className="av-actions">
<button className="btn btn-sm" type="button" onClick={() => setModal("avatar")}></button>
<button className="btn btn-ghost btn-sm" type="button"></button>
</div>
</div>
</div>
</div>
<div className="form-row">
<div className="lbl"><span className="req">*</span></div>
<div className="val"><input className="input" defaultValue={user.username} /></div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<input className="input" defaultValue={user.email || ""} />
<button className="btn btn-ghost btn-sm" type="button"></button>
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<input className="input" defaultValue="138****8000" />
<button className="btn btn-ghost btn-sm" type="button"></button>
</div>
</div>
<div className="form-row">
<div className="lbl"><div className="lbl-sub">// 一人一团队</div></div>
<div className="val">
<span className="static">{team.name}</span>
<span className="role-tag"><span className="dot" /> · </span>
<a href="#team" className="row-link"> </a>
</div>
</div>
<div className="form-row">
<div className="lbl"> ID<div className="lbl-sub">// 不可改</div></div>
<div className="val"><span className="static mono">{user.id}</span></div>
</div>
</section>
)}
{section === "security" && (
<section className="pane" aria-label="安全">
<h3></h3>
<div className="pane-desc">// 登录密码、双因素、在用设备</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<span className="static mono"></span>
<span className="row-note" style={{ marginLeft: "auto" }}> 2026-04-12</span>
<button className="btn btn-sm" type="button" style={{ marginLeft: 10 }}></button>
</div>
</div>
<div className="form-row">
<div className="lbl"><div className="lbl-sub">// 推荐开启</div></div>
<div className="val">
<Switch checked={twoFactor} onChange={setTwoFactor} />
<span className="switch-note"> + Authenticator</span>
</div>
</div>
<h3 className="sub-head"></h3>
<div className="pane-desc">// 不在此列表上的设备登录会触发短信告警</div>
<div className="device-list">
{DEVICES.map((device) => (
<div className="device-row" key={device.name}>
<div className="ic">
{device.phone ? (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="2" width="12" height="20" rx="2" /><path d="M11 18h2" /></svg>
) : (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="4" width="18" height="14" rx="2" /><path d="M2 20h20" /></svg>
)}
</div>
<div>
<div className="nm">{device.name}{device.current ? <span className="tag-cur">CURRENT</span> : null}</div>
<div className="meta">{device.meta}</div>
</div>
<div className="spacer" />
{device.current
? <span className="row-note"></span>
: <button className="btn btn-ghost btn-sm" type="button">线</button>}
</div>
))}
</div>
<div style={{ marginTop: 14 }}>
<button className="btn" type="button">线</button>
</div>
</section>
)}
{section === "notify" && (
<section className="pane" aria-label="通知">
<h3></h3>
<div className="pane-desc">// 邮件、短信、站内提示开关</div>
{NOTIFY_ROWS.map((row) => (
<div className="form-row" key={row.key}>
<div className="lbl">{row.title}{row.sub ? <div className="lbl-sub">{row.sub}</div> : null}</div>
<div className="val">
<Switch checked={!!notify[row.key]} onChange={(next) => setNotify((prev) => ({ ...prev, [row.key]: next }))} />
<span className="switch-note">{row.channels}</span>
</div>
</div>
))}
</section>
)}
{section === "pref" && (
<section className="pane" aria-label="创作默认">
<h3></h3>
<div className="pane-desc">// 新建项目时的预填值,可在向导中改</div>
<div className="form-row row-top">
<div className="lbl"></div>
<div className="val">
<div className="pref-choices">
{TEMPLATE_CHOICES.map((choice) => (
<div
key={choice.v}
className={`pref-choice ${template === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setTemplate(choice.v)}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
</div>
))}
</div>
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<div className="duration-row">
{DURATIONS.map((d) => (
<span
key={d}
className={`dur-chip ${duration === d ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setDuration(d)}
>
{d}s
</span>
))}
</div>
<span className="switch-note" style={{ marginLeft: 10 }}>// 60s = 4 段 × 15s</span>
</div>
</div>
<div className="form-row row-top">
<div className="lbl"></div>
<div className="val">
<div className="pref-choices">
{SUBTITLE_CHOICES.map((choice) => (
<div
key={choice.v}
className={`pref-choice ${subtitle === choice.v ? "selected" : ""}`}
role="button"
tabIndex={0}
onClick={() => setSubtitle(choice.v)}
>
<div className="t">{choice.t}</div>
<div className="d">{choice.d}</div>
</div>
))}
</div>
</div>
</div>
<div className="form-row">
<div className="lbl"> BGM </div>
<div className="val">
<select className="select" defaultValue="kapian">
<option value="kapian"> Top10 </option>
<option value="emotion"> · /</option>
<option value="urban"> · </option>
<option value="none"> BGM</option>
</select>
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" defaultValue="fade">
<option value="none"></option>
<option value="fade"> · 0.3s</option>
<option value="slide"> · 0.3s</option>
<option value="zoom"> · 0.3s</option>
</select>
</div>
</div>
<div className="form-row">
<div className="lbl"><div className="lbl-sub">// VIP 可关闭</div></div>
<div className="val">
<Switch checked disabled />
<span className="switch-note"> · Airshelf</span>
<a href="#account" className="row-link"> VIP </a>
</div>
</div>
</section>
)}
{section === "display" && (
<section className="pane" aria-label="显示">
<h3></h3>
<div className="pane-desc">// 界面外观与语言</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" defaultValue="system">
<option value="system"></option>
<option value="light"></option>
<option value="dark" disabled>(V2)</option>
</select>
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" defaultValue="zh">
<option value="zh"></option>
<option value="en" disabled>English(V2)</option>
</select>
</div>
</div>
<div className="form-row">
<div className="lbl"></div>
<div className="val">
<select className="select" defaultValue="standard">
<option value="compact"></option>
<option value="standard"></option>
<option value="loose"></option>
</select>
</div>
</div>
</section>
)}
<div className="settings-foot">// Airshelf · v2.1 · build 20260521</div>
</main>
</div>
{/* 上传头像 modal · 仅视觉还原,无后端接入 */}
<TeamModal
open={modal === "avatar"}
title="上传头像"
subtitle="// 用于个人主页、评论与团队展示"
icon={<Upload size={16} />}
close={() => setModal("")}
footer={
<button className="btn btn-primary" type="button" onClick={() => setModal("")}>
<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>
使
</button>
}
>
<div className="av-up-preview-row">
<div className="av-up-preview">{avatarChar}</div>
<div className="av-up-preview-meta">
<div className="t"> · </div>
<div className="d">// 系统生成 · 取姓氏首字</div>
</div>
</div>
<div className="upload-zone" role="button" tabIndex={0} aria-label="点击或拖入图片上传">
<span 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 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
</span>
<div><strong></strong> · </div>
<span className="uz-hint">JPG / PNG / WebP · 2 MB · 256 × 256</span>
</div>
<div className="av-up-rules">
<div className="li"> 2 MB · 1:1 · </div>
<div className="li">,</div>
</div>
</TeamModal>
{/* 退出登录确认 modal · 仅视觉还原,无后端接入 */}
<TeamModal
open={modal === "logout"}
title="退出当前账号"
subtitle="// LOG OUT CURRENT SESSION"
icon={<LogOut size={16} />}
close={() => setModal("")}
footer={
<button className="btn btn-primary" type="button" onClick={() => setModal("")}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><path d="m16 17 5-5-5-5" /><path d="M21 12H9" /></svg>
退
</button>
}
>
<p className="logout-confirm-copy">退 Airshelf,使</p>
<div className="logout-confirm-points">
<div className="li"></div>
<div className="li">,线</div>
</div>
</TeamModal>
</section>
);
}