Files
DadaoTask/web/src/components/TaskDetail.tsx
T
dadao 5be55fdccb 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。
2026-10-28 18:37:35 +08:00

636 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}