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:
co-authored by
Claude Opus 4.8
parent
25bf3293df
commit
78fd7ee13d
@@ -1,10 +1,22 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Bell, Search } from "lucide-react";
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Bell, Clapperboard, CreditCard, Info, Search, Users } from "lucide-react";
|
||||
import type { Notification } from "../types";
|
||||
import type { Page } from "./route-config";
|
||||
import { routeLabels } from "./route-config";
|
||||
|
||||
// 通知的 related_url(.html 风格)→ 应用内 Page
|
||||
type TabKey = "all" | "unread" | "task" | "team" | "billing" | "system";
|
||||
|
||||
const PRI_LABEL: Record<string, string> = { ok: "已完成", warn: "需关注", err: "风险", info: "更新" };
|
||||
const ZH_TYPE: Record<string, string> = { all: "全部", unread: "未读", task: "任务", team: "团队", billing: "计费", system: "系统" };
|
||||
|
||||
function typeIcon(type: string): ReactNode {
|
||||
if (type === "task") return <Clapperboard size={14} />;
|
||||
if (type === "team") return <Users size={14} />;
|
||||
if (type === "billing") return <CreditCard size={14} />;
|
||||
return <Info size={14} />;
|
||||
}
|
||||
|
||||
// 通知 related_url(.html 风格)→ 应用内 Page
|
||||
function targetPage(n: Notification): Page {
|
||||
const url = n.related_url || "";
|
||||
if (url.includes("pipeline")) return "pipeline";
|
||||
@@ -12,9 +24,23 @@ function targetPage(n: Notification): Page {
|
||||
if (url.includes("library")) return "library";
|
||||
if (url.includes("settings")) return "settingsNotify";
|
||||
if (url.includes("product")) return "products";
|
||||
if (url.includes("team")) return "team";
|
||||
return "dashboard";
|
||||
}
|
||||
|
||||
function fmtTime(iso: string): string {
|
||||
const diff = Math.round((Date.now() - new Date(iso).getTime()) / 60000);
|
||||
if (diff < 1) return "刚刚";
|
||||
if (diff < 60) return `${diff}m`;
|
||||
if (diff < 1440) return `${Math.floor(diff / 60)}h`;
|
||||
return `${Math.floor(diff / 1440)}d`;
|
||||
}
|
||||
function fmtFull(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const z = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAllRead, navigate }: {
|
||||
notifications: Notification[];
|
||||
unreadCount: number;
|
||||
@@ -22,77 +48,148 @@ export function MessagesPage({ notifications, unreadCount, onMarkRead, onMarkAll
|
||||
onMarkAllRead: () => void | Promise<unknown>;
|
||||
navigate: (page: Page) => void;
|
||||
}) {
|
||||
const [tab, setTab] = useState<TabKey>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string>("");
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
|
||||
const visible = notifications.filter(
|
||||
(item) => !query || `${item.title} ${item.brief}`.toLowerCase().includes(query.toLowerCase())
|
||||
const counts = useMemo(
|
||||
() => ({
|
||||
all: notifications.length,
|
||||
unread: notifications.filter((n) => !n.is_read).length,
|
||||
task: notifications.filter((n) => n.notification_type === "task").length,
|
||||
team: notifications.filter((n) => n.notification_type === "team").length,
|
||||
billing: notifications.filter((n) => n.notification_type === "billing").length,
|
||||
system: notifications.filter((n) => n.notification_type === "system").length
|
||||
}),
|
||||
[notifications]
|
||||
);
|
||||
const selected = notifications.find((item) => item.id === selectedId) || visible[0] || notifications[0] || null;
|
||||
|
||||
// 默认选中第一条(不自动标已读;仅显式点选才标)
|
||||
useEffect(() => {
|
||||
if (!selectedId && notifications.length) setSelectedId(notifications[0].id);
|
||||
}, [selectedId, notifications]);
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return notifications.filter((n) => {
|
||||
if (tab === "unread" && n.is_read) return false;
|
||||
if (!["all", "unread"].includes(tab) && n.notification_type !== tab) return false;
|
||||
if (q && ![n.title, n.brief, n.body, n.source, n.project_name, n.stage].join(" ").toLowerCase().includes(q)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [notifications, tab, query]);
|
||||
|
||||
function selectItem(item: Notification) {
|
||||
setSelectedId(item.id);
|
||||
if (!item.is_read) void onMarkRead(item.id);
|
||||
const selected = notifications.find((n) => n.id === selectedId) || visible[0] || notifications[0] || null;
|
||||
|
||||
function selectItem(n: Notification) {
|
||||
setSelectedId(n.id);
|
||||
if (!n.is_read) void onMarkRead(n.id);
|
||||
}
|
||||
|
||||
const filters: Array<[TabKey, string, number]> = [
|
||||
["all", "全部", counts.all],
|
||||
["unread", "未读", counts.unread],
|
||||
["task", "任务", counts.task],
|
||||
["team", "团队", counts.team],
|
||||
["billing", "计费", counts.billing],
|
||||
["system", "系统", counts.system]
|
||||
];
|
||||
|
||||
const target = selected ? targetPage(selected) : "dashboard";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="msg-page">
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<h1>消息中心</h1>
|
||||
<div className="sub">
|
||||
<span className="mono">// {notifications.length} 条总计 · {unreadCount} 未读</span> 任务提醒 · 团队协作 · 计费与系统公告
|
||||
</div>
|
||||
<div className="sub"><span className="mono">// {counts.unread} 条未读 · {notifications.length} 条总计</span> 任务提醒 · 团队协作 · 计费与系统公告</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button className="btn" type="button" onClick={() => void onMarkAllRead()} disabled={unreadCount === 0}>全部已读</button>
|
||||
<div className="msg-head-actions">
|
||||
<button className="btn" type="button" onClick={() => void onMarkAllRead()} disabled={unreadCount === 0}>全部标已读</button>
|
||||
<button className="btn" type="button" onClick={() => navigate("settingsNotify")}>通知设置</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="msg-workbench">
|
||||
<section className="msg-panel msg-inbox">
|
||||
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 显示 {visible.length} 条</span></div>
|
||||
<div className="msg-search"><Search size={14} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" /></div>
|
||||
<div className="msg-list">
|
||||
{visible.length === 0 && (
|
||||
<div className="msg-empty" style={{ padding: "24px 16px", color: "var(--black-alpha-48)", fontSize: "12px", fontFamily: "var(--font-mono)" }}>// 暂无消息</div>
|
||||
)}
|
||||
{visible.map((item) => (
|
||||
<button className={`msg-item ${selected?.id === item.id ? "active" : ""} ${item.is_read ? "" : "unread"}`} type="button" key={item.id} onClick={() => selectItem(item)}>
|
||||
<span className={`msg-type-ic ${item.notification_type}`}><Bell size={13} /></span>
|
||||
<span className="msg-item-main"><span className="msg-item-title">{item.title}</span><span className="msg-brief">{item.brief}</span></span>
|
||||
<div className="msg-filters">
|
||||
{filters.map(([id, label, ct]) => (
|
||||
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
|
||||
{label}<span className="ct">{ct}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="msg-search">
|
||||
<Search />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||
</div>
|
||||
<div className="msg-list">
|
||||
{visible.length === 0 ? (
|
||||
<div className="msg-empty"><Search /><span>没有符合条件的消息</span></div>
|
||||
) : (
|
||||
visible.map((n) => (
|
||||
<button key={n.id} className={`msg-item ${selected?.id === n.id ? "active" : ""} ${n.is_read ? "read" : ""}`} type="button" onClick={() => selectItem(n)}>
|
||||
<span className={`msg-type-ic ${n.notification_type}`}>{typeIcon(n.notification_type)}</span>
|
||||
<span className="msg-item-main">
|
||||
<span className="msg-item-row">
|
||||
<span className="msg-dot"></span>
|
||||
<span className="msg-item-title">{n.title}</span>
|
||||
<span className="msg-time">{fmtTime(n.created_at)}</span>
|
||||
</span>
|
||||
<span className="msg-brief">{n.brief}</span>
|
||||
<span className="msg-item-foot">
|
||||
<span className={`msg-priority ${n.priority}`}>{PRI_LABEL[n.priority] || "更新"}</span>
|
||||
{n.project_name ? <span className="msg-priority">{n.project_name}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="msg-panel msg-detail">
|
||||
{selected ? (
|
||||
<>
|
||||
<div className="msg-detail-body">
|
||||
<div className="msg-detail-top">
|
||||
<span className={`msg-type-ic ${selected.notification_type}`}><Bell size={15} /></span>
|
||||
<span className={`msg-type-ic ${selected.notification_type}`}>{typeIcon(selected.notification_type)}</span>
|
||||
<div className="msg-detail-title">
|
||||
<h2>{selected.title}</h2>
|
||||
<div className="meta"><span>{selected.source || selected.notification_type}</span>{selected.stage ? <span> · {selected.stage}</span> : null}</div>
|
||||
<div className="meta"><span>{selected.source || ZH_TYPE[selected.notification_type]}</span><span>// {ZH_TYPE[selected.notification_type]}</span><span>{fmtFull(selected.created_at)}</span></div>
|
||||
</div>
|
||||
<span className={`msg-priority ${selected.priority}`}>{PRI_LABEL[selected.priority] || "更新"}</span>
|
||||
</div>
|
||||
<p className="msg-body-text">{selected.body || selected.brief}</p>
|
||||
<div className="msg-props"><span className="k">关联资源</span><span className="v">{routeLabels[target]}</span></div>
|
||||
<div className="msg-props">
|
||||
{([
|
||||
["来源", selected.source || "-"],
|
||||
["类别", ZH_TYPE[selected.notification_type] || selected.notification_type],
|
||||
["项目", selected.project_name || "-"],
|
||||
["阶段", selected.stage || "-"],
|
||||
["负责人", selected.owner_label || "-"],
|
||||
["费用", selected.cost_label || "-"],
|
||||
["时间", fmtFull(selected.created_at)]
|
||||
] as Array<[string, string]>).flatMap(([k, v]) => [
|
||||
<span className="k" key={`${k}-k`}>{k}</span>,
|
||||
<span className="v" key={`${k}-v`}>{v}</span>
|
||||
])}
|
||||
<span className="k">关联资源</span>
|
||||
<span className="v"><a onClick={() => navigate(target)}>{routeLabels[target]} →</a></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="msg-detail-f">
|
||||
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => void onMarkRead(selected.id)}>标为已读</button>}
|
||||
<span className="spacer"></span>
|
||||
<button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button>
|
||||
</div>
|
||||
<div className="msg-detail-f"><span className="spacer" /><button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button></div>
|
||||
</>
|
||||
) : (
|
||||
<div className="msg-detail-body"><p className="msg-body-text">// 选择左侧一条消息查看详情</p></div>
|
||||
<div className="msg-detail-empty"><div className="ic"><Bell /></div><div>暂无消息</div></div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
|
||||
<div className="msg-foot-note">
|
||||
<span>// 消息保留 90 天 · 高风险任务会同时进入工作台队列</span>
|
||||
<a onClick={() => navigate("settingsNotify")}>管理通知策略 →</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user