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:
dadao
2026-10-28 18:37:35 +08:00
parent f683436689
commit 5be55fdccb
21 changed files with 2520 additions and 968 deletions
+303 -165
View File
@@ -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>
);
}