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
+2
View File
@@ -6,6 +6,7 @@ import { LoginPage } from "./pages/Login";
import { HomePage } from "./pages/Home";
import { WorkspacePage } from "./pages/Workspace";
import { TasksPage } from "./pages/Tasks";
import { BoardPage } from "./pages/Board";
import { JoinPage } from "./pages/Join";
interface AuthState {
@@ -82,6 +83,7 @@ export function App() {
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<HomePage />} />
<Route path="/w/:wsId" element={<TasksPage />} />
<Route path="/w/:wsId/board" element={<BoardPage />} />
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
<Route path="/join/:wsId/:code" element={<JoinPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
+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>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { ItemStatus } from "./types";
const PRIORITY_ALIASES: Record<string, number> = {
"!0": 0,
"!1": 1,
"!2": 2,
"!3": 3,
"!低": 1,
"!中": 2,
"!高": 3,
};
export interface QuickAdd {
title: string;
tagNames: string[];
priority: number;
status: ItemStatus;
}
/**
* 解析快速录入:`#标签` 与 `!优先级` 会被吃掉,剩下的做标题。
*
* 只认行内 `#` 开头且后面不是空格的片段——中文正文里 `#` 极少自然出现,
* 真要以 # 开头就加个空格。`@人` 暂时不解析:重名的人比标签多,
* 悄悄指错负责人比不支持更糟(等 M2 有成员选择器再做)。
*/
export function parseQuickAdd(raw: string, defaultStatus: ItemStatus): QuickAdd {
const tagNames: string[] = [];
let priority = 1;
const words: string[] = [];
for (const token of raw.split(/\s+/)) {
if (!token) continue;
if (token.length > 1 && token.startsWith("#")) {
tagNames.push(token.slice(1));
continue;
}
if (PRIORITY_ALIASES[token] !== undefined) {
priority = PRIORITY_ALIASES[token];
continue;
}
words.push(token);
}
const title = words.join(" ").trim() || raw.trim();
return { title, tagNames, priority, status: defaultStatus };
}
export const ROLE_LABEL: Record<string, string> = {
owner: "负责人",
admin: "管理员",
member: "成员",
guest: "访客",
};
export const STATUS_LABEL: Record<ItemStatus, string> = {
inbox: "收件箱",
todo: "待办",
doing: "进行中",
done: "已完成",
blocked: "被阻塞",
canceled: "已取消",
};
/** 状态机前端影子:只用来置灰拖拽目标,服务端仍会再校验一次。 */
export const ALLOWED: Record<ItemStatus, ItemStatus[]> = {
inbox: ["todo", "canceled"],
todo: ["inbox", "doing", "done", "canceled"],
doing: ["todo", "done", "blocked", "canceled"],
blocked: ["doing", "canceled"],
done: ["todo"],
canceled: ["todo"],
};
export function canTransition(from: ItemStatus, to: ItemStatus): boolean {
return from === to || (ALLOWED[from] ?? []).includes(to);
}
export const PRIORITY_LABEL: Record<number, string> = {
0: "无",
1: "低",
2: "中",
3: "高",
};
+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>
);
}
+86 -72
View File
@@ -1,26 +1,19 @@
import { useCallback, useEffect, useRef, useState } from "react";
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 type { Item, ItemDetail, ItemStatus, Page } from "../lib/types";
import { PRIORITY_LABEL, STATUS_LABEL, parseQuickAdd } from "../lib/parse";
import type { Item, ItemStatus, Page } from "../lib/types";
const VIEWS: { key: string; label: string; hint: string }[] = [
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的" },
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞" },
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事" },
{ key: "done", label: "已完成", hint: "最近完成的" },
{ key: "all", label: "全部", hint: "含已取消" },
const VIEWS: { key: string; label: string; hint: string; createAs: ItemStatus }[] = [
{ key: "today", label: "今天", hint: "今天之前到期、还没做完的", createAs: "todo" },
{ key: "active", label: "进行中", hint: "待办 + 进行中 + 被阻塞", createAs: "todo" },
{ key: "inbox", label: "收件箱", hint: "想到还没归类的事", createAs: "inbox" },
{ key: "done", label: "已完成", hint: "最近完成的", createAs: "todo" },
{ key: "all", label: "全部", hint: "含已取消", createAs: "todo" },
];
const STATUS_LABEL: Record<ItemStatus, string> = {
inbox: "收件箱",
todo: "待办",
doing: "进行中",
done: "已完成",
blocked: "被阻塞",
canceled: "已取消",
};
const STATUS_STYLE: Record<ItemStatus, string> = {
inbox: "bg-ink-100 text-ink-700",
todo: "bg-ink-100 text-ink-700",
@@ -30,12 +23,11 @@ const STATUS_STYLE: Record<ItemStatus, string> = {
canceled: "bg-ink-100 text-ink-500 line-through",
};
function formatDue(iso: string | null): { text: string; overdue: boolean } | null {
function dueLabel(iso: string | null): { text: string; overdue: boolean } | null {
if (!iso) return null;
const due = new Date(iso);
const now = new Date();
const text = due.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
return { text, overdue: due.getTime() < now.getTime() };
return { text, overdue: due.getTime() < Date.now() };
}
export function TasksPage() {
@@ -49,8 +41,9 @@ export function TasksPage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [openId, setOpenId] = useState<string | null>(null);
const [detail, setDetail] = useState<ItemDetail | null>(null);
const draftRef = useRef<HTMLInputElement>(null);
const [activeIdx, setActiveIdx] = useState(0);
const current = VIEWS.find((v) => v.key === view);
const load = useCallback(
async (nextCursor?: string | null) => {
@@ -78,28 +71,48 @@ export function TasksPage() {
void load(null);
}, [load]);
const activeItem = useMemo(() => items[activeIdx] ?? null, [items, activeIdx]);
useEffect(() => {
if (!openId || !wsId) {
setDetail(null);
return;
}
void api
.get<ItemDetail>(`/workspaces/${wsId}/items/${openId}`)
.then(setDetail)
.catch(() => setDetail(null));
}, [openId, wsId]);
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement;
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable;
if (e.key === "Escape") {
setOpenId(null);
(target as HTMLInputElement).blur?.();
return;
}
if (typing || items.length === 0) return;
if (e.key === "j" || e.key === "ArrowDown") {
e.preventDefault();
setActiveIdx((i) => Math.min(i + 1, items.length - 1));
} else if (e.key === "k" || e.key === "ArrowUp") {
e.preventDefault();
setActiveIdx((i) => Math.max(i - 1, 0));
} else if (e.key === "e") {
if (activeItem) setOpenId(activeItem.id);
} else if (e.key === "Enter") {
if (activeItem) void changeStatus(activeItem, activeItem.status === "done" ? "todo" : "done");
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
async function addTask() {
const title = draft.trim();
if (!title || !wsId) return;
const raw = draft.trim();
if (!raw || !wsId || !current) return;
const parsed = parseQuickAdd(raw, current.createAs);
try {
const created = await api.post<Item>(`/workspaces/${wsId}/items`, {
title,
status: view === "inbox" ? "inbox" : "todo",
title: parsed.title,
status: parsed.status,
priority: parsed.priority,
tag_names: parsed.tagNames,
});
setDraft("");
setItems((prev) => [created, ...prev]);
draftRef.current?.focus();
if (view !== "done") setItems((prev) => [created, ...prev]);
} catch (err) {
setError(err instanceof ApiError ? err.message : "创建失败");
}
@@ -113,7 +126,6 @@ export function TasksPage() {
version: item.version,
});
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)));
if (openId === updated.id) setDetail((prev) => (prev ? { ...prev, ...updated } : prev));
} catch (err) {
if (err instanceof ApiError && err.code === 4009) {
setError("这条任务刚被别人改过,已为你刷新");
@@ -124,18 +136,23 @@ export function TasksPage() {
}
}
const current = VIEWS.find((v) => v.key === view);
return (
<Layout>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">任务</h1>
<p className="mt-0.5 text-xs text-ink-500">{current?.hint}</p>
<p className="mt-0.5 text-xs text-ink-500">
{current?.hint}
<span className="ml-2 text-ink-100">|</span>
<span className="ml-2">j/k 选择 · Enter 完成 · e 编辑 · Esc 关闭</span>
</p>
</div>
<div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-3 text-sm">
<Link to={`/w/${wsId}/board`} className="text-ink-500 hover:text-ink-900">
看板
</Link>
<Link to={`/w/${wsId}/members`} className="text-ink-500 hover:text-ink-900">
成员与邀请
成员
</Link>
<Link to="/" className="text-ink-500 hover:text-ink-900">
切换工作区
@@ -166,9 +183,8 @@ export function TasksPage() {
<div className="card mb-4">
<div className="flex gap-2">
<input
ref={draftRef}
className="input flex-1"
placeholder="加一件要做的事,回车创建"
placeholder="加一件要做的事,回车创建。支持 #标签 !高"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
@@ -190,13 +206,19 @@ export function TasksPage() {
</p>
)}
{items.map((item) => {
const due = formatDue(item.due_at);
{items.map((item, index) => {
const due = dueLabel(item.due_at);
const done = item.status === "done";
// 收件箱里的东西还没"收下",不给完成勾选框——先归类,再完成。
const canComplete = item.status !== "inbox" && item.status !== "canceled";
return (
<div key={item.id} className="flex items-start gap-3 px-4 py-3">
<div
key={item.id}
className={`flex items-start gap-3 px-4 py-3 ${
index === activeIdx ? "bg-ink-50/70" : ""
}`}
onMouseEnter={() => setActiveIdx(index)}
>
{canComplete ? (
<input
type="checkbox"
@@ -211,7 +233,7 @@ export function TasksPage() {
<div className="min-w-0 flex-1">
<button
className={`block w-full text-left text-sm ${done ? "text-ink-500 line-through" : ""}`}
onClick={() => setOpenId(openId === item.id ? null : item.id)}
onClick={() => setOpenId(item.id)}
>
{item.title}
</button>
@@ -219,6 +241,9 @@ export function TasksPage() {
<span className={`rounded px-1.5 py-0.5 ${STATUS_STYLE[item.status]}`}>
{STATUS_LABEL[item.status]}
</span>
{item.priority > 0 && (
<span className="text-ink-500">优先级 {PRIORITY_LABEL[item.priority]}</span>
)}
{due && (
<span className={due.overdue && !done ? "text-red-600" : "text-ink-500"}>
截止 {due.text}
@@ -231,29 +256,6 @@ export function TasksPage() {
</span>
))}
</div>
{openId === item.id && (
<div className="mt-3 rounded-lg bg-ink-50 p-3 text-xs">
{detail && detail.id === item.id ? (
<>
{detail.body_md ? (
<p className="whitespace-pre-wrap text-ink-700">{detail.body_md}</p>
) : (
<p className="text-ink-500">没有备注。</p>
)}
<ul className="mt-3 space-y-1 text-ink-500">
{detail.activities.slice(0, 8).map((a) => (
<li key={a.id}>
{new Date(a.created_at).toLocaleString("zh-CN")} · {a.action}
</li>
))}
</ul>
</>
) : (
<p className="text-ink-500">加载中…</p>
)}
</div>
)}
</div>
<div className="flex shrink-0 gap-1">
@@ -286,6 +288,18 @@ export function TasksPage() {
</div>
)}
{loading && items.length === 0 && <p className="mt-4 text-center text-sm text-ink-500">加载中…</p>}
{openId && wsId && (
<TaskDetail
wsId={wsId}
itemId={openId}
onClose={() => setOpenId(null)}
onChanged={(updated) =>
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
}
onDeleted={(id) => setItems((prev) => prev.filter((it) => it.id !== id))}
/>
)}
</Layout>
);
}