用户反馈"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。
301 lines
12 KiB
TypeScript
301 lines
12 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import { useParams } from "react-router-dom";
|
||
import { AppShell } from "../components/AppShell";
|
||
import { TaskDetail } from "../components/TaskDetail";
|
||
import { Check, Columns } from "../components/icons";
|
||
import { Button, EmptyState, InlineAlert, PageHeader, StatusPill } from "../components/ui";
|
||
import { ApiError, api } from "../lib/client";
|
||
import { PRIORITY_LABEL, STATUS_LABEL, canTransition } from "../lib/parse";
|
||
import type { Item, ItemStatus } from "../lib/types";
|
||
|
||
const COLUMNS: { key: ItemStatus; label: string; dot: string; tint: string }[] = [
|
||
{ key: "todo", label: "待办", dot: "bg-sky-500", tint: "bg-sky-50/40" },
|
||
{ key: "doing", label: "进行中", dot: "bg-primary-500", tint: "bg-primary-50/40" },
|
||
{ key: "blocked", label: "被阻塞", dot: "bg-wheat-500", tint: "bg-wheat-50/40" },
|
||
{ key: "done", label: "已完成", dot: "bg-sage-500", tint: "bg-sage-50/40" },
|
||
];
|
||
|
||
interface BoardPayload {
|
||
columns: Record<string, Item[]>;
|
||
order: string[];
|
||
}
|
||
|
||
interface BatchResult {
|
||
updated: { id: string; version: number; unchanged: boolean }[];
|
||
failed: { id: string; code: number; message: string; allowed?: string[] }[];
|
||
}
|
||
|
||
export function BoardPage() {
|
||
const { wsId = "" } = useParams<{ wsId: string }>();
|
||
const [columns, setColumns] = useState<Record<string, Item[]>>({});
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [dragId, setDragId] = useState<string | null>(null);
|
||
const [hoverCol, setHoverCol] = useState<string | null>(null);
|
||
const [openId, setOpenId] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const load = useCallback(async () => {
|
||
if (!wsId) return;
|
||
try {
|
||
const data = await api.get<BoardPayload>(`/workspaces/${wsId}/items/board`);
|
||
setColumns(data.columns);
|
||
setSelected((prev) => {
|
||
const alive = new Set(Object.values(data.columns).flat().map((i) => i.id));
|
||
return new Set([...prev].filter((id) => alive.has(id)));
|
||
});
|
||
setError(null);
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.message : "看板加载失败");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [wsId]);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
const allItems = useMemo(() => Object.values(columns).flat(), [columns]);
|
||
const selectedItems = allItems.filter((i) => selected.has(i.id));
|
||
|
||
function moveLocally(item: Item, to: ItemStatus) {
|
||
setColumns((prev) => {
|
||
const next: Record<string, Item[]> = {};
|
||
for (const [key, list] of Object.entries(prev)) {
|
||
next[key] = list.filter((i) => i.id !== item.id);
|
||
}
|
||
next[to] = [{ ...item, status: to }, ...(next[to] ?? [])];
|
||
return next;
|
||
});
|
||
}
|
||
|
||
async function move(item: Item, to: ItemStatus) {
|
||
if (!wsId || item.status === to) return;
|
||
if (!canTransition(item.status, to)) {
|
||
setError(`「${STATUS_LABEL[item.status]}」不能直接变成「${STATUS_LABEL[to]}」`);
|
||
return;
|
||
}
|
||
moveLocally(item, to);
|
||
try {
|
||
const updated = await api.post<Item>(`/workspaces/${wsId}/items/${item.id}/status`, {
|
||
status: to,
|
||
version: item.version,
|
||
});
|
||
setColumns((prev) => {
|
||
const next: Record<string, Item[]> = {};
|
||
for (const [key, list] of Object.entries(prev)) {
|
||
next[key] = list.map((i) => (i.id === updated.id ? updated : i));
|
||
}
|
||
return next;
|
||
});
|
||
setError(null);
|
||
} catch (err) {
|
||
void load();
|
||
if (err instanceof ApiError && err.code === 4009) {
|
||
setError("这条刚被别人改过,看板已刷新,请再拖一次");
|
||
} else {
|
||
setError(err instanceof ApiError ? err.message : "移动失败");
|
||
}
|
||
}
|
||
}
|
||
|
||
async function batchMove(to: ItemStatus) {
|
||
if (!wsId || selectedItems.length === 0) return;
|
||
const movable = selectedItems.filter((i) => canTransition(i.status, to));
|
||
const blocked = selectedItems.length - movable.length;
|
||
if (movable.length === 0) {
|
||
setError(`选中的 ${selectedItems.length} 条都不能直接变成「${STATUS_LABEL[to]}」`);
|
||
return;
|
||
}
|
||
try {
|
||
const result = await api.post<BatchResult>(`/workspaces/${wsId}/items/batch-status`, {
|
||
status: to,
|
||
items: movable.map((i) => ({ id: i.id, version: i.version })),
|
||
});
|
||
const failedIds = new Set(result.failed.map((f) => f.id));
|
||
const note = [
|
||
`已移动 ${result.updated.length} 条`,
|
||
failedIds.size > 0 ? `${failedIds.size} 条因并发冲突未移动` : "",
|
||
blocked > 0 ? `${blocked} 条状态不允许` : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(",");
|
||
setError(note);
|
||
setSelected(new Set());
|
||
await load();
|
||
} catch (err) {
|
||
setError(err instanceof ApiError ? err.message : "批量移动失败");
|
||
}
|
||
}
|
||
|
||
async function onDrop(to: ItemStatus) {
|
||
setHoverCol(null);
|
||
const item = allItems.find((i) => i.id === dragId);
|
||
setDragId(null);
|
||
if (item) await move(item, to);
|
||
}
|
||
|
||
return (
|
||
<AppShell wsId={wsId} active="board">
|
||
<PageHeader
|
||
title="看板"
|
||
desc="拖卡片改状态;勾选后可以批量移动。状态机不允许的组合会当场拒绝并说明原因。"
|
||
eyebrow={<>收件箱不上看板,先归类再排期</>}
|
||
/>
|
||
|
||
{selectedItems.length > 0 && (
|
||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-xl border border-primary-200/70 bg-primary-50 px-3 py-2 text-sm">
|
||
<span className="font-medium text-primary-800">已选 {selectedItems.length} 条</span>
|
||
{COLUMNS.map((col) => (
|
||
<Button key={col.key} variant="ghost" size="sm" onClick={() => void batchMove(col.key)}>
|
||
移到{col.label}
|
||
</Button>
|
||
))}
|
||
<Button variant="subtle" size="sm" className="ml-auto" onClick={() => setSelected(new Set())}>
|
||
取消选择
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="mb-4">
|
||
<InlineAlert tone="info">{error}</InlineAlert>
|
||
</div>
|
||
)}
|
||
|
||
{loading && Object.keys(columns).length === 0 ? (
|
||
<div className="grid gap-3 md:grid-cols-4">
|
||
{COLUMNS.map((c) => (
|
||
<div key={c.key} className="h-52 animate-pulse rounded-2xl border border-line-200 bg-white/60" />
|
||
))}
|
||
</div>
|
||
) : allItems.length === 0 ? (
|
||
<div className="card">
|
||
<EmptyState
|
||
icon={<Columns size={18} />}
|
||
title="看板还是空的"
|
||
hint="任务页收下几条待办,这里就会按状态分列排开。"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="grid gap-3 md:grid-cols-4">
|
||
{COLUMNS.map((col) => {
|
||
const list = columns[col.key] ?? [];
|
||
const hot = hoverCol === col.key;
|
||
return (
|
||
<section
|
||
key={col.key}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
setHoverCol(col.key);
|
||
}}
|
||
onDragLeave={() => setHoverCol((prev) => (prev === col.key ? null : prev))}
|
||
onDrop={() => void onDrop(col.key)}
|
||
className={`rounded-2xl border p-2 transition-all ${
|
||
hot
|
||
? "border-primary-400 bg-primary-50/60 ring-2 ring-primary-400/25"
|
||
: "border-line-200 bg-paper-50/70"
|
||
}`}
|
||
>
|
||
<header className="mb-2 flex items-center gap-1.5 px-1 py-1">
|
||
<span className={`h-1.5 w-1.5 rounded-full ${col.dot}`} />
|
||
<span className="text-xs font-medium text-ink-700">{col.label}</span>
|
||
<span className="rounded bg-white px-1.5 text-2xs tabular-nums text-ink-400 ring-1 ring-line-200">
|
||
{list.length}
|
||
</span>
|
||
</header>
|
||
|
||
<div className={`space-y-2 rounded-xl ${hot ? "bg-white/40" : ""}`}>
|
||
{list.map((item) => (
|
||
<article
|
||
key={item.id}
|
||
draggable
|
||
onDragStart={() => setDragId(item.id)}
|
||
onDragEnd={() => setDragId(null)}
|
||
className={`group cursor-grab rounded-xl border border-line-200 bg-white p-2.5 shadow-card transition hover:-translate-y-px hover:border-line-300 active:cursor-grabbing ${
|
||
dragId === item.id ? "rotate-1 opacity-50" : ""
|
||
}`}
|
||
>
|
||
<div className="flex items-start gap-2">
|
||
{col.key === "done" ? (
|
||
<Check size={14} className="mt-0.5 shrink-0 text-sage-600" />
|
||
) : (
|
||
<input
|
||
type="checkbox"
|
||
className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded accent-primary-700"
|
||
checked={selected.has(item.id)}
|
||
onChange={() => {
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(item.id)) next.delete(item.id);
|
||
else next.add(item.id);
|
||
return next;
|
||
});
|
||
}}
|
||
aria-label={`选择 ${item.title}`}
|
||
/>
|
||
)}
|
||
<button
|
||
className={`min-w-0 flex-1 text-left text-sm leading-snug transition-colors ${
|
||
item.status === "done"
|
||
? "text-ink-400 line-through"
|
||
: "text-ink-900 hover:text-primary-700"
|
||
}`}
|
||
onClick={() => setOpenId(item.id)}
|
||
>
|
||
{item.title}
|
||
</button>
|
||
</div>
|
||
<div className="mt-2 flex flex-wrap items-center gap-1 pl-5">
|
||
{item.priority > 0 && (
|
||
<span className="chip bg-clay-50 text-clay-600">
|
||
{PRIORITY_LABEL[item.priority]}
|
||
</span>
|
||
)}
|
||
<StatusPill status={item.status} label={STATUS_LABEL[item.status]} />
|
||
{item.due_at && (
|
||
<span className="text-2xs text-ink-400">
|
||
{new Date(item.due_at).toLocaleDateString("zh-CN", {
|
||
month: "numeric",
|
||
day: "numeric",
|
||
})}
|
||
</span>
|
||
)}
|
||
{item.tags.map((t) => (
|
||
<span key={t.id} className="text-2xs text-ink-400">
|
||
#{t.name}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</article>
|
||
))}
|
||
{list.length === 0 && (
|
||
<p
|
||
className={`rounded-xl border border-dashed border-line-200 px-2 py-6 text-center text-2xs ${
|
||
hot ? "border-primary-300 text-primary-500" : "text-ink-300"
|
||
}`}
|
||
>
|
||
拖到这里
|
||
</p>
|
||
)}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{openId && wsId && (
|
||
<TaskDetail
|
||
wsId={wsId}
|
||
itemId={openId}
|
||
onClose={() => setOpenId(null)}
|
||
onChanged={() => void load()}
|
||
onDeleted={() => void load()}
|
||
/>
|
||
)}
|
||
</AppShell>
|
||
);
|
||
}
|