知识页的标题改成 15px/semibold 之后,任务和工单还是 text-sm font-medium, 同一个产品里扫列表时视觉层级不一致。这里统一成 `text-[15px] font-semibold leading-6`, 理由和知识页一样:标题是卡片上唯一要一眼看到的东西,不能和类型、分类、标签那排 chip 等重。
428 lines
15 KiB
TypeScript
428 lines
15 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { useParams, useSearchParams } from "react-router-dom";
|
|
import { AppShell } from "../components/AppShell";
|
|
import { TaskDetail } from "../components/TaskDetail";
|
|
import { ChevronRight, Plus, TicketIcon } from "../components/icons";
|
|
import {
|
|
Button,
|
|
EmptyState,
|
|
InlineAlert,
|
|
PageHeader,
|
|
Sheet,
|
|
SkeletonRows,
|
|
StatusPill,
|
|
useToast,
|
|
} from "../components/ui";
|
|
import { ApiError, api } from "../lib/client";
|
|
import { STATUS_LABEL } from "../lib/parse";
|
|
import type { Item, Member, Ticket } from "../lib/types";
|
|
|
|
const ROLES = [
|
|
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
|
|
{ key: "requested", label: "我发起的", desc: "你等别人的" },
|
|
{ key: "all", label: "全部", desc: "工作区所有工单" },
|
|
] as const;
|
|
|
|
const STATUS_FILTERS = [
|
|
{ key: "", label: "全部状态" },
|
|
{ key: "open", label: "待接收" },
|
|
{ key: "accepted", label: "待办理" },
|
|
{ key: "in_progress", label: "办理中" },
|
|
{ key: "pending_verify", label: "待验收" },
|
|
{ key: "done", label: "已完成" },
|
|
] as const;
|
|
|
|
export function TicketsPage() {
|
|
const { wsId = "" } = useParams();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [role, setRole] = useState<(typeof ROLES)[number]["key"]>("assigned");
|
|
const [status, setStatus] = useState<string>("");
|
|
const [tickets, setTickets] = useState<Ticket[]>([]);
|
|
const [cursor, setCursor] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
// 从通知点进来时带 ?open=<id>,落地即展开那一条
|
|
const [openId, setOpenId] = useState<string | null>(searchParams.get("open"));
|
|
const reqId = useRef(0);
|
|
const { push: toast } = useToast();
|
|
|
|
// 手工开单:这条路径不经过任何任务,所以人得自己选
|
|
const [members, setMembers] = useState<Member[]>([]);
|
|
const [creating, setCreating] = useState(false);
|
|
const [formTitle, setFormTitle] = useState("");
|
|
const [formBody, setFormBody] = useState("");
|
|
const [formAssignee, setFormAssignee] = useState("");
|
|
const [formPriority, setFormPriority] = useState(1);
|
|
const [formDue, setFormDue] = useState("");
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!wsId) return;
|
|
let alive = true;
|
|
void api
|
|
.get<{ items: Member[] }>(`/workspaces/${wsId}/members?limit=100`)
|
|
.then((page) => {
|
|
if (alive) setMembers(page.items);
|
|
})
|
|
.catch(() => {
|
|
/* 成员拉不到时,表单里就没人可选,但不该拦住列表 */
|
|
});
|
|
return () => {
|
|
alive = false;
|
|
};
|
|
}, [wsId]);
|
|
|
|
const load = useCallback(
|
|
async (nextCursor: string | null) => {
|
|
// 切 tab / 切状态会连发请求,慢的回来会盖掉新结果,用序号丢弃过期响应
|
|
const mine = ++reqId.current;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const qs = new URLSearchParams({ role });
|
|
if (status) qs.set("status", status);
|
|
if (nextCursor) qs.set("cursor", nextCursor);
|
|
const page = await api.get<{
|
|
tickets: Ticket[];
|
|
next_cursor: string | null;
|
|
has_more: boolean;
|
|
}>(`/workspaces/${wsId}/tickets?${qs.toString()}`);
|
|
if (mine !== reqId.current) return;
|
|
setTickets((prev) => (nextCursor ? [...prev, ...page.tickets] : page.tickets));
|
|
setCursor(page.has_more ? page.next_cursor : null);
|
|
} catch (err) {
|
|
if (mine !== reqId.current) return;
|
|
setError(err instanceof ApiError ? err.message : "工单加载失败");
|
|
} finally {
|
|
if (mine === reqId.current) setLoading(false);
|
|
}
|
|
},
|
|
[wsId, role, status],
|
|
);
|
|
|
|
useEffect(() => {
|
|
void load(null);
|
|
}, [load]);
|
|
|
|
async function quickAction(ticket: Ticket, action: string, body: Record<string, unknown> = {}) {
|
|
try {
|
|
await api.post(`/workspaces/${wsId}/items/${ticket.id}/ticket/${action}`, {
|
|
version: ticket.version,
|
|
...body,
|
|
});
|
|
void load(null);
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.code === 4009) {
|
|
setError("这条刚被别人改过,已为你刷新列表");
|
|
void load(null);
|
|
return;
|
|
}
|
|
setError(err instanceof ApiError ? err.message : "操作失败");
|
|
}
|
|
}
|
|
|
|
const currentRole = ROLES.find((r) => r.key === role);
|
|
|
|
async function createTicket() {
|
|
if (!formTitle.trim()) {
|
|
setError("工单得有个标题,别人才能知道要办什么");
|
|
return;
|
|
}
|
|
if (!formAssignee) {
|
|
setError("先选一个人——工单不指派就等于没人接");
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
const created = await api.post<Ticket>(`/workspaces/${wsId}/tickets`, {
|
|
title: formTitle.trim(),
|
|
body_md: formBody.trim() || null,
|
|
assignee_id: formAssignee,
|
|
priority: formPriority,
|
|
due_at: formDue ? new Date(formDue).toISOString() : null,
|
|
});
|
|
setCreating(false);
|
|
setFormTitle("");
|
|
setFormBody("");
|
|
setFormAssignee("");
|
|
setFormDue("");
|
|
setFormPriority(1);
|
|
setRole("requested");
|
|
toast("工单已开出,对方会收到通知", "success");
|
|
setOpenId(created.id);
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : "开单失败");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<AppShell wsId={wsId} active="tickets">
|
|
<PageHeader
|
|
title="工单"
|
|
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
|
|
eyebrow={<>需要别人搭把手的事,都在这里</>}
|
|
actions={
|
|
<>
|
|
<select
|
|
className="input h-9 w-36 text-sm"
|
|
value={status}
|
|
onChange={(e) => setStatus(e.target.value)}
|
|
aria-label="按状态筛选"
|
|
>
|
|
{STATUS_FILTERS.map((s) => (
|
|
<option key={s.key} value={s.key}>
|
|
{s.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<Button variant="primary" size="sm" onClick={() => setCreating(true)}>
|
|
<Plus size={14} />
|
|
新建工单
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<div className="mb-4 inline-flex items-center gap-0.5 rounded-xl border border-line-200 bg-paper-50 p-0.5">
|
|
{ROLES.map((r) => {
|
|
const active = role === r.key;
|
|
return (
|
|
<button
|
|
key={r.key}
|
|
onClick={() => setRole(r.key)}
|
|
title={r.desc}
|
|
className={`rounded-[9px] px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
active
|
|
? "bg-white text-ink-900 shadow-sm ring-1 ring-line-200"
|
|
: "text-ink-500 hover:text-ink-800"
|
|
}`}
|
|
>
|
|
{r.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4">
|
|
<InlineAlert>{error}</InlineAlert>
|
|
</div>
|
|
)}
|
|
|
|
{loading && tickets.length === 0 ? (
|
|
<div className="card overflow-hidden">
|
|
<SkeletonRows rows={3} />
|
|
</div>
|
|
) : tickets.length === 0 ? (
|
|
<div className="card">
|
|
<EmptyState
|
|
icon={<TicketIcon size={18} />}
|
|
title={role === "assigned" ? "没有等你的工单" : "这里还空着"}
|
|
hint={
|
|
role === "assigned"
|
|
? "别人把事交给你办时,这里会出现;要主动找人帮忙,去任务详情点「升级成工单」。"
|
|
: currentRole?.desc
|
|
}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<ul className="grid gap-2 md:grid-cols-2 2xl:grid-cols-3">
|
|
{tickets.map((t) => (
|
|
<li
|
|
key={t.id}
|
|
className="card group p-0 transition hover:border-line-300 hover:shadow-pop"
|
|
>
|
|
<div className="flex items-start justify-between gap-3 p-4">
|
|
<button className="min-w-0 flex-1 text-left" onClick={() => setOpenId(t.id)}>
|
|
<div className="flex items-center gap-2">
|
|
<StatusPill status={t.status} label={STATUS_LABEL[t.status]} />
|
|
{t.ticket.reject_count > 0 && (
|
|
<span className="pill bg-rust-50 text-rust-500">
|
|
退回 {t.ticket.reject_count} 次
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="mt-1.5 truncate text-[15px] font-semibold leading-6 text-ink-900 group-hover:text-primary-700">
|
|
{t.title}
|
|
</p>
|
|
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-2xs text-ink-400">
|
|
<span>更新 {new Date(t.updated_at).toLocaleString("zh-CN")}</span>
|
|
{t.due_at ? (
|
|
<span>截止 {new Date(t.due_at).toLocaleDateString("zh-CN")}</span>
|
|
) : null}
|
|
{t.tags.map((tag) => (
|
|
<span key={tag.id}>#{tag.name}</span>
|
|
))}
|
|
</p>
|
|
</button>
|
|
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
{t.status === "open" && (
|
|
<Button variant="primary" size="sm" onClick={() => void quickAction(t, "accept")}>
|
|
接收
|
|
</Button>
|
|
)}
|
|
{t.status === "accepted" && (
|
|
<Button variant="primary" size="sm" onClick={() => void quickAction(t, "start")}>
|
|
开始
|
|
</Button>
|
|
)}
|
|
{t.status === "pending_verify" && (
|
|
<Button
|
|
variant="primary"
|
|
size="sm"
|
|
onClick={() => void quickAction(t, "verify", { approve: true })}
|
|
>
|
|
通过
|
|
</Button>
|
|
)}
|
|
<Button variant="ghost" size="sm" onClick={() => setOpenId(t.id)}>
|
|
详情
|
|
<ChevronRight size={13} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{cursor && (
|
|
<div className="mt-4 text-center">
|
|
<Button variant="ghost" disabled={loading} onClick={() => void load(cursor)}>
|
|
{loading ? "加载中…" : "加载更多"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* 手工开单:不经过任务,直接把一件要别人帮忙的事开成工单 */}
|
|
<Sheet
|
|
open={creating}
|
|
onClose={() => setCreating(false)}
|
|
size="xl"
|
|
title="新建工单"
|
|
subtitle="不是从任务升级来的也可以——写清楚要办什么,指定一个人。"
|
|
footer={
|
|
<>
|
|
<Button variant="ghost" onClick={() => setCreating(false)} disabled={saving}>
|
|
取消
|
|
</Button>
|
|
<Button variant="primary" onClick={() => void createTicket()} disabled={saving}>
|
|
{saving ? "正在开单…" : "开单并通知对方"}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-5">
|
|
<div>
|
|
<label className="label" htmlFor="ticket-title">
|
|
要办什么
|
|
</label>
|
|
<input
|
|
id="ticket-title"
|
|
className="input"
|
|
value={formTitle}
|
|
onChange={(e) => setFormTitle(e.target.value)}
|
|
placeholder="一句能直接看懂的话,例如:核对 9 月发票与报销单"
|
|
maxLength={200}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label" htmlFor="ticket-body">
|
|
详细说明(可选)
|
|
</label>
|
|
<textarea
|
|
id="ticket-body"
|
|
className="input min-h-[96px] resize-y text-sm leading-6"
|
|
value={formBody}
|
|
onChange={(e) => setFormBody(e.target.value)}
|
|
placeholder="背景、已有的线索、验收标准…对方看到的就是这些"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<label className="label" htmlFor="ticket-assignee">
|
|
交给谁
|
|
</label>
|
|
<select
|
|
id="ticket-assignee"
|
|
className="input"
|
|
value={formAssignee}
|
|
onChange={(e) => setFormAssignee(e.target.value)}
|
|
>
|
|
<option value="">选择一个人…</option>
|
|
{members.map((m) => (
|
|
<option key={m.user_id} value={m.user_id}>
|
|
{m.nickname || m.user_id.slice(0, 8)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label" htmlFor="ticket-priority">
|
|
优先级
|
|
</label>
|
|
<select
|
|
id="ticket-priority"
|
|
className="input"
|
|
value={formPriority}
|
|
onChange={(e) => setFormPriority(Number(e.target.value))}
|
|
>
|
|
<option value={0}>无</option>
|
|
<option value={1}>低</option>
|
|
<option value={2}>中</option>
|
|
<option value={3}>高</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label" htmlFor="ticket-due">
|
|
希望什么时候办好(可选)
|
|
</label>
|
|
<input
|
|
id="ticket-due"
|
|
type="datetime-local"
|
|
className="input"
|
|
value={formDue}
|
|
onChange={(e) => setFormDue(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<p className="rounded-xl border border-line-200 bg-paper-200/60 px-3.5 py-2.5 text-2xs leading-5 text-ink-500">
|
|
开出后对方会收到通知,并按「待接 → 办理中 → 待验收 → 完成」推进。
|
|
手工开的单没有来源任务,验收通过后不会回流任何任务,但同样会沉淀一份知识草稿。
|
|
</p>
|
|
</div>
|
|
</Sheet>
|
|
|
|
{openId && (
|
|
<TaskDetail
|
|
wsId={wsId}
|
|
itemId={openId}
|
|
onClose={() => {
|
|
setOpenId(null);
|
|
if (searchParams.get("open")) setSearchParams({}, { replace: true });
|
|
}}
|
|
onChanged={(updated: Item) =>
|
|
setTickets((prev) => prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)))
|
|
}
|
|
onDeleted={(id) => {
|
|
setTickets((prev) => prev.filter((t) => t.id !== id));
|
|
setOpenId(null);
|
|
}}
|
|
onTicketChanged={() => {
|
|
void load(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</AppShell>
|
|
);
|
|
}
|