用户反馈"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。
636 lines
24 KiB
TypeScript
636 lines
24 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
||
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;
|
||
body_md: string;
|
||
author_id: string;
|
||
created_at: string;
|
||
}
|
||
|
||
type TicketAction = "accept" | "start" | "submit" | "cancel" | "reassign" | "verify";
|
||
|
||
interface Props {
|
||
wsId: string;
|
||
itemId: string;
|
||
onClose: () => void;
|
||
onChanged: (item: Item) => void;
|
||
onDeleted: (id: string) => void;
|
||
/** 工单状态变化后通知父级刷新角标 */
|
||
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);
|
||
const pad = (n: number) => String(n).padStart(2, "0");
|
||
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,
|
||
onClose,
|
||
onChanged,
|
||
onDeleted,
|
||
onTicketChanged,
|
||
}: Props) {
|
||
const { user } = useAuth();
|
||
const [item, setItem] = useState<ItemDetail | null>(null);
|
||
const [title, setTitle] = useState("");
|
||
const [body, setBody] = useState("");
|
||
const [tags, setTags] = useState("");
|
||
const [due, setDue] = useState("");
|
||
const [comments, setComments] = useState<Comment[]>([]);
|
||
const [draft, setDraft] = useState("");
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [notice, setNotice] = useState<string | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const [role, setRole] = useState<Role>("member");
|
||
const [members, setMembers] = useState<Member[]>([]);
|
||
const [promoteTo, setPromoteTo] = useState("");
|
||
const [note, setNote] = useState("");
|
||
|
||
const base = `/workspaces/${wsId}/items/${itemId}`;
|
||
const isAdmin = role === "owner" || role === "admin";
|
||
|
||
const hydrate = useCallback((next: ItemDetail) => {
|
||
setItem(next);
|
||
setTitle(next.title);
|
||
setBody(next.body_md);
|
||
setTags(next.tags.map((t) => t.name).join(" "));
|
||
setDue(toLocalInput(next.due_at));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
void (async () => {
|
||
try {
|
||
const detail = await api.get<ItemDetail>(base);
|
||
if (alive) hydrate(detail);
|
||
const page = await api.get<{ items: Comment[] }>(`${base}/comments`);
|
||
if (alive) setComments(page.items);
|
||
const ws = await api.get<{ role: Role }>(`/workspaces/${wsId}`);
|
||
if (alive) setRole(ws.role);
|
||
const roster = await api.get<Page<Member>>(`/workspaces/${wsId}/members`);
|
||
if (alive) {
|
||
setMembers(roster.items);
|
||
setPromoteTo(roster.items.find((m) => m.user_id !== user?.id)?.user_id ?? "");
|
||
}
|
||
} catch (err) {
|
||
if (alive) setError(err instanceof ApiError ? err.message : "加载失败");
|
||
}
|
||
})();
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [base, hydrate, wsId, user?.id]);
|
||
|
||
async function patch(values: Record<string, unknown>, label: string): Promise<boolean> {
|
||
if (!item) return false;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const updated = await api.patch<Item>(base, { ...values, version: item.version });
|
||
onChanged(updated);
|
||
hydrate({ ...(item as ItemDetail), ...updated, activities: item.activities });
|
||
void refreshActivities(updated);
|
||
return true;
|
||
} catch (err) {
|
||
if (err instanceof ApiError && err.code === 4009) {
|
||
setError(`${label}没保存:这条刚被别人改过,已刷新为最新内容`);
|
||
const fresh = await api.get<ItemDetail>(base);
|
||
hydrate(fresh);
|
||
} else {
|
||
setError(err instanceof ApiError ? err.message : `${label}没保存`);
|
||
}
|
||
return false;
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function refreshActivities(updated: Item) {
|
||
try {
|
||
const fresh = await api.get<ItemDetail>(base);
|
||
setItem({ ...fresh, ...updated, activities: fresh.activities });
|
||
setTitle(updated.title);
|
||
setBody(updated.body_md);
|
||
setTags(updated.tags.map((t) => t.name).join(" "));
|
||
setDue(toLocalInput(updated.due_at));
|
||
} catch {
|
||
/* 刷新事件流失败不影响已保存的结果 */
|
||
}
|
||
}
|
||
|
||
async function saveTitle() {
|
||
if (!item || !title.trim() || title.trim() === item.title) return;
|
||
await patch({ title: title.trim() }, "标题");
|
||
}
|
||
|
||
async function saveBody() {
|
||
if (!item || body === item.body_md) return;
|
||
await patch({ body_md: body }, "备注");
|
||
}
|
||
|
||
async function saveTags() {
|
||
if (!item) return;
|
||
const next = tags.split(/[\s,,]+/).filter(Boolean);
|
||
const current = item.tags.map((t) => t.name);
|
||
if (next.join() === current.join()) return;
|
||
await patch({ tag_names: next }, "标签");
|
||
}
|
||
|
||
async function saveDue(value: string) {
|
||
if (!item) return;
|
||
if (!value) {
|
||
await patch({ clear_due_at: true }, "截止时间");
|
||
return;
|
||
}
|
||
await patch({ due_at: new Date(value).toISOString() }, "截止时间");
|
||
}
|
||
|
||
async function addComment() {
|
||
const text = draft.trim();
|
||
if (!text) return;
|
||
try {
|
||
const created = await api.post<Comment>(`${base}/comments`, { body_md: text });
|
||
setComments((prev) => [...prev, created]);
|
||
setDraft("");
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.message : "评论失败");
|
||
}
|
||
}
|
||
|
||
async function remove() {
|
||
if (!item) return;
|
||
if (!window.confirm("删除这条任务?可以在「已删除」视图里找回内容。")) return;
|
||
try {
|
||
await api.del(`${base}?version=${item.version}`);
|
||
onDeleted(item.id);
|
||
onClose();
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.message : "删除失败");
|
||
}
|
||
}
|
||
|
||
/** 任务 -> 工单。成功后原任务变「被阻塞」,这条抽屉继续显示任务本身。 */
|
||
async function promote() {
|
||
if (!item || !promoteTo) return;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const result = await api.post<{ task: Item; ticket: Item }>(`${base}/promote`, {
|
||
assignee_id: promoteTo,
|
||
version: item.version,
|
||
});
|
||
onChanged(result.task);
|
||
hydrate({ ...(item as ItemDetail), ...result.task, activities: item.activities });
|
||
void refreshActivities(result.task);
|
||
const who = members.find((m) => m.user_id === promoteTo)?.nickname ?? "同事";
|
||
setNotice(`已把工单交给 ${who},这条任务现在处于「被阻塞」;对方验收通过后会自动回来。`);
|
||
onTicketChanged?.();
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.message : "升级失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
/** 把这件事整理成知识草稿。草稿默认待核对,不直接发布。 */
|
||
async function toKnowledge() {
|
||
if (!item) return;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
await api.post(`${base}/knowledge?doc_type=${item.kind === "ticket" ? "postmortem" : "howto"}`);
|
||
setNotice("已生成知识草稿。它现在在「知识 → 待核对」里,核对无误后再发布,才会进入团队检索。");
|
||
} catch (err) {
|
||
if (err instanceof ApiError && err.code === 7002) {
|
||
setNotice("这件事已经有一份知识草稿了,去「知识」页找它。");
|
||
} else {
|
||
setError(err instanceof ApiError ? err.message : "生成失败");
|
||
}
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function ticketAction(action: TicketAction, extra: Record<string, unknown> = {}) {
|
||
if (!item) return;
|
||
setBusy(true);
|
||
setError(null);
|
||
try {
|
||
const updated = await api.post<Item & { ticket: TicketInfo }>(
|
||
`${base}/ticket/${action}`,
|
||
{ note, version: item.version, ...extra },
|
||
);
|
||
onChanged(updated);
|
||
hydrate({ ...(item as ItemDetail), ...updated, activities: item.activities });
|
||
void refreshActivities(updated);
|
||
setNote("");
|
||
onTicketChanged?.();
|
||
} catch (err) {
|
||
if (err instanceof ApiError && err.code === 4009) {
|
||
setError("这条工单刚被改过,已刷新为最新内容");
|
||
hydrate(await api.get<ItemDetail>(base));
|
||
} else {
|
||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||
}
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
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() {
|
||
const buttons: { key: TicketAction | "approve" | "reject"; label: string; show: boolean }[] = [
|
||
{ 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) && pendingVerify },
|
||
{ key: "reject", label: "驳回", show: (isRequester || isAdmin) && pendingVerify },
|
||
{
|
||
key: "cancel",
|
||
label: "取消工单",
|
||
show:
|
||
(isRequester || isAssignee || isAdmin) &&
|
||
["open", "accepted", "in_progress", "pending_verify"].includes(item?.status ?? ""),
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{buttons
|
||
.filter((b) => b.show)
|
||
.map((b) => (
|
||
<Button
|
||
key={b.key}
|
||
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 });
|
||
else if (b.key === "reject") void ticketAction("verify", { approve: false });
|
||
else void ticketAction(b.key);
|
||
}}
|
||
>
|
||
{b.label}
|
||
</Button>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const meta = item
|
||
? `${item.kind === "ticket" ? "工单" : item.kind === "knowledge" ? "知识" : "任务"} · ${
|
||
STATUS_LABEL[item.status]
|
||
} · 版本 ${item.version}`
|
||
: "加载中…";
|
||
|
||
return (
|
||
<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>
|
||
</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>
|
||
) : 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-flush text-lg font-medium"
|
||
value={title}
|
||
onChange={(e) => setTitle(e.target.value)}
|
||
onBlur={() => void saveTitle()}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<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"
|
||
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="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>
|
||
|
||
<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" ? (
|
||
<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 ? (
|
||
<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}
|
||
|
||
<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="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.5 flex gap-2">
|
||
<input
|
||
className="input flex-1"
|
||
placeholder="补充一句进展…"
|
||
value={draft}
|
||
onChange={(e) => setDraft(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") void addComment();
|
||
}}
|
||
/>
|
||
<Button variant="primary" disabled={!draft.trim()} onClick={() => void addComment()}>
|
||
发表
|
||
</Button>
|
||
</div>
|
||
</section>
|
||
|
||
<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} 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
|
||
? ` ${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>
|
||
</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>
|
||
);
|
||
}
|