From b714976d388a59b610f672fb6717e8b90d048e1f Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 17 Jun 2026 20:13:32 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E6=B6=88=E6=81=AF=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E5=AF=B9=E9=BD=90=E8=AE=BE=E8=AE=A1=E7=A8=BF=20=C2=B7?= =?UTF-8?q?=20=E5=A4=84=E7=90=86=E8=AE=B0=E5=BD=95(=E7=9C=9F=E5=AE=9E?= =?UTF-8?q?=E6=95=B0=E6=8D=AE)+=20=E5=AD=97=E5=8F=B7/=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E6=A1=86/=E9=A1=B5=E8=84=9A=20+=20=E5=BD=92=E6=A1=A3=E6=8E=A5?= =?UTF-8?q?=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ops/views.py: 通知 metadata 写入真实 timeline(项目/资产/计费/欢迎,真时间戳),create_once 给旧通知补齐 timeline - messages.tsx/css: 新增「处理记录」区块(读 metadata.timeline)+ 补回 .msg-timeline/.msg-step/.msg-log 样式;搜索框结构回设计稿 .msg-search;mono 字号对齐 10.5/10px;空态图标色 token 化 - 归档按钮接活:api.archiveNotification + App 装配 onArchive + 详情页脚归档(后端落 archived_at + 本地乐观移除/扣计数) Co-Authored-By: Claude Opus 4.8 --- core/backend/apps/ops/views.py | 43 +++++++++++- core/frontend/src/App.tsx | 7 ++ core/frontend/src/api.ts | 4 ++ core/frontend/src/messages-page.css | 96 +++++++++++++++++++++------ core/frontend/src/routes/messages.tsx | 57 ++++++++++++++-- 5 files changed, 175 insertions(+), 32 deletions(-) diff --git a/core/backend/apps/ops/views.py b/core/backend/apps/ops/views.py index 596cc06..e68158f 100644 --- a/core/backend/apps/ops/views.py +++ b/core/backend/apps/ops/views.py @@ -40,14 +40,28 @@ def project_priority(project): return Notification.Priority.INFO +def _step_time(dt): + """处理记录单步时间:今天只显示 HH:MM,跨天加 MM-DD(对齐设计稿的 mono 时间列)。""" + if dt is None: + return "" + local = timezone.localtime(dt) + now = timezone.localtime() + return local.strftime("%H:%M") if local.date() == now.date() else local.strftime("%m-%d %H:%M") + + def ensure_team_notifications(team, user): def create_once(dedupe_key, **payload): - Notification.objects.get_or_create( + obj, created = Notification.objects.get_or_create( team=team, recipient=user, dedupe_key=dedupe_key, defaults=payload, ) + # 已存在的旧通知补齐「处理记录」timeline(早期建的没存),让历史消息也能显示处理记录 + timeline = (payload.get("metadata") or {}).get("timeline") + if not created and timeline and not (obj.metadata or {}).get("timeline"): + obj.metadata = {**(obj.metadata or {}), "timeline": timeline} + obj.save(update_fields=["metadata", "updated_at"]) create_once( "system:welcome", @@ -61,6 +75,7 @@ def ensure_team_notifications(team, user): owner_label="系统", cost_label="-", related_url="settings.html#sec-notify", + metadata={"timeline": [[_step_time(timezone.now()), "团队接入 AirShelf,真实消息中心已启用"]]}, ) for project in Project.objects.filter(team=team).select_related("product", "created_by").order_by("-updated_at")[:5]: @@ -78,7 +93,15 @@ def ensure_team_notifications(team, user): owner_label=project.created_by.username if project.created_by_id else "成员", cost_label="-", related_url=f"pipeline.html?project_id={project.id}", - metadata={"status": project.status, "current_stage": project.current_stage}, + metadata={ + "status": project.status, + "current_stage": project.current_stage, + "timeline": [ + [_step_time(project.created_at), f"项目「{project.name}」创建"], + [_step_time(project.updated_at), f"推进至 {project_stage_label(project)}"], + [_step_time(project.updated_at), f"当前状态 · {project.get_status_display()}"], + ], + }, ) for asset in Asset.objects.filter(team=team).select_related("created_by").order_by("-updated_at")[:3]: @@ -94,7 +117,15 @@ def ensure_team_notifications(team, user): owner_label=asset.created_by.username if asset.created_by_id else "成员", cost_label="-", related_url="library.html", - metadata={"asset_id": str(asset.id), "category": asset.category, "asset_type": asset.asset_type}, + metadata={ + "asset_id": str(asset.id), + "category": asset.category, + "asset_type": asset.asset_type, + "timeline": [ + [_step_time(asset.created_at), f"{asset.get_category_display()} · {asset.get_asset_type_display()} 生成"], + [_step_time(asset.updated_at), "通过基础审核,写入资产库"], + ], + }, ) account, _ = CreditAccount.objects.get_or_create(team=team) @@ -111,6 +142,12 @@ def ensure_team_notifications(team, user): owner_label="系统", cost_label=f"¥{account.balance:.2f}", related_url="account.html", + metadata={ + "timeline": [ + [_step_time(timezone.now()), f"余额降至 ¥{account.balance:.2f},低于预警线 ¥100"], + [_step_time(timezone.now()), "已发送站内预警通知"], + ], + }, ) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 1543eb9..24efecb 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -371,6 +371,11 @@ export function App() { await reloadNotifications(); } + async function archiveNotification(id: string) { + await api.archiveNotification(id).catch(() => undefined); + await reloadNotifications(); + } + async function saveProfile(payload: { name?: string; phone?: string; email?: string }) { const res = await action(() => api.updateProfile(payload), "资料已保存"); if (res) { @@ -619,6 +624,7 @@ export function App() { } }} onCreateProduct={(payload) => action(() => api.createProduct(payload), "")} + onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")} /> ); case "pipeline": @@ -668,6 +674,7 @@ export function App() { unreadCount={unreadCount} onMarkRead={markNotificationRead} onMarkAllRead={markAllNotificationsRead} + onArchive={archiveNotification} navigate={navigate} /> ); diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 040a363..b2c6e3a 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -483,5 +483,9 @@ export const api = { }, markNotificationRead(id: string) { return request(`/api/ops/notifications/${id}/mark-read/`, { method: "POST" }); + }, + archiveNotification(id: string) { + // 后端 archive 端点返回 204 无 body + return request(`/api/ops/notifications/${id}/archive/`, { method: "POST" }); } }; diff --git a/core/frontend/src/messages-page.css b/core/frontend/src/messages-page.css index a78ecf4..a5b9771 100644 --- a/core/frontend/src/messages-page.css +++ b/core/frontend/src/messages-page.css @@ -56,7 +56,7 @@ .msg-panel-h .mono { margin-left: auto; font-family: var(--font-mono); - font-size: 12px; + font-size: 10.5px; color: var(--black-alpha-48); letter-spacing: .04em; } @@ -94,31 +94,39 @@ } .msg-filter .ct { font-family: var(--font-mono); - font-size: 12px; + font-size: 10px; letter-spacing: .02em; } -.msg-search-wrap { +.msg-search { + position: relative; padding: 0 14px 12px; border-bottom: 1px solid var(--border-faint); } -.msg-search-wrap .search-inline { - position: relative; -} -.msg-search-wrap .search-inline svg { +.msg-search svg { position: absolute; - left: 12px; - top: 50%; - transform: translateY(-50%); - width: 14px; - height: 14px; - color: var(--black-alpha-56); + left: 26px; + top: 10px; + width: 13px; + height: 13px; + color: var(--black-alpha-48); pointer-events: none; - z-index: 2; } -.msg-search-wrap .search-inline input.input { - padding-left: 36px; +.msg-search input { + width: 100%; height: 34px; + padding: 0 12px 0 32px; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + background: var(--background-lighter); + color: var(--accent-black); + font-family: inherit; font-size: 13px; + outline: none; +} +.msg-search input:focus { + background: var(--surface); + border-color: var(--heat-40); + box-shadow: inset 0 0 0 1px var(--heat-40); } .msg-list { flex: 1; @@ -201,7 +209,7 @@ flex-shrink: 0; color: var(--black-alpha-48); font-family: var(--font-mono); - font-size: 12px; + font-size: 10.5px; letter-spacing: .02em; } .msg-brief { @@ -230,7 +238,7 @@ background: var(--background-lighter); color: var(--black-alpha-56); font-family: var(--font-mono); - font-size: 12px; + font-size: 10px; letter-spacing: .02em; } .msg-priority.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); } @@ -247,7 +255,7 @@ font-size: 13px; text-align: center; } -.msg-empty svg { width: 24px; height: 24px; color: var(--ink-3); } +.msg-empty svg { width: 24px; height: 24px; color: var(--black-alpha-40); } .msg-detail-empty { flex: 1; min-height: 520px; @@ -299,7 +307,7 @@ margin-top: 8px; color: var(--black-alpha-48); font-family: var(--font-mono); - font-size: 12px; + font-size: 10.5px; letter-spacing: .04em; } .msg-body-text { @@ -321,7 +329,7 @@ .msg-props .k { color: var(--black-alpha-48); font-family: var(--font-mono); - font-size: 12px; + font-size: 10.5px; letter-spacing: .04em; } .msg-props .v { @@ -330,6 +338,50 @@ font-size: 13px; } .msg-props .v a { color: var(--heat); } +.msg-timeline { + margin-top: 18px; + padding: 16px; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); +} +.msg-timeline-h { + margin-bottom: 12px; + color: var(--accent-black); + font-size: 13px; + font-weight: 600; +} +.msg-step { + display: grid; + grid-template-columns: 68px 1fr; + gap: 12px; + padding: 10px 0; + border-top: 1px solid var(--border-faint); +} +.msg-step:first-of-type { border-top: 0; padding-top: 0; } +.msg-step .t { + color: var(--black-alpha-48); + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .02em; +} +.msg-step .d { + color: var(--black-alpha-72); + font-size: 12.5px; + line-height: 1.55; +} +.msg-log { + margin-top: 14px; + padding: 12px 14px; + border: 1px solid var(--border-faint); + border-radius: var(--r-md); + background: var(--accent-black); + color: var(--accent-white); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.7; + letter-spacing: .02em; + white-space: pre-wrap; +} .msg-detail-f { display: flex; align-items: center; @@ -345,7 +397,7 @@ gap: 8px; color: var(--black-alpha-48); font-family: var(--font-mono); - font-size: 12px; + font-size: 10.5px; letter-spacing: .04em; } .msg-foot-note a { color: var(--heat); cursor: pointer; } diff --git a/core/frontend/src/routes/messages.tsx b/core/frontend/src/routes/messages.tsx index dc721a0..775c7af 100644 --- a/core/frontend/src/routes/messages.tsx +++ b/core/frontend/src/routes/messages.tsx @@ -53,10 +53,27 @@ function fmtFull(iso: string): string { return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`; } -export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate }: { +// 处理记录:从 notification.metadata.timeline 解析成 [时间, 描述] 列表;兼容数组对 / 对象两种存法 +function readTimeline(meta: Record | undefined): Array<[string, string]> { + const raw = meta?.timeline; + if (!Array.isArray(raw)) return []; + return raw + .map((step): [string, string] => { + if (Array.isArray(step)) return [String(step[0] ?? ""), String(step[1] ?? "")]; + if (step && typeof step === "object") { + const o = step as Record; + return [String(o.t ?? o.time ?? ""), String(o.d ?? o.desc ?? "")]; + } + return ["", String(step ?? "")]; + }) + .filter(([t, d]) => t || d); +} + +export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, onArchive, navigate }: { unreadCount: number; onMarkRead: (id: string) => void | Promise; onMarkAllRead: () => void | Promise; + onArchive: (id: string) => void | Promise; navigate: (page: Page) => void; }) { const [tab, setTab] = useState("all"); @@ -149,6 +166,21 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate setCounts((c) => ({ ...c, unread: 0 })); } + // 归档:后端落 archived_at(下次拉取即不再返回)+ 本地乐观移除该条,并扣减总数/分类/未读计数 + function archiveOne(n: Notification) { + void onArchive(n.id); + setItems((prev) => prev.filter((x) => x.id !== n.id)); + setTotal((t) => Math.max(0, t - 1)); + setSelectedId(""); // 落回 items[0] + setCounts((c) => { + const next: NotificationTypeCounts = { ...c, all: Math.max(0, c.all - 1) }; + if (!n.is_read) next.unread = Math.max(0, c.unread - 1); + const tk = n.notification_type as keyof NotificationTypeCounts; + if (typeof next[tk] === "number") next[tk] = Math.max(0, next[tk] - 1); + return next; + }); + } + const filters: Array<[TabKey, string, number]> = [ ["all", "全部", counts.all], ["unread", "未读", counts.unread], @@ -175,7 +207,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
-
收件箱// 已加载 {items.length} / {total} 条
+
收件箱// 显示 {items.length} 条{total > items.length ? ` / ${total}` : ""}
{filters.map(([id, label, ct]) => ( ))}
-
-
- - setQuery(event.target.value)} placeholder="搜索项目、来源、内容" /> -
+
+ + setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
{items.length === 0 && !loading ? ( @@ -247,8 +277,21 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate 关联资源 navigate(target)}>{routeLabels[target]} →
+ {(() => { + const timeline = readTimeline(selected.metadata); + if (timeline.length === 0) return null; + return ( +
+
处理记录
+ {timeline.map(([t, d], i) => ( +
{t}{d}
+ ))} +
+ ); + })()}
+ {!selected.is_read && }