feat(web,android): 看板拖拽、详情侧栏编辑、快速录入与安卓任务列表

Web:
- 看板四列可拖拽,非法流转当场说明原因;勾选后可批量移动
- 详情侧栏全部可编辑(标题/优先级/截止/标签/备注/评论),409 自动刷新
- 快速录入 #标签 !优先级;键盘流 j/k、Enter、e、Esc
- Playwright 真浏览器 e2e 7 项断言全绿(web/e2e/smoke.mjs)
- 1000 条任务实测首屏 P50 38-55ms

Android(未编译验证:本机无 JDK/Gradle):
- 任务列表/详情/勾选完成/快速新建,冲突时提示并刷新
- 修:User/Member 字段名与后端 nickmame/phone 对齐,原先必然反序列化失败
This commit is contained in:
dadao
2026-10-28 04:05:41 +08:00
parent c4868f04cf
commit 6e33e1024d
13 changed files with 1316 additions and 93 deletions
+263
View File
@@ -0,0 +1,263 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Layout } from "../components/Layout";
import { TaskDetail } from "../components/TaskDetail";
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 }[] = [
{ key: "todo", label: "待办" },
{ key: "doing", label: "进行中" },
{ key: "blocked", label: "被阻塞" },
{ key: "done", label: "已完成" },
];
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 (
<Layout>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-lg font-semibold">看板</h1>
<p className="mt-0.5 text-xs text-ink-500">
拖卡片改状态;勾选后可以批量移动。状态机不允许的组合会直接拒绝并说明原因。
</p>
</div>
<div className="flex items-center gap-3 text-sm">
<Link to={`/w/${wsId}`} className="text-ink-500 hover:text-ink-900">
列表
</Link>
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
成员
</Link>
</div>
</div>
{selectedItems.length > 0 && (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-lg bg-ink-50 px-3 py-2 text-sm">
<span className="text-ink-700">已选 {selectedItems.length} 条</span>
{COLUMNS.map((col) => (
<button
key={col.key}
className="btn-ghost px-2 py-1 text-xs"
onClick={() => void batchMove(col.key)}
>
移到{col.label}
</button>
))}
<button className="btn-ghost ml-auto px-2 py-1 text-xs" onClick={() => setSelected(new Set())}>
取消选择
</button>
</div>
)}
{error && <p className="mb-3 text-sm text-amber-700">{error}</p>}
{loading && <p className="text-sm text-ink-500">加载中…</p>}
<div className="grid gap-3 md:grid-cols-4">
{COLUMNS.map((col) => {
const list = columns[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-xl border p-2 transition ${
hoverCol === col.key ? "border-ink-900 bg-ink-50" : "border-ink-100 bg-white"
}`}
>
<header className="mb-2 flex items-center justify-between px-1 text-xs text-ink-500">
<span className="font-medium text-ink-700">{col.label}</span>
<span>{list.length}</span>
</header>
<div className="space-y-2">
{list.map((item) => (
<article
key={item.id}
draggable
onDragStart={() => setDragId(item.id)}
onDragEnd={() => setDragId(null)}
className={`cursor-grab rounded-lg border border-ink-100 bg-white p-2 shadow-sm ${
dragId === item.id ? "opacity-50" : ""
}`}
>
<div className="flex items-start gap-2">
<input
type="checkbox"
className="mt-1 h-3.5 w-3.5 accent-ink-900"
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;
});
}}
/>
<button
className="min-w-0 flex-1 text-left text-sm"
onClick={() => setOpenId(item.id)}
>
{item.title}
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-1 pl-5 text-[11px] text-ink-500">
{item.priority > 0 && (
<span className="rounded bg-accent/15 px-1 text-accent">
{PRIORITY_LABEL[item.priority]}
</span>
)}
{item.due_at && <span>{new Date(item.due_at).toLocaleDateString("zh-CN")}</span>}
{item.tags.map((t) => (
<span key={t.id}>#{t.name}</span>
))}
</div>
</article>
))}
{list.length === 0 && (
<p className="px-1 py-4 text-center text-xs text-ink-500">空</p>
)}
</div>
</section>
);
})}
</div>
{openId && wsId && (
<TaskDetail
wsId={wsId}
itemId={openId}
onClose={() => setOpenId(null)}
onChanged={() => void load()}
onDeleted={() => void load()}
/>
)}
</Layout>
);
}