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 = { 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(null); const [title, setTitle] = useState(""); const [body, setBody] = useState(""); const [tags, setTags] = useState(""); const [due, setDue] = useState(""); const [comments, setComments] = useState([]); const [draft, setDraft] = useState(""); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); const [busy, setBusy] = useState(false); const [role, setRole] = useState("member"); const [members, setMembers] = useState([]); 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(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>(`/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, label: string): Promise { if (!item) return false; setBusy(true); setError(null); try { const updated = await api.patch(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(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(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(`${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 = {}) { if (!item) return; setBusy(true); setError(null); try { const updated = await api.post( `${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(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 (
{buttons .filter((b) => b.show) .map((b) => ( ))}
); } const meta = item ? `${item.kind === "ticket" ? "工单" : item.kind === "knowledge" ? "知识" : "任务"} · ${ STATUS_LABEL[item.status] } · 版本 ${item.version}` : "加载中…"; return ( {item.updated_at ? `最后更新 ${new Date(item.updated_at).toLocaleString("zh-CN")}` : ""}
{/* 工单不提供删除:它该走"取消",否则原任务永远解不开 */} {item.kind !== "ticket" && ( )}
) : null } > {!item ? (

{error ?? "加载中…"}

) : (
{ticket ? (
需求人 {members.find((m) => m.user_id === ticket.requester_id)?.nickname ?? "—"} 办理人 {members.find((m) => m.user_id === ticket.assignee_id)?.nickname ?? "—"} {ticket.reject_count > 0 ? ( 退回 {ticket.reject_count} 次 ) : null} {ticket.parent_item_id ? ( 由任务升级而来 ) : null}
{ticket.resolution_md ? (

验收结论

{ticket.resolution_md}

) : null}
setNote(e.target.value)} /> {actionButtons()}
) : null}
setTitle(e.target.value)} onBlur={() => void saveTitle()} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} />