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,126 @@
|
||||
/**
|
||||
* 三条真浏览器用例:登录 → 建任务(含 #标签/!优先级)→ 看板拖拽 → 详情抽屉保存。
|
||||
*
|
||||
* 用 playwright-core + 本机缓存的 Chromium,不下载浏览器(这台机器外网被掐)。
|
||||
* 跑之前先起后端(8100)与前端(5180):
|
||||
* node e2e/smoke.mjs
|
||||
*/
|
||||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const APP = process.env.APP_URL ?? "http://127.0.0.1:5180";
|
||||
const CHROME =
|
||||
process.env.CHROME_PATH ??
|
||||
`${process.env.LOCALAPPDATA}\\ms-playwright\\chromium-1223\\chrome-win64\\chrome.exe`;
|
||||
const SHOT_DIR = new URL("./artifacts/", import.meta.url).pathname.replace(/^\//, "");
|
||||
|
||||
if (!existsSync(CHROME)) {
|
||||
console.error(`找不到 Chromium:${CHROME}\n用 CHROME_PATH 指定可执行文件`);
|
||||
process.exit(2);
|
||||
}
|
||||
mkdirSync(SHOT_DIR, { recursive: true });
|
||||
|
||||
const phone = `139${String(Date.now()).slice(-8)}`;
|
||||
let step = 0;
|
||||
function pass(message) {
|
||||
console.log(` ✓ ${++step}. ${message}`);
|
||||
}
|
||||
function fail(message) {
|
||||
console.error(` ✗ ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME, headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
page.setDefaultTimeout(10_000);
|
||||
|
||||
const consoleErrors = [];
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") consoleErrors.push(msg.text());
|
||||
});
|
||||
page.on("pageerror", (err) => consoleErrors.push(String(err)));
|
||||
|
||||
try {
|
||||
// 1. 登录
|
||||
await page.goto(`${APP}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.locator("#phone").fill(phone);
|
||||
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||
const devCodeText = await page.locator("text=开发环境验证码").first().innerText();
|
||||
const code = devCodeText.match(/(\d{6})/)?.[1];
|
||||
if (!code) fail(`没读到开发验证码:${devCodeText}`);
|
||||
await page.locator("#code").fill(code);
|
||||
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
|
||||
await page.waitForURL(`${APP}/`);
|
||||
pass("手机号验证码登录成功");
|
||||
|
||||
// 2. 建工作区(创建后直接跳进任务页)
|
||||
await page.getByPlaceholder("例如:达道科技 / 我的事项").fill("E2E 测试组");
|
||||
await page.getByRole("button", { name: "创建" }).click();
|
||||
await page.waitForURL(/\/w\/[0-9a-f-]{36}$/);
|
||||
await page.getByRole("heading", { name: "任务" }).waitFor();
|
||||
pass("建工作区并进入任务页");
|
||||
|
||||
// 3. 快速录入:#标签 + !优先级
|
||||
const composer = page.getByPlaceholder(/加一件要做的事/);
|
||||
await composer.fill("写周报初稿 #周报 !高");
|
||||
await composer.press("Enter");
|
||||
const row = page.locator("div.card > div").filter({ hasText: "写周报初稿" }).first();
|
||||
await row.waitFor();
|
||||
const rowText = await row.innerText();
|
||||
if (!rowText.includes("#周报")) fail(`标签没解析进标题外:${rowText}`);
|
||||
if (rowText.includes("#周报") && rowText.includes("写周报初稿 #周报")) fail("标题里残留了语法");
|
||||
if (!rowText.includes("优先级 高")) fail(`优先级没生效:${rowText}`);
|
||||
pass("快速录入解析 #标签 与 !优先级(标题里已剥离语法)");
|
||||
|
||||
// 4. 详情抽屉可编辑:改优先级 + 加评论
|
||||
await page.getByRole("button", { name: "写周报初稿" }).click();
|
||||
const drawer = page.locator("aside");
|
||||
await drawer.waitFor();
|
||||
await drawer.locator("select").selectOption("2");
|
||||
await page.waitForTimeout(500);
|
||||
const commentBox = drawer.getByPlaceholder("补充一句进展…");
|
||||
await commentBox.fill("E2E:初稿已出,等评审");
|
||||
await commentBox.press("Enter");
|
||||
await drawer.locator("text=E2E:初稿已出,等评审").waitFor();
|
||||
await page.screenshot({ path: `${SHOT_DIR}/detail.png` });
|
||||
await page.getByRole("button", { name: "关闭" }).click();
|
||||
const rowAfter = await page.locator("div.card > div").filter({ hasText: "写周报初稿" }).first().innerText();
|
||||
if (!rowAfter.includes("优先级 中")) fail(`详情里改优先级没同步回列表:${rowAfter}`);
|
||||
pass("详情抽屉保存(优先级回写列表)与评论落库");
|
||||
|
||||
// 5. 看板拖拽:待办 → 进行中
|
||||
await page.getByRole("link", { name: "看板" }).click();
|
||||
await page.getByRole("heading", { name: "看板" }).waitFor();
|
||||
const columns = page.locator("section");
|
||||
const todoCol = columns.nth(0);
|
||||
const doingCol = columns.nth(1);
|
||||
const card = todoCol.locator("article").filter({ hasText: "写周报初稿" }).first();
|
||||
await card.waitFor();
|
||||
await card.dragTo(doingCol);
|
||||
await doingCol.locator("article").filter({ hasText: "写周报初稿" }).first().waitFor();
|
||||
await page.screenshot({ path: `${SHOT_DIR}/board.png` });
|
||||
pass("看板拖拽改状态(待办 → 进行中)落库并刷新");
|
||||
|
||||
// 6. 状态机拒绝非法拖拽:进行中 → 待办 之后不能再直接拖到"已完成"以外的非法列(用 blocked→done 验证)
|
||||
const blockedCol = columns.nth(2);
|
||||
await doingCol.locator("article").filter({ hasText: "写周报初稿" }).first().dragTo(blockedCol);
|
||||
await blockedCol.locator("article").filter({ hasText: "写周报初稿" }).first().waitFor();
|
||||
const doneCol = columns.nth(3);
|
||||
await blockedCol.locator("article").filter({ hasText: "写周报初稿" }).first().dragTo(doneCol);
|
||||
const warning = await page.locator("text=不能直接变成").first().innerText();
|
||||
if (!warning.includes("不能直接变成")) fail("非法拖拽没有被拦下");
|
||||
pass(`非法状态流转被拦下并给出原因:${warning}`);
|
||||
|
||||
if (consoleErrors.length > 0) {
|
||||
fail(`浏览器控制台有报错:\n${consoleErrors.join("\n")}`);
|
||||
}
|
||||
pass("浏览器控制台无报错");
|
||||
|
||||
console.log(`\n全部通过,截图在 ${SHOT_DIR}`);
|
||||
await browser.close();
|
||||
} catch (err) {
|
||||
await page.screenshot({ path: `${SHOT_DIR}/failure.png` }).catch(() => {});
|
||||
console.error(`\n失败:${err?.message ?? err}`);
|
||||
await browser.close();
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -4,6 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>达道Task</title>
|
||||
<meta name="description" content="达道Task:任务、工单、知识库,一条道走到底" />
|
||||
<!-- 内联 SVG 图标:不额外发请求,也就不会有 favicon 404 -->
|
||||
<link
|
||||
rel="icon"
|
||||
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%233F5A68'/%3E%3Cpath d='M18 34l8 8 20-20' stroke='%23C2703D' stroke-width='7' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: "高",
|
||||
};
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user