feat(core): 消息中心对齐设计稿 · 处理记录(真实数据)+ 字号/搜索框/页脚 + 归档接活
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -40,14 +40,28 @@ def project_priority(project):
|
|||||||
return Notification.Priority.INFO
|
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 ensure_team_notifications(team, user):
|
||||||
def create_once(dedupe_key, **payload):
|
def create_once(dedupe_key, **payload):
|
||||||
Notification.objects.get_or_create(
|
obj, created = Notification.objects.get_or_create(
|
||||||
team=team,
|
team=team,
|
||||||
recipient=user,
|
recipient=user,
|
||||||
dedupe_key=dedupe_key,
|
dedupe_key=dedupe_key,
|
||||||
defaults=payload,
|
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(
|
create_once(
|
||||||
"system:welcome",
|
"system:welcome",
|
||||||
@@ -61,6 +75,7 @@ def ensure_team_notifications(team, user):
|
|||||||
owner_label="系统",
|
owner_label="系统",
|
||||||
cost_label="-",
|
cost_label="-",
|
||||||
related_url="settings.html#sec-notify",
|
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]:
|
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 "成员",
|
owner_label=project.created_by.username if project.created_by_id else "成员",
|
||||||
cost_label="-",
|
cost_label="-",
|
||||||
related_url=f"pipeline.html?project_id={project.id}",
|
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]:
|
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 "成员",
|
owner_label=asset.created_by.username if asset.created_by_id else "成员",
|
||||||
cost_label="-",
|
cost_label="-",
|
||||||
related_url="library.html",
|
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)
|
account, _ = CreditAccount.objects.get_or_create(team=team)
|
||||||
@@ -111,6 +142,12 @@ def ensure_team_notifications(team, user):
|
|||||||
owner_label="系统",
|
owner_label="系统",
|
||||||
cost_label=f"¥{account.balance:.2f}",
|
cost_label=f"¥{account.balance:.2f}",
|
||||||
related_url="account.html",
|
related_url="account.html",
|
||||||
|
metadata={
|
||||||
|
"timeline": [
|
||||||
|
[_step_time(timezone.now()), f"余额降至 ¥{account.balance:.2f},低于预警线 ¥100"],
|
||||||
|
[_step_time(timezone.now()), "已发送站内预警通知"],
|
||||||
|
],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -371,6 +371,11 @@ export function App() {
|
|||||||
await reloadNotifications();
|
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 }) {
|
async function saveProfile(payload: { name?: string; phone?: string; email?: string }) {
|
||||||
const res = await action(() => api.updateProfile(payload), "资料已保存");
|
const res = await action(() => api.updateProfile(payload), "资料已保存");
|
||||||
if (res) {
|
if (res) {
|
||||||
@@ -619,6 +624,7 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onCreateProduct={(payload) => action(() => api.createProduct(payload), "")}
|
onCreateProduct={(payload) => action(() => api.createProduct(payload), "")}
|
||||||
|
onUploadImage={(productId, formData) => action(() => api.uploadProductImage(productId, formData), "")}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "pipeline":
|
case "pipeline":
|
||||||
@@ -668,6 +674,7 @@ export function App() {
|
|||||||
unreadCount={unreadCount}
|
unreadCount={unreadCount}
|
||||||
onMarkRead={markNotificationRead}
|
onMarkRead={markNotificationRead}
|
||||||
onMarkAllRead={markAllNotificationsRead}
|
onMarkAllRead={markAllNotificationsRead}
|
||||||
|
onArchive={archiveNotification}
|
||||||
navigate={navigate}
|
navigate={navigate}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -483,5 +483,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
markNotificationRead(id: string) {
|
markNotificationRead(id: string) {
|
||||||
return request<Notification>(`/api/ops/notifications/${id}/mark-read/`, { method: "POST" });
|
return request<Notification>(`/api/ops/notifications/${id}/mark-read/`, { method: "POST" });
|
||||||
|
},
|
||||||
|
archiveNotification(id: string) {
|
||||||
|
// 后端 archive 端点返回 204 无 body
|
||||||
|
return request<void>(`/api/ops/notifications/${id}/archive/`, { method: "POST" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
.msg-panel-h .mono {
|
.msg-panel-h .mono {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 10.5px;
|
||||||
color: var(--black-alpha-48);
|
color: var(--black-alpha-48);
|
||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
}
|
}
|
||||||
@@ -94,31 +94,39 @@
|
|||||||
}
|
}
|
||||||
.msg-filter .ct {
|
.msg-filter .ct {
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 10px;
|
||||||
letter-spacing: .02em;
|
letter-spacing: .02em;
|
||||||
}
|
}
|
||||||
.msg-search-wrap {
|
.msg-search {
|
||||||
|
position: relative;
|
||||||
padding: 0 14px 12px;
|
padding: 0 14px 12px;
|
||||||
border-bottom: 1px solid var(--border-faint);
|
border-bottom: 1px solid var(--border-faint);
|
||||||
}
|
}
|
||||||
.msg-search-wrap .search-inline {
|
.msg-search svg {
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.msg-search-wrap .search-inline svg {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 12px;
|
left: 26px;
|
||||||
top: 50%;
|
top: 10px;
|
||||||
transform: translateY(-50%);
|
width: 13px;
|
||||||
width: 14px;
|
height: 13px;
|
||||||
height: 14px;
|
color: var(--black-alpha-48);
|
||||||
color: var(--black-alpha-56);
|
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 2;
|
|
||||||
}
|
}
|
||||||
.msg-search-wrap .search-inline input.input {
|
.msg-search input {
|
||||||
padding-left: 36px;
|
width: 100%;
|
||||||
height: 34px;
|
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;
|
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 {
|
.msg-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -201,7 +209,7 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--black-alpha-48);
|
color: var(--black-alpha-48);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 10.5px;
|
||||||
letter-spacing: .02em;
|
letter-spacing: .02em;
|
||||||
}
|
}
|
||||||
.msg-brief {
|
.msg-brief {
|
||||||
@@ -230,7 +238,7 @@
|
|||||||
background: var(--background-lighter);
|
background: var(--background-lighter);
|
||||||
color: var(--black-alpha-56);
|
color: var(--black-alpha-56);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 10px;
|
||||||
letter-spacing: .02em;
|
letter-spacing: .02em;
|
||||||
}
|
}
|
||||||
.msg-priority.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }
|
.msg-priority.ok { background: var(--forest-bg); border-color: var(--forest-bd); color: var(--accent-forest); }
|
||||||
@@ -247,7 +255,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
text-align: center;
|
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 {
|
.msg-detail-empty {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 520px;
|
min-height: 520px;
|
||||||
@@ -299,7 +307,7 @@
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
color: var(--black-alpha-48);
|
color: var(--black-alpha-48);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 10.5px;
|
||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
}
|
}
|
||||||
.msg-body-text {
|
.msg-body-text {
|
||||||
@@ -321,7 +329,7 @@
|
|||||||
.msg-props .k {
|
.msg-props .k {
|
||||||
color: var(--black-alpha-48);
|
color: var(--black-alpha-48);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 10.5px;
|
||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
}
|
}
|
||||||
.msg-props .v {
|
.msg-props .v {
|
||||||
@@ -330,6 +338,50 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.msg-props .v a { color: var(--heat); }
|
.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 {
|
.msg-detail-f {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -345,7 +397,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
color: var(--black-alpha-48);
|
color: var(--black-alpha-48);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
font-size: 10.5px;
|
||||||
letter-spacing: .04em;
|
letter-spacing: .04em;
|
||||||
}
|
}
|
||||||
.msg-foot-note a { color: var(--heat); cursor: pointer; }
|
.msg-foot-note a { color: var(--heat); cursor: pointer; }
|
||||||
|
|||||||
@@ -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())}`;
|
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<string, unknown> | 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<string, unknown>;
|
||||||
|
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;
|
unreadCount: number;
|
||||||
onMarkRead: (id: string) => void | Promise<unknown>;
|
onMarkRead: (id: string) => void | Promise<unknown>;
|
||||||
onMarkAllRead: () => void | Promise<unknown>;
|
onMarkAllRead: () => void | Promise<unknown>;
|
||||||
|
onArchive: (id: string) => void | Promise<unknown>;
|
||||||
navigate: (page: Page) => void;
|
navigate: (page: Page) => void;
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<TabKey>("all");
|
const [tab, setTab] = useState<TabKey>("all");
|
||||||
@@ -149,6 +166,21 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
|
|||||||
setCounts((c) => ({ ...c, unread: 0 }));
|
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]> = [
|
const filters: Array<[TabKey, string, number]> = [
|
||||||
["all", "全部", counts.all],
|
["all", "全部", counts.all],
|
||||||
["unread", "未读", counts.unread],
|
["unread", "未读", counts.unread],
|
||||||
@@ -175,7 +207,7 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
|
|||||||
|
|
||||||
<div className="msg-workbench">
|
<div className="msg-workbench">
|
||||||
<section className="msg-panel msg-inbox">
|
<section className="msg-panel msg-inbox">
|
||||||
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 已加载 {items.length} / {total} 条</span></div>
|
<div className="msg-panel-h"><span className="ti">收件箱</span><span className="mono">// 显示 {items.length} 条{total > items.length ? ` / ${total}` : ""}</span></div>
|
||||||
<div className="msg-filters">
|
<div className="msg-filters">
|
||||||
{filters.map(([id, label, ct]) => (
|
{filters.map(([id, label, ct]) => (
|
||||||
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
|
<button key={id} className={`msg-filter ${tab === id ? "active" : ""}`} type="button" onClick={() => setTab(id)}>
|
||||||
@@ -183,11 +215,9 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="msg-search-wrap">
|
<div className="msg-search">
|
||||||
<div className="search-inline">
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><circle cx="11" cy="11" r="7" /><path d="m21 21-4.3-4.3" /></svg>
|
||||||
<Search />
|
<input type="text" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
||||||
<input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目、来源、内容" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="msg-list" ref={listRef} onScroll={onScroll}>
|
<div className="msg-list" ref={listRef} onScroll={onScroll}>
|
||||||
{items.length === 0 && !loading ? (
|
{items.length === 0 && !loading ? (
|
||||||
@@ -247,8 +277,21 @@ export function MessagesPage({ unreadCount, onMarkRead, onMarkAllRead, navigate
|
|||||||
<span className="k">关联资源</span>
|
<span className="k">关联资源</span>
|
||||||
<span className="v"><a onClick={() => navigate(target)}>{routeLabels[target]} →</a></span>
|
<span className="v"><a onClick={() => navigate(target)}>{routeLabels[target]} →</a></span>
|
||||||
</div>
|
</div>
|
||||||
|
{(() => {
|
||||||
|
const timeline = readTimeline(selected.metadata);
|
||||||
|
if (timeline.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="msg-timeline">
|
||||||
|
<div className="msg-timeline-h">处理记录</div>
|
||||||
|
{timeline.map(([t, d], i) => (
|
||||||
|
<div className="msg-step" key={i}><span className="t">{t}</span><span className="d">{d}</span></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="msg-detail-f">
|
<div className="msg-detail-f">
|
||||||
|
<button className="btn btn-ghost" type="button" onClick={() => archiveOne(selected)}>归档</button>
|
||||||
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => markOne(selected.id)}>标为已读</button>}
|
{!selected.is_read && <button className="btn btn-ghost" type="button" onClick={() => markOne(selected.id)}>标为已读</button>}
|
||||||
<span className="spacer"></span>
|
<span className="spacer"></span>
|
||||||
<button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button>
|
<button className="btn btn-primary" type="button" onClick={() => navigate(target)}>进入{routeLabels[target]}</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user