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
+284
View File
@@ -0,0 +1,284 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError, api } from "../lib/client";
import { PRIORITY_LABEL, STATUS_LABEL } from "../lib/parse";
import type { Activity, Item, ItemDetail } from "../lib/types";
interface Comment {
id: string;
body_md: string;
author_id: string;
created_at: string;
}
interface Props {
wsId: string;
itemId: string;
onClose: () => void;
onChanged: (item: Item) => void;
onDeleted: (id: string) => void;
}
function toLocalInput(iso: string | null): string {
if (!iso) return "";
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function TaskDetail({ wsId, itemId, onClose, onChanged, onDeleted }: Props) {
const [item, setItem] = useState<ItemDetail | null>(null);
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [tags, setTags] = useState("");
const [due, setDue] = useState("");
const [comments, setComments] = useState<Comment[]>([]);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const base = `/workspaces/${wsId}/items/${itemId}`;
const hydrate = useCallback((next: ItemDetail) => {
setItem(next);
setTitle(next.title);
setBody(next.body_md);
setTags(next.tags.map((t) => t.name).join(" "));
setDue(toLocalInput(next.due_at));
}, []);
useEffect(() => {
let alive = true;
void (async () => {
try {
const detail = await api.get<ItemDetail>(base);
if (alive) hydrate(detail);
const page = await api.get<{ items: Comment[] }>(`${base}/comments`);
if (alive) setComments(page.items);
} catch (err) {
if (alive) setError(err instanceof ApiError ? err.message : "加载失败");
}
})();
return () => {
alive = false;
};
}, [base, hydrate]);
async function patch(
values: Record<string, unknown>,
label: string,
): Promise<boolean> {
if (!item) return false;
setBusy(true);
setError(null);
try {
const updated = await api.patch<Item>(base, { ...values, version: item.version });
onChanged(updated);
hydrate({ ...(item as ItemDetail), ...updated, activities: item.activities });
void refreshActivities(updated);
return true;
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError(`${label}没保存:这条刚被别人改过,已刷新为最新内容`);
const fresh = await api.get<ItemDetail>(base);
hydrate(fresh);
} else {
setError(err instanceof ApiError ? err.message : `${label}没保存`);
}
return false;
} finally {
setBusy(false);
}
}
async function refreshActivities(updated: Item) {
try {
const fresh = await api.get<ItemDetail>(base);
setItem({ ...fresh, ...updated, activities: fresh.activities });
setTitle(updated.title);
setBody(updated.body_md);
setTags(updated.tags.map((t) => t.name).join(" "));
setDue(toLocalInput(updated.due_at));
} catch {
/* 刷新事件流失败不影响已保存的结果 */
}
}
async function saveTitle() {
if (!item || !title.trim() || title.trim() === item.title) return;
await patch({ title: title.trim() }, "标题");
}
async function saveBody() {
if (!item || body === item.body_md) return;
await patch({ body_md: body }, "备注");
}
async function saveTags() {
if (!item) return;
const next = tags.split(/[\s,,]+/).filter(Boolean);
const current = item.tags.map((t) => t.name);
if (next.join() === current.join()) return;
await patch({ tag_names: next }, "标签");
}
async function saveDue(value: string) {
if (!item) return;
if (!value) {
await patch({ clear_due_at: true }, "截止时间");
return;
}
await patch({ due_at: new Date(value).toISOString() }, "截止时间");
}
async function addComment() {
const text = draft.trim();
if (!text) return;
try {
const created = await api.post<Comment>(`${base}/comments`, { body_md: text });
setComments((prev) => [...prev, created]);
setDraft("");
} catch (err) {
setError(err instanceof ApiError ? err.message : "评论失败");
}
}
async function remove() {
if (!item) return;
if (!window.confirm("删除这条任务?可以在「已删除」视图里找回内容。")) return;
try {
await api.del(`${base}?version=${item.version}`);
onDeleted(item.id);
onClose();
} catch (err) {
setError(err instanceof ApiError ? err.message : "删除失败");
}
}
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>
{STATUS_LABEL[item.status]} · 版本 {item.version}
</span>
<div className="flex gap-2">
<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>
</div>
<input
className="input text-base font-medium"
value={title}
onChange={(e) => setTitle(e.target.value)}
onBlur={() => void saveTitle()}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
}}
/>
<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
className="input"
value={tags}
onChange={(e) => setTags(e.target.value)}
onBlur={() => void saveTags()}
placeholder="周报 客户"
/>
</label>
<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>
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
{busy && <p className="mt-3 text-xs text-ink-500">保存中…</p>}
<h3 className="mt-6 text-sm font-medium">评论({comments.length})</h3>
<ul className="mt-2 space-y-2">
{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>
))}
</ul>
<div className="mt-2 flex gap-2">
<input
className="input flex-1"
placeholder="补充一句进展…"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addComment();
}}
/>
<button className="btn-primary" disabled={!draft.trim()} onClick={() => void addComment()}>
发表
</button>
</div>
<h3 className="mt-6 text-sm font-medium">动态</h3>
<ul className="mt-2 space-y-1 text-xs text-ink-500">
{item.activities.slice(0, 30).map((a: Activity) => (
<li key={a.id}>
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
{a.action === "status_changed" && a.payload
? ` ${String(a.payload.from ?? "")} → ${String(a.payload.to ?? "")}`
: ""}
</li>
))}
</ul>
</>
)}
</aside>
</div>
);
}