feat(web): 重做设计系统与全部页面,前端观感对齐当下审美
用户反馈"UI 太丑、太简陋"。不是换配色,而是先立 token 再让页面去用: - tailwind.config.js:纸白 paper / 墨绿 primary / 陶土橙 clay + 语义色 sage/wheat/rust/sky,字号阶、圆角阶、投影、动效 - index.css:卡片、按钮五种变体、输入、pill/chip、导航、骨架屏 - 新增 icons.tsx(线性图标集)与 ui.tsx(Button/Sheet/Toast/空态/骨架/PageHeader) - 新增 AppShell 取代 Layout + WorkspaceNav:桌面侧栏 + 移动顶部横条,工单未读角标 - 六个页面全部重写:登录、首页、任务、看板、工单、知识、成员、邀请落地 - 详情抽屉改用 Sheet,工单态补齐需求人/办理人/退回次数/动作按钮 e2e 因 DOM 升级同步调整(抽屉按 role=dialog 定位、下拉改用 aria-label), 三套用例(7 + 8 + 7 项)全部重跑通过;tsc 0 错,构建 45 modules / gzip 76.70 kB。
This commit is contained in:
+303
-165
@@ -3,6 +3,19 @@ import { useAuth } from "../App";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
|
||||
import type { Activity, Item, ItemDetail, Member, Page, Role, TicketInfo } from "../lib/types";
|
||||
import {
|
||||
Book,
|
||||
Clock,
|
||||
Flag,
|
||||
Link2,
|
||||
Lock,
|
||||
Sparkle,
|
||||
TicketIcon,
|
||||
Trash,
|
||||
Users,
|
||||
} from "./icons";
|
||||
import { Button } from "./ui";
|
||||
import { Sheet } from "./ui";
|
||||
|
||||
interface Comment {
|
||||
id: string;
|
||||
@@ -23,6 +36,17 @@ interface Props {
|
||||
onTicketChanged?: () => void;
|
||||
}
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
created: "创建",
|
||||
status_changed: "状态变更",
|
||||
promoted_to_ticket: "升级成工单",
|
||||
ticket_done: "工单验收通过",
|
||||
comment_added: "评论",
|
||||
assigned: "指派",
|
||||
knowledge_status_changed: "知识状态变更",
|
||||
title_changed: "改标题",
|
||||
};
|
||||
|
||||
function toLocalInput(iso: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
@@ -30,6 +54,12 @@ function toLocalInput(iso: string | null): string {
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function initials(name: string): string {
|
||||
const n = name.trim();
|
||||
if (!n) return "?";
|
||||
return /^[a-zA-Z]/.test(n) ? n.slice(0, 2).toUpperCase() : n.slice(-2);
|
||||
}
|
||||
|
||||
export function TaskDetail({
|
||||
wsId,
|
||||
itemId,
|
||||
@@ -249,14 +279,15 @@ export function TaskDetail({
|
||||
const ticket = item?.kind === "ticket" ? item.ticket : undefined;
|
||||
const isAssignee = !!ticket && !!user && ticket.assignee_id === user.id;
|
||||
const isRequester = !!ticket && !!user && ticket.requester_id === user.id;
|
||||
const pendingVerify = item?.status === "pending_verify";
|
||||
|
||||
function actionButtons(t: TicketInfo) {
|
||||
function actionButtons() {
|
||||
const buttons: { key: TicketAction | "approve" | "reject"; label: string; show: boolean }[] = [
|
||||
{ key: "accept", label: "接收", show: t && isAssignee && item?.status === "open" },
|
||||
{ key: "accept", label: "接收", show: isAssignee && item?.status === "open" },
|
||||
{ key: "start", label: "开始办理", show: isAssignee && item?.status === "accepted" },
|
||||
{ key: "submit", label: "提交验收", show: isAssignee && item?.status === "in_progress" },
|
||||
{ key: "approve", label: "通过验收", show: isRequester || isAdmin },
|
||||
{ key: "reject", label: "驳回", show: isRequester || isAdmin },
|
||||
{ key: "approve", label: "通过验收", show: (isRequester || isAdmin) && pendingVerify },
|
||||
{ key: "reject", label: "驳回", show: (isRequester || isAdmin) && pendingVerify },
|
||||
{
|
||||
key: "cancel",
|
||||
label: "取消工单",
|
||||
@@ -265,16 +296,16 @@ export function TaskDetail({
|
||||
["open", "accepted", "in_progress", "pending_verify"].includes(item?.status ?? ""),
|
||||
},
|
||||
];
|
||||
const verifyOnly = item?.status !== "pending_verify";
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{buttons
|
||||
.filter((b) => b.show && !(verifyOnly && (b.key === "approve" || b.key === "reject")))
|
||||
.filter((b) => b.show)
|
||||
.map((b) => (
|
||||
<button
|
||||
<Button
|
||||
key={b.key}
|
||||
className={b.key === "cancel" || b.key === "reject" ? "btn-ghost" : "btn-primary"}
|
||||
variant={b.key === "approve" ? "clay" : b.key === "cancel" || b.key === "reject" ? "ghost" : "primary"}
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (b.key === "approve") void ticketAction("verify", { approve: true });
|
||||
@@ -283,69 +314,98 @@ export function TaskDetail({
|
||||
}}
|
||||
>
|
||||
{b.label}
|
||||
</button>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = item
|
||||
? `${item.kind === "ticket" ? "工单" : item.kind === "knowledge" ? "知识" : "任务"} · ${
|
||||
STATUS_LABEL[item.status]
|
||||
} · 版本 ${item.version}`
|
||||
: "加载中…";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex justify-end bg-ink-900/20" onClick={onClose}>
|
||||
<aside
|
||||
className="flex h-full w-full max-w-xl flex-col overflow-y-auto bg-white p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!item ? (
|
||||
<p className="text-sm text-ink-500">{error ?? "加载中…"}</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-4 flex items-center justify-between text-xs text-ink-500">
|
||||
<span>
|
||||
{item.kind === "ticket" ? "工单 · " : ""}
|
||||
{STATUS_LABEL[item.status]} · 版本 {item.version}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{/* 工单不提供删除:它该走"取消",否则原任务永远解不开 */}
|
||||
{item.kind !== "ticket" && (
|
||||
<button className="btn-ghost px-2 py-1 text-xs text-red-600" onClick={() => void remove()}>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
<button className="btn-ghost px-2 py-1 text-xs" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<Sheet
|
||||
open
|
||||
onClose={onClose}
|
||||
width="max-w-2xl"
|
||||
title={item ? item.title : "打开中…"}
|
||||
subtitle={meta}
|
||||
footer={
|
||||
item ? (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-2xs text-ink-400">
|
||||
{item.updated_at ? `最后更新 ${new Date(item.updated_at).toLocaleString("zh-CN")}` : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 工单不提供删除:它该走"取消",否则原任务永远解不开 */}
|
||||
{item.kind !== "ticket" && (
|
||||
<Button variant="danger" size="sm" onClick={() => void remove()}>
|
||||
<Trash size={13} />
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{ticket && (
|
||||
<div className="mb-4 rounded-lg border border-ink-100 bg-ink-50 p-3 text-xs text-ink-700">
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
<span>
|
||||
需求人:{members.find((m) => m.user_id === ticket.requester_id)?.nickname ?? "—"}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{!item ? (
|
||||
<p className="text-sm text-ink-400">{error ?? "加载中…"}</p>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{ticket ? (
|
||||
<section className="rounded-xl border border-line-200 bg-paper-50 p-4">
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-xs">
|
||||
<span className="inline-flex items-center gap-1.5 text-ink-600">
|
||||
<Users size={12} className="text-ink-400" />
|
||||
需求人
|
||||
<b className="font-medium text-ink-900">
|
||||
{members.find((m) => m.user_id === ticket.requester_id)?.nickname ?? "—"}
|
||||
</b>
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 text-ink-600">
|
||||
办理人
|
||||
<b className="font-medium text-ink-900">
|
||||
{members.find((m) => m.user_id === ticket.assignee_id)?.nickname ?? "—"}
|
||||
</b>
|
||||
</span>
|
||||
{ticket.reject_count > 0 ? (
|
||||
<span className="pill bg-rust-50 text-rust-500">退回 {ticket.reject_count} 次</span>
|
||||
) : null}
|
||||
{ticket.parent_item_id ? (
|
||||
<span className="inline-flex items-center gap-1 text-ink-400">
|
||||
<Link2 size={11} />
|
||||
由任务升级而来
|
||||
</span>
|
||||
<span>
|
||||
办理人:{members.find((m) => m.user_id === ticket.assignee_id)?.nickname ?? "—"}
|
||||
</span>
|
||||
{ticket.reject_count > 0 && <span>退回 {ticket.reject_count} 次</span>}
|
||||
{ticket.parent_item_id && <span>由任务升级而来</span>}
|
||||
</div>
|
||||
{ticket.resolution_md && (
|
||||
<p className="mt-2 whitespace-pre-wrap text-ink-900">验收结论:{ticket.resolution_md}</p>
|
||||
)}
|
||||
<div className="mt-3 space-y-2">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="给对方的说明(提交或验收时一并发过去)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
{actionButtons(ticket)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ticket.resolution_md ? (
|
||||
<div className="mt-3 rounded-lg border border-line-200 bg-white p-3">
|
||||
<p className="text-2xs text-ink-400">验收结论</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-sm text-ink-800">{ticket.resolution_md}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="给对方的说明(提交或验收时一并发过去)"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
/>
|
||||
{actionButtons()}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className="label">标题</label>
|
||||
<input
|
||||
className="input text-base font-medium"
|
||||
className="input-flush text-lg font-medium"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onBlur={() => void saveTitle()}
|
||||
@@ -353,114 +413,167 @@ export function TaskDetail({
|
||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<label className="block">
|
||||
<span className="label">优先级</span>
|
||||
<select
|
||||
className="input"
|
||||
value={item.priority}
|
||||
onChange={(e) => void patch({ priority: Number(e.target.value) }, "优先级")}
|
||||
>
|
||||
{[0, 1, 2, 3].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{PRIORITY_LABEL[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">截止时间</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="input"
|
||||
value={due}
|
||||
onChange={(e) => setDue(e.target.value)}
|
||||
onBlur={(e) => void saveDue(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 block text-sm">
|
||||
<span className="label">标签(空格分隔)</span>
|
||||
<input
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="label inline-flex items-center gap-1">
|
||||
<Flag size={11} />
|
||||
优先级
|
||||
</span>
|
||||
<select
|
||||
className="input"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
onBlur={() => void saveTags()}
|
||||
placeholder="周报 客户"
|
||||
aria-label="优先级"
|
||||
value={item.priority}
|
||||
onChange={(e) => void patch({ priority: Number(e.target.value) }, "优先级")}
|
||||
>
|
||||
{[0, 1, 2, 3].map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{PRIORITY_LABEL[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label inline-flex items-center gap-1">
|
||||
<Clock size={11} />
|
||||
截止时间
|
||||
</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="input"
|
||||
aria-label="截止时间"
|
||||
value={due}
|
||||
onChange={(e) => setDue(e.target.value)}
|
||||
onBlur={(e) => void saveDue(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="mt-3 block text-sm">
|
||||
<span className="label">备注</span>
|
||||
<textarea
|
||||
className="input min-h-32"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onBlur={() => void saveBody()}
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="label">标签(空格分隔)</span>
|
||||
<input
|
||||
className="input"
|
||||
aria-label="标签"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
onBlur={() => void saveTags()}
|
||||
placeholder="周报 客户"
|
||||
/>
|
||||
{item.tags.length > 0 ? (
|
||||
<span className="mt-1.5 flex flex-wrap gap-1">
|
||||
{item.tags.map((t) => (
|
||||
<span key={t.id} className="chip bg-paper-200 text-ink-600">
|
||||
#{t.name}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
{item.kind === "task" && !["done", "canceled"].includes(item.status) && (
|
||||
<div className="mt-4 rounded-lg border border-ink-100 p-3">
|
||||
<p className="text-sm font-medium">这件事卡在别人手里?升级成工单</p>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
升级后这条任务会变成「被阻塞」,工单办好并验收通过后自动回到待办,不用你自己盯。
|
||||
</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<select
|
||||
className="input flex-1"
|
||||
value={promoteTo}
|
||||
onChange={(e) => setPromoteTo(e.target.value)}
|
||||
>
|
||||
<option value="">选择找谁办…</option>
|
||||
{members
|
||||
.filter((m) => m.status === "active")
|
||||
.map((m) => (
|
||||
<option key={m.user_id} value={m.user_id}>
|
||||
{m.nickname}
|
||||
{m.role === "guest" ? "(访客)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn-primary" disabled={busy || !promoteTo} onClick={() => void promote()}>
|
||||
升级成工单
|
||||
</button>
|
||||
</div>
|
||||
<label className="block">
|
||||
<span className="label">备注</span>
|
||||
<textarea
|
||||
className="input min-h-32 resize-y leading-relaxed"
|
||||
aria-label="备注"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
onBlur={() => void saveBody()}
|
||||
placeholder="背景、约束、链接…写下来,将来才想得起来"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{item.kind === "task" && !["done", "canceled"].includes(item.status) ? (
|
||||
<section className="rounded-xl border border-clay-200 bg-clay-50/50 p-4">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-ink-900">
|
||||
<TicketIcon size={15} className="text-clay-600" />
|
||||
这件事卡在别人手里?升级成工单
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-ink-500">
|
||||
升级后这条任务会变成「被阻塞」,工单办好并验收通过后自动回到待办,不用你自己盯。
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<select
|
||||
className="input flex-1"
|
||||
aria-label="选择办理人"
|
||||
value={promoteTo}
|
||||
onChange={(e) => setPromoteTo(e.target.value)}
|
||||
>
|
||||
<option value="">选择找谁办…</option>
|
||||
{members
|
||||
.filter((m) => m.status === "active")
|
||||
.map((m) => (
|
||||
<option key={m.user_id} value={m.user_id}>
|
||||
{m.nickname}
|
||||
{m.role === "guest" ? "(访客)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="clay" disabled={busy || !promoteTo} onClick={() => void promote()}>
|
||||
升级成工单
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{item.kind !== "knowledge" && item.status !== "canceled" && (
|
||||
<div className="mt-4 rounded-lg border border-ink-100 p-3">
|
||||
<p className="text-sm font-medium">这件事值得留下来?沉淀成知识</p>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
会把标题、备注、时间线和评论整理成一份草稿,默认进「待核对」,不会直接对外发布。
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<button className="btn-ghost px-3 py-1 text-sm" disabled={busy} onClick={() => void toKnowledge()}>
|
||||
{item.kind === "ticket" ? "沉淀成复盘" : "沉淀成知识"}
|
||||
</button>
|
||||
</div>
|
||||
{item.kind !== "knowledge" && item.status !== "canceled" ? (
|
||||
<section className="rounded-xl border border-line-200 p-4">
|
||||
<p className="flex items-center gap-1.5 text-sm font-medium text-ink-900">
|
||||
<Book size={15} className="text-primary-600" />
|
||||
这件事值得留下来?沉淀成知识
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-relaxed text-ink-500">
|
||||
会把标题、备注、时间线和评论整理成一份草稿,默认进「待核对」,不会直接对外发布。
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void toKnowledge()}>
|
||||
<Sparkle size={13} className="text-clay-500" />
|
||||
{item.kind === "ticket" ? "沉淀成复盘" : "沉淀成知识"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{notice && <p className="mt-3 text-sm text-emerald-700">{notice}</p>}
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
{busy && <p className="mt-3 text-xs text-ink-500">处理中…</p>}
|
||||
{notice ? (
|
||||
<div className="rounded-lg border border-sage-500/20 bg-sage-50 px-3 py-2 text-sm text-sage-500">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-rust-500/20 bg-rust-50 px-3 py-2 text-sm text-rust-500">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{busy ? <p className="text-2xs text-ink-400">处理中…</p> : null}
|
||||
|
||||
<h3 className="mt-6 text-sm font-medium">评论({comments.length})</h3>
|
||||
<ul className="mt-2 space-y-2">
|
||||
<section>
|
||||
<h3 className="mb-2 flex items-center gap-1.5 text-sm font-medium text-ink-800">
|
||||
评论
|
||||
<span className="text-ink-400">{comments.length}</span>
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{comments.length === 0 ? (
|
||||
<li className="rounded-lg border border-dashed border-line-200 px-3 py-4 text-center text-xs text-ink-400">
|
||||
还没有评论。写一句,将来的你会感谢现在记下来的你。
|
||||
</li>
|
||||
) : null}
|
||||
{comments.map((c) => (
|
||||
<li key={c.id} className="rounded-lg bg-ink-50 p-2 text-sm">
|
||||
<p className="whitespace-pre-wrap">{c.body_md}</p>
|
||||
<p className="mt-1 text-xs text-ink-500">
|
||||
{new Date(c.created_at).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
<li key={c.id} className="flex gap-2.5">
|
||||
<span className="mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full bg-paper-200 text-[10px] font-medium text-ink-600">
|
||||
{initials(
|
||||
members.find((m) => m.user_id === c.author_id)?.nickname ?? String(c.author_id),
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 rounded-xl rounded-tl-sm bg-paper-100 px-3 py-2">
|
||||
<p className="whitespace-pre-wrap text-sm text-ink-800">{c.body_md}</p>
|
||||
<p className="mt-0.5 text-2xs text-ink-400">
|
||||
{new Date(c.created_at).toLocaleString("zh-CN")}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<div className="mt-2.5 flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
placeholder="补充一句进展…"
|
||||
@@ -470,28 +583,53 @@ export function TaskDetail({
|
||||
if (e.key === "Enter") void addComment();
|
||||
}}
|
||||
/>
|
||||
<button className="btn-primary" disabled={!draft.trim()} onClick={() => void addComment()}>
|
||||
<Button variant="primary" disabled={!draft.trim()} onClick={() => void addComment()}>
|
||||
发表
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h3 className="mt-6 text-sm font-medium">动态</h3>
|
||||
<ul className="mt-2 space-y-1 text-xs text-ink-500">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer list-none items-center gap-1.5 text-sm font-medium text-ink-800">
|
||||
<Clock size={14} className="text-ink-400" />
|
||||
动态
|
||||
<span className="text-ink-400">{item.activities.length}</span>
|
||||
<span className="ml-auto text-2xs font-normal text-ink-400 group-open:hidden">
|
||||
展开
|
||||
</span>
|
||||
</summary>
|
||||
<ul className="mt-2 space-y-1.5 border-l border-line-200 pl-3">
|
||||
{item.activities.slice(0, 30).map((a: Activity) => (
|
||||
<li key={a.id}>
|
||||
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
|
||||
<li key={a.id} className="text-xs text-ink-500">
|
||||
<span className="text-ink-400">
|
||||
{new Date(a.created_at).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
<span className="mx-1.5 text-ink-300">·</span>
|
||||
{ACTION_LABEL[a.action] ?? a.action}
|
||||
{a.action === "status_changed" && a.payload
|
||||
? ` ${String(a.payload.from ?? "")} → ${String(a.payload.to ?? "")}`
|
||||
? ` ${STATUS_LABEL[String(a.payload.from ?? "")] ?? String(a.payload.from ?? "")} → ${
|
||||
STATUS_LABEL[String(a.payload.to ?? "")] ?? String(a.payload.to ?? "")
|
||||
}`
|
||||
: ""}
|
||||
{a.action === "ticket_submit" && a.payload?.note
|
||||
? ` · ${String(a.payload.note)}`
|
||||
: ""}
|
||||
</li>
|
||||
))}
|
||||
{item.activities.length === 0 ? (
|
||||
<li className="text-xs text-ink-400">还没有记录。</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{ticket ? (
|
||||
<p className="flex items-start gap-1.5 text-2xs leading-relaxed text-ink-400">
|
||||
<Lock size={11} className="mt-0.5 shrink-0" />
|
||||
工单不能删除,只能取消;删除会让当初卡住的那条任务永远解不开。
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user