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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user