Files
DadaoTask/web/src/pages/Board.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

301 lines
12 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, 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>
);
}