M2 最后一块:让"该我办了"能出现在系统里,并且允许不经过任务直接开单。 通知(backend/app/services/notify.py 统一出口) - 七类触发:task_assigned、task_status_changed、item_commented、 ticket_assigned、ticket_submitted、ticket_approved/rejected、task_unblocked - 不通知自己:notify() 按 actor_id 跳过,调用方不用各自判断 - 已读:GET /notifications、/unread-count、POST /read、/read-all 手工开单 - POST /tickets:直接指定人、初始 open,与任务升级单共用同一条 kind='ticket' item, ticket_ext.parent_item_id 为空,验收通过不回流任务 - 指派前 _ensure_member 校验在册成员,越界指派返回 1001(避免"挂在看不到的名字上") 前端 - Web:通知页 /w/:wsId/notifications(未读/全部,点开标已读并跳目标); 工单页"新建"面板;AppShell 每 30 秒对未读数,标已读后 dispatch notifications:changed 立刻刷新; 任务与工单支持 ?open= 深链落地即展开 - 安卓:五个 tab 加"通知",未读数 30 秒刷新;工单动作按需求人/办理人/管理员显隐;手工开单对话框 验证 - pytest 85 passed(新增 backend/tests/test_notifications.py 6 个用例) - ruff check / format --check 全 0 - tsc --noEmit 通过;vite build 成功 - web/e2e/notifications.mjs 12 项断言全绿(手工开单 → B 收到 → 点开跳转 → 未读归零) - 安卓:本机 JDK 25 与 Gradle 8.7 / AGP 8.7.3 不兼容,本次未做编译验证, 装回 JDK 17 后第一件事是 assembleDebug
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
// 通知与手工开单的端到端验收。
|
||||
// 两个真实用户:A 开单交给 B、B 收到通知并点开;A 改任务状态,B 也应收到提醒。
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const BASE = process.env.BASE ?? "http://127.0.0.1:5180";
|
||||
const API = process.env.API ?? "http://127.0.0.1:8100/api/v1";
|
||||
const CHROME = process.env.CHROME ?? "C:/Users/Steven/AppData/Local/ms-playwright/chromium-1223/chrome-win64/chrome.exe";
|
||||
|
||||
const stamp = String(Date.now()).slice(-6);
|
||||
const phoneA = `135${stamp}00`;
|
||||
const phoneB = `138${String((Number(stamp) + 2222) % 1e8).padStart(8, "0")}`;
|
||||
const wsName = `通知${stamp}`;
|
||||
|
||||
let failed = 0;
|
||||
const ok = (cond, msg) => {
|
||||
if (cond) console.log(` PASS ${msg}`);
|
||||
else {
|
||||
failed += 1;
|
||||
console.log(` FAIL ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
async function login(page, phone) {
|
||||
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.fill("#phone", phone);
|
||||
await page.getByRole("button", { name: "获取验证码" }).click();
|
||||
await page.waitForSelector("text=开发环境验证码", { timeout: 15000 });
|
||||
const code = (await page.locator("text=开发环境验证码").first().innerText()).match(/(\d{6})/)?.[1];
|
||||
await page.fill("#code", code);
|
||||
await page.getByRole("main").getByRole("button", { name: "登录", exact: true }).click();
|
||||
await page.waitForURL((u) => !u.pathname.startsWith("/login"), { timeout: 15000 });
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: CHROME,
|
||||
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
||||
});
|
||||
|
||||
const ctxA = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const pageA = await ctxA.newPage();
|
||||
const errorsA = [];
|
||||
pageA.on("console", (m) => m.type() === "error" && errorsA.push(m.text()));
|
||||
pageA.on("response", (r) => r.status() >= 400 && errorsA.push(`${r.url()} ${r.status()}`));
|
||||
|
||||
try {
|
||||
await login(pageA, phoneA);
|
||||
console.log("PASS 1. A 登录");
|
||||
|
||||
// 建工作区
|
||||
await pageA.getByPlaceholder("例如:达道科技 / 我的事项").fill(wsName);
|
||||
await pageA.getByRole("button", { name: "创建" }).click();
|
||||
await pageA.waitForURL(/\/w\/[0-9a-f-]{36}/, { timeout: 20000 });
|
||||
const wsId = pageA.url().match(/\/w\/([0-9a-f-]{36})/)[1];
|
||||
console.log("PASS 2. 建工作区");
|
||||
|
||||
// 邀请 B 加入
|
||||
await pageA.goto(`${BASE}/w/${wsId}/members`, { waitUntil: "domcontentloaded" });
|
||||
await pageA.getByRole("button", { name: "邀请成员" }).click();
|
||||
await pageA.waitForSelector("text=/join/", { timeout: 15000 });
|
||||
const link = await pageA
|
||||
.locator("text=/join/")
|
||||
.first()
|
||||
.innerText()
|
||||
.then((t) => t.match(/https?:\/\/\S+join\/\S+/)?.[0]);
|
||||
ok(Boolean(link), "3. 拿到邀请链接");
|
||||
|
||||
const ctxB = await browser.newContext({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
const pageB = await ctxB.newPage();
|
||||
const errorsB = [];
|
||||
pageB.on("console", (m) => m.type() === "error" && errorsB.push(m.text()));
|
||||
pageB.on("response", (r) => r.status() >= 400 && errorsB.push(`${r.url()} ${r.status()}`));
|
||||
|
||||
await login(pageB, phoneB);
|
||||
await pageB.goto(link, { waitUntil: "domcontentloaded" });
|
||||
await pageB.waitForURL(new RegExp(`/w/${wsId}`), { timeout: 20000 });
|
||||
console.log("PASS 4. B 从邀请链接登录后自动加入");
|
||||
|
||||
// A 手工开一张工单交给 B
|
||||
await pageA.goto(`${BASE}/w/${wsId}/tickets`, { waitUntil: "domcontentloaded" });
|
||||
await pageA.getByRole("button", { name: "新建工单" }).click();
|
||||
await pageA.getByLabel("要办什么").fill(`核对 9 月发票${stamp}`);
|
||||
await pageA.getByLabel("交给谁").selectOption({ index: 1 });
|
||||
await pageA.getByRole("button", { name: "开单并通知对方" }).click();
|
||||
await pageA.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||
ok(true, "5. 手工工单开出(不经过任何任务)");
|
||||
|
||||
// 接口确认它确实是 kind=ticket 且没有来源任务
|
||||
const created = await pageA.evaluate(
|
||||
async (ws) => {
|
||||
const t = localStorage.getItem("dadao.access");
|
||||
const r = await fetch(`/api/v1/workspaces/${ws}/tickets?role=requested`, {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
return r.json();
|
||||
},
|
||||
wsId,
|
||||
);
|
||||
const t0 = created.data.tickets[0];
|
||||
ok(t0?.kind === "ticket" && t0?.ticket?.parent_item_id === null, "6. 手工单没有来源任务");
|
||||
|
||||
// B 收到通知
|
||||
await pageB.goto(`${BASE}/w/${wsId}/notifications`, { waitUntil: "domcontentloaded" });
|
||||
await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||
ok(true, "7. B 收到「派给你的工单」通知");
|
||||
|
||||
const unreadBefore = await pageB.locator("li.card").count();
|
||||
ok(unreadBefore >= 1, "8. 未读列表里有条目");
|
||||
|
||||
// 点开通知:跳到工单页
|
||||
await pageB.locator("li.card").first().click();
|
||||
await pageB.waitForURL(/\/tickets\?open=/, { timeout: 20000 });
|
||||
await pageB.waitForSelector(`text=核对 9 月发票${stamp}`, { timeout: 20000 });
|
||||
ok(true, "9. 点通知跳到工单并展开那张单");
|
||||
|
||||
// 标记已读后未读数归零
|
||||
const unread = await pageB.evaluate(async (ws) => {
|
||||
const t = localStorage.getItem("dadao.access");
|
||||
const r = await fetch(`/api/v1/workspaces/${ws}/notifications/unread-count`, {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
return (await r.json()).data.unread;
|
||||
}, wsId);
|
||||
ok(unread === 0, "10. 点开后未读数归零");
|
||||
|
||||
ok(errorsA.length === 0, `11. A 无报错${errorsA.slice(0, 2).join(" | ")}`);
|
||||
ok(errorsB.length === 0, `12. B 无报错${errorsB.slice(0, 2).join(" | ")}`);
|
||||
|
||||
await pageB.screenshot({ path: "e2e/artifacts/notifications.png" });
|
||||
await pageA.screenshot({ path: "e2e/artifacts/ticket-new.png" });
|
||||
} catch (err) {
|
||||
failed += 1;
|
||||
console.log("FAIL 异常:", err.message);
|
||||
await pageA.screenshot({ path: "e2e/artifacts/notifications-fail-a.png" }).catch(() => {});
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
console.log(failed === 0 ? "\nALL PASS" : `\n${failed} 项失败`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,50 @@
|
||||
// 只为截图:登录一个已有通知的账号,截「通知页」与「新建工单」面板。
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const BASE = "http://127.0.0.1:5180";
|
||||
const API = "http://127.0.0.1:8100/api/v1";
|
||||
const CHROME = "C:/Users/Steven/AppData/Local/ms-playwright/chromium-1223/chrome-win64/chrome.exe";
|
||||
|
||||
const login = async (phone) => {
|
||||
const send = await fetch(`${API}/auth/sms/send`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone }),
|
||||
}).then((r) => r.json());
|
||||
const code = send.data?.dev_code ?? "123456";
|
||||
const r = await fetch(`${API}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ phone, code }),
|
||||
}).then((x) => x.json());
|
||||
return r.data;
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({ executablePath: CHROME, args: ["--no-sandbox"] });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, locale: "zh-CN" });
|
||||
|
||||
const auth = await login("13900009002");
|
||||
const me = await fetch(`${API}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${auth.tokens.access_token}` },
|
||||
}).then((r) => r.json());
|
||||
const ws = me.data.workspaces[0];
|
||||
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.evaluate(
|
||||
([a, r]) => {
|
||||
localStorage.setItem("dadao.access", a);
|
||||
localStorage.setItem("dadao.refresh", r);
|
||||
},
|
||||
[auth.tokens.access_token, auth.tokens.refresh_token],
|
||||
);
|
||||
|
||||
await page.goto(`${BASE}/w/${ws.id}/notifications`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector("ul li", { timeout: 20000 });
|
||||
await page.screenshot({ path: "e2e/artifacts/ui-notifications.png" });
|
||||
|
||||
await page.goto(`${BASE}/w/${ws.id}/tickets`, { waitUntil: "domcontentloaded" });
|
||||
await page.getByRole("button", { name: "新建工单" }).click();
|
||||
await page.waitForSelector("text=交给谁", { timeout: 15000 });
|
||||
await page.screenshot({ path: "e2e/artifacts/ui-ticket-new.png" });
|
||||
|
||||
console.log("done", ws.name);
|
||||
await browser.close();
|
||||
@@ -10,6 +10,7 @@ import { TasksPage } from "./pages/Tasks";
|
||||
import { BoardPage } from "./pages/Board";
|
||||
import { TicketsPage } from "./pages/Tickets";
|
||||
import { KnowledgePage } from "./pages/Knowledge";
|
||||
import { NotificationsPage } from "./pages/Notifications";
|
||||
import { JoinPage } from "./pages/Join";
|
||||
|
||||
interface AuthState {
|
||||
@@ -90,6 +91,7 @@ export function App() {
|
||||
<Route path="/w/:wsId/board" element={<BoardPage />} />
|
||||
<Route path="/w/:wsId/tickets" element={<TicketsPage />} />
|
||||
<Route path="/w/:wsId/knowledge" element={<KnowledgePage />} />
|
||||
<Route path="/w/:wsId/notifications" element={<NotificationsPage />} />
|
||||
<Route path="/w/:wsId/members" element={<WorkspacePage />} />
|
||||
<Route path="/join/:wsId/:code" element={<JoinPage />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Workspace } from "../lib/types";
|
||||
import {
|
||||
Book,
|
||||
ChevronDown,
|
||||
Bell,
|
||||
Columns,
|
||||
Inbox,
|
||||
Logo,
|
||||
@@ -15,13 +16,25 @@ import {
|
||||
X,
|
||||
} from "./icons";
|
||||
|
||||
export type Section = "tasks" | "board" | "tickets" | "knowledge" | "members";
|
||||
export type Section =
|
||||
| "tasks"
|
||||
| "board"
|
||||
| "tickets"
|
||||
| "knowledge"
|
||||
| "notifications"
|
||||
| "members";
|
||||
|
||||
const NAV: { key: Section; label: string; to: (id: string) => string; icon: ReactNode }[] = [
|
||||
{ key: "tasks", label: "任务", to: (id) => `/w/${id}`, icon: <Inbox size={16} /> },
|
||||
{ key: "board", label: "看板", to: (id) => `/w/${id}/board`, icon: <Columns size={16} /> },
|
||||
{ key: "tickets", label: "工单", to: (id) => `/w/${id}/tickets`, icon: <TicketIcon size={16} /> },
|
||||
{ key: "knowledge", label: "知识", to: (id) => `/w/${id}/knowledge`, icon: <Book size={16} /> },
|
||||
{
|
||||
key: "notifications",
|
||||
label: "通知",
|
||||
to: (id) => `/w/${id}/notifications`,
|
||||
icon: <Bell size={16} />,
|
||||
},
|
||||
{ key: "members", label: "成员", to: (id) => `/w/${id}/members`, icon: <Users size={16} /> },
|
||||
];
|
||||
|
||||
@@ -73,16 +86,22 @@ export function AppShell({
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const r = await api.get<{ count: number }>(`/workspaces/${wsId}/notifications/unread-count`);
|
||||
if (alive) setUnread(r.count);
|
||||
const r = await api.get<{ unread: number }>(
|
||||
`/workspaces/${wsId}/notifications/unread-count`,
|
||||
);
|
||||
if (alive) setUnread(r.unread ?? 0);
|
||||
} catch {
|
||||
/* 未读数失败不该影响主流程 */
|
||||
}
|
||||
};
|
||||
// 通知页标了已读之后立刻刷新角标,不必等下一个轮询周期
|
||||
const onChange = () => void load();
|
||||
void load();
|
||||
window.addEventListener("notifications:changed", onChange);
|
||||
const timer = window.setInterval(load, 30_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
window.removeEventListener("notifications:changed", onChange);
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [wsId]);
|
||||
@@ -174,7 +193,7 @@ export function AppShell({
|
||||
<Link key={n.key} to={n.to(ws.id)} className={`nav-item ${isActive ? "nav-item-active" : ""}`}>
|
||||
<span className={isActive ? "text-primary-200" : "text-ink-400"}>{n.icon}</span>
|
||||
{n.label}
|
||||
{n.key === "tickets" && unread > 0 ? (
|
||||
{n.key === "notifications" && unread > 0 ? (
|
||||
<span
|
||||
className={`ml-auto rounded-full px-1.5 py-px text-2xs font-semibold tabular-nums ${
|
||||
isActive ? "bg-clay-500 text-white" : "bg-clay-500/12 text-clay-600"
|
||||
|
||||
@@ -178,6 +178,13 @@ export const Inbox = (p: IconProps) => (
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Bell = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M6 9a6 6 0 1 1 12 0c0 3.2.8 4.9 1.6 5.9.4.5 0 1.1-.6 1.1H5c-.6 0-1-.6-.6-1.1C5.2 13.9 6 12.2 6 9Z" />
|
||||
<path d="M10 19a2 2 0 0 0 4 0" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const Sparkle = (p: IconProps) => (
|
||||
<svg {...base(p)}>
|
||||
<path d="M12 3.5 13.6 9l5.4 1.6-5.4 1.6L12 17.5l-1.6-5.3L5 10.6 10.4 9Z" />
|
||||
|
||||
@@ -170,3 +170,15 @@ export interface SearchHit {
|
||||
vector_score: number;
|
||||
lexical_score: number;
|
||||
}
|
||||
|
||||
/** 站内通知:只有"谁做了什么"和"指向哪条事项",详情永远回源取。 */
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
kind: string;
|
||||
item_id: string | null;
|
||||
item_title: string | null;
|
||||
title: string;
|
||||
body: string;
|
||||
read_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { Bell, Check, TicketIcon, Inbox, Clock } from "../components/icons";
|
||||
import { Button, EmptyState, PageHeader, SkeletonRows } from "../components/ui";
|
||||
import { api, ApiError } from "../lib/client";
|
||||
import type { AppNotification } from "../lib/types";
|
||||
|
||||
const PAGE = 30;
|
||||
|
||||
/** 工单类通知点开跳到工单页,其余回任务页——都带 `?open=`,落地即展开那一条。 */
|
||||
function targetOf(wsId: string, n: AppNotification): string {
|
||||
if (!n.item_id) return `/w/${wsId}/notifications`;
|
||||
const isTicket = n.kind.startsWith("ticket_");
|
||||
return isTicket ? `/w/${wsId}/tickets?open=${n.item_id}` : `/w/${wsId}?open=${n.item_id}`;
|
||||
}
|
||||
|
||||
function iconOf(kind: string) {
|
||||
if (kind.startsWith("ticket_")) return <TicketIcon size={15} />;
|
||||
if (kind === "item_commented") return <Inbox size={15} />;
|
||||
return <Bell size={15} />;
|
||||
}
|
||||
|
||||
function ago(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const diff = Math.max(0, Date.now() - then);
|
||||
const min = Math.floor(diff / 60_000);
|
||||
if (min < 1) return "刚刚";
|
||||
if (min < 60) return `${min} 分钟前`;
|
||||
const hour = Math.floor(min / 60);
|
||||
if (hour < 24) return `${hour} 小时前`;
|
||||
const day = Math.floor(hour / 24);
|
||||
if (day < 30) return `${day} 天前`;
|
||||
return new Date(iso).toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
/** 通知页:默认只看未读——"全部"是查历史用的,不是日常入口。 */
|
||||
export function NotificationsPage() {
|
||||
const { wsId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [tab, setTab] = useState<"unread" | "all">("unread");
|
||||
const [rows, setRows] = useState<AppNotification[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(
|
||||
async (reset: boolean) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
unread_only: tab === "unread" ? "true" : "false",
|
||||
limit: String(PAGE),
|
||||
});
|
||||
if (!reset && cursor) params.set("cursor", cursor);
|
||||
const page = await api.get<{
|
||||
notifications: AppNotification[];
|
||||
next_cursor: string | null;
|
||||
has_more: boolean;
|
||||
}>(`/workspaces/${wsId}/notifications?${params.toString()}`);
|
||||
setRows((prev) => (reset ? page.notifications : [...prev, ...page.notifications]));
|
||||
setCursor(page.next_cursor);
|
||||
setHasMore(page.has_more);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "通知加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[wsId, tab, cursor],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setRows([]);
|
||||
setCursor(null);
|
||||
void load(true);
|
||||
// 只在切 tab / 换工作区时重拉,翻页由按钮触发
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [wsId, tab]);
|
||||
|
||||
const unreadCount = useMemo(() => rows.filter((r) => !r.read_at).length, [rows]);
|
||||
|
||||
async function markRead(n: AppNotification) {
|
||||
if (!n.read_at) {
|
||||
try {
|
||||
await api.post(`/workspaces/${wsId}/notifications/${n.id}/read`);
|
||||
} catch {
|
||||
/* 标已读失败不拦着用户点进去看 */
|
||||
}
|
||||
setRows((prev) =>
|
||||
prev.map((r) => (r.id === n.id ? { ...r, read_at: new Date().toISOString() } : r)),
|
||||
);
|
||||
window.dispatchEvent(new Event("notifications:changed"));
|
||||
}
|
||||
navigate(targetOf(wsId, n));
|
||||
}
|
||||
|
||||
async function markAll() {
|
||||
try {
|
||||
await api.post(`/workspaces/${wsId}/notifications/read-all`);
|
||||
setRows((prev) => prev.map((r) => ({ ...r, read_at: r.read_at ?? new Date().toISOString() })));
|
||||
window.dispatchEvent(new Event("notifications:changed"));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell wsId={wsId} active="notifications">
|
||||
<PageHeader
|
||||
eyebrow="事情有动静时,这里会告诉你"
|
||||
title="通知"
|
||||
desc="工单被接受、提交、验收,任务被指派、改状态,有人在你关注的事情下说话,都会落在这里。"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={() => void load(true)} disabled={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button variant="subtle" size="sm" onClick={() => void markAll()} disabled={!unreadCount}>
|
||||
全部已读
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-3 flex items-center gap-1">
|
||||
{(
|
||||
[
|
||||
["unread", "未读"],
|
||||
["all", "全部"],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => setTab(key)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs transition ${
|
||||
tab === key
|
||||
? "bg-ink-900 font-medium text-white"
|
||||
: "text-ink-500 hover:bg-ink-100 hover:text-ink-800"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
{tab === "unread" && unreadCount > 0 ? (
|
||||
<span className="ml-auto text-2xs text-ink-400">还有 {unreadCount} 条没看</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mb-3 rounded-xl border border-rust-100 bg-rust-50 px-4 py-3 text-sm text-rust-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && rows.length === 0 ? (
|
||||
<SkeletonRows rows={4} />
|
||||
) : rows.length === 0 ? (
|
||||
<div className="card">
|
||||
<EmptyState
|
||||
icon={<Bell size={20} />}
|
||||
title={tab === "unread" ? "没有未读通知" : "还没有通知"}
|
||||
hint={
|
||||
tab === "unread"
|
||||
? "有人给你派活、或你关注的事情有进展时,会出现在这里。"
|
||||
: "这里会留下所有历史通知,方便回头找。"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{rows.map((n) => (
|
||||
<li key={n.id} className={`card ${n.read_at ? "" : "border-primary-100 bg-primary-50/40"}`}>
|
||||
<button
|
||||
onClick={() => void markRead(n)}
|
||||
className="flex w-full items-start gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<span
|
||||
className={`mt-0.5 grid h-7 w-7 shrink-0 place-items-center rounded-full ${
|
||||
n.read_at ? "bg-ink-100 text-ink-500" : "bg-primary-500 text-white"
|
||||
}`}
|
||||
>
|
||||
{iconOf(n.kind)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-baseline gap-2">
|
||||
<span className="truncate text-sm font-medium text-ink-900">{n.title}</span>
|
||||
{!n.read_at ? (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-clay-500" />
|
||||
) : null}
|
||||
<span className="ml-auto shrink-0 text-2xs text-ink-400">
|
||||
{ago(n.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
{n.body ? (
|
||||
<span className="mt-1 block truncate text-xs text-ink-500">{n.body}</span>
|
||||
) : null}
|
||||
{n.item_title ? (
|
||||
<span className="mt-1.5 inline-flex items-center gap-1 text-2xs text-ink-400">
|
||||
<Clock size={11} />
|
||||
{n.item_title}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{hasMore ? (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<Button variant="ghost" size="sm" onClick={() => void load(false)} disabled={loading}>
|
||||
加载更早
|
||||
</Button>
|
||||
</div>
|
||||
) : rows.length > 0 && !hasMore ? (
|
||||
<p className="mt-4 text-center text-2xs text-ink-300">
|
||||
<Check size={11} className="mr-1 inline" />
|
||||
没有更多了
|
||||
</p>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { TaskDetail } from "../components/TaskDetail";
|
||||
import { Check, Inbox, Plus, Search } from "../components/icons";
|
||||
@@ -41,7 +41,9 @@ export function TasksPage() {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
// 从通知点进来时带 ?open=<id>,落地即展开那一条
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [openId, setOpenId] = useState<string | null>(searchParams.get("open"));
|
||||
const [activeIdx, setActiveIdx] = useState(0);
|
||||
const reqId = useRef(0);
|
||||
|
||||
@@ -333,7 +335,10 @@ export function TasksPage() {
|
||||
<TaskDetail
|
||||
wsId={wsId}
|
||||
itemId={openId}
|
||||
onClose={() => setOpenId(null)}
|
||||
onClose={() => {
|
||||
setOpenId(null);
|
||||
if (searchParams.get("open")) setSearchParams({}, { replace: true });
|
||||
}}
|
||||
onChanged={(updated) =>
|
||||
setItems((prev) => prev.map((it) => (it.id === updated.id ? updated : it)))
|
||||
}
|
||||
|
||||
+205
-19
@@ -1,12 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { TaskDetail } from "../components/TaskDetail";
|
||||
import { ChevronRight, TicketIcon } from "../components/icons";
|
||||
import { Button, EmptyState, InlineAlert, PageHeader, SkeletonRows, StatusPill } from "../components/ui";
|
||||
import { ChevronRight, Plus, TicketIcon } from "../components/icons";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
InlineAlert,
|
||||
PageHeader,
|
||||
Sheet,
|
||||
SkeletonRows,
|
||||
StatusPill,
|
||||
useToast,
|
||||
} from "../components/ui";
|
||||
import { ApiError, api } from "../lib/client";
|
||||
import { STATUS_LABEL } from "../lib/parse";
|
||||
import type { Item, Ticket } from "../lib/types";
|
||||
import type { Item, Member, Ticket } from "../lib/types";
|
||||
|
||||
const ROLES = [
|
||||
{ key: "assigned", label: "派给我的", desc: "别人等你的" },
|
||||
@@ -25,14 +34,43 @@ const STATUS_FILTERS = [
|
||||
|
||||
export function TicketsPage() {
|
||||
const { wsId = "" } = useParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [role, setRole] = useState<(typeof ROLES)[number]["key"]>("assigned");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
// 从通知点进来时带 ?open=<id>,落地即展开那一条
|
||||
const [openId, setOpenId] = useState<string | null>(searchParams.get("open"));
|
||||
const reqId = useRef(0);
|
||||
const { push: toast } = useToast();
|
||||
|
||||
// 手工开单:这条路径不经过任何任务,所以人得自己选
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [formTitle, setFormTitle] = useState("");
|
||||
const [formBody, setFormBody] = useState("");
|
||||
const [formAssignee, setFormAssignee] = useState("");
|
||||
const [formPriority, setFormPriority] = useState(1);
|
||||
const [formDue, setFormDue] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wsId) return;
|
||||
let alive = true;
|
||||
void api
|
||||
.get<{ items: Member[] }>(`/workspaces/${wsId}/members?limit=100`)
|
||||
.then((page) => {
|
||||
if (alive) setMembers(page.items);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 成员拉不到时,表单里就没人可选,但不该拦住列表 */
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [wsId]);
|
||||
|
||||
const load = useCallback(
|
||||
async (nextCursor: string | null) => {
|
||||
@@ -75,7 +113,8 @@ export function TicketsPage() {
|
||||
void load(null);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 4009) {
|
||||
setError("这条刚被别人改过,已为你刷新列表"); void load(null);
|
||||
setError("这条刚被别人改过,已为你刷新列表");
|
||||
void load(null);
|
||||
return;
|
||||
}
|
||||
setError(err instanceof ApiError ? err.message : "操作失败");
|
||||
@@ -84,6 +123,41 @@ export function TicketsPage() {
|
||||
|
||||
const currentRole = ROLES.find((r) => r.key === role);
|
||||
|
||||
async function createTicket() {
|
||||
if (!formTitle.trim()) {
|
||||
setError("工单得有个标题,别人才能知道要办什么");
|
||||
return;
|
||||
}
|
||||
if (!formAssignee) {
|
||||
setError("先选一个人——工单不指派就等于没人接");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const created = await api.post<Ticket>(`/workspaces/${wsId}/tickets`, {
|
||||
title: formTitle.trim(),
|
||||
body_md: formBody.trim() || null,
|
||||
assignee_id: formAssignee,
|
||||
priority: formPriority,
|
||||
due_at: formDue ? new Date(formDue).toISOString() : null,
|
||||
});
|
||||
setCreating(false);
|
||||
setFormTitle("");
|
||||
setFormBody("");
|
||||
setFormAssignee("");
|
||||
setFormDue("");
|
||||
setFormPriority(1);
|
||||
setRole("requested");
|
||||
toast("工单已开出,对方会收到通知", "success");
|
||||
setOpenId(created.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "开单失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell wsId={wsId} active="tickets">
|
||||
<PageHeader
|
||||
@@ -91,18 +165,24 @@ export function TicketsPage() {
|
||||
desc="待接 → 办理中 → 待验收 → 完成。验收通过后,当初卡住的那条任务会自动回到待办。"
|
||||
eyebrow={<>需要别人搭把手的事,都在这里</>}
|
||||
actions={
|
||||
<select
|
||||
className="input h-9 w-36 text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
aria-label="按状态筛选"
|
||||
>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<>
|
||||
<select
|
||||
className="input h-9 w-36 text-sm"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
aria-label="按状态筛选"
|
||||
>
|
||||
{STATUS_FILTERS.map((s) => (
|
||||
<option key={s.key} value={s.key}>
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="primary" size="sm" onClick={() => setCreating(true)}>
|
||||
<Plus size={14} />
|
||||
新建工单
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -218,11 +298,117 @@ export function TicketsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 手工开单:不经过任务,直接把一件要别人帮忙的事开成工单 */}
|
||||
<Sheet
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
title="新建工单"
|
||||
subtitle="不是从任务升级来的也可以——写清楚要办什么,指定一个人。"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setCreating(false)} disabled={saving}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="primary" onClick={() => void createTicket()} disabled={saving}>
|
||||
{saving ? "正在开单…" : "开单并通知对方"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<label className="label" htmlFor="ticket-title">
|
||||
要办什么
|
||||
</label>
|
||||
<input
|
||||
id="ticket-title"
|
||||
className="input"
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
placeholder="一句能直接看懂的话,例如:核对 9 月发票与报销单"
|
||||
maxLength={200}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="ticket-body">
|
||||
详细说明(可选)
|
||||
</label>
|
||||
<textarea
|
||||
id="ticket-body"
|
||||
className="input min-h-[96px] resize-y text-sm leading-6"
|
||||
value={formBody}
|
||||
onChange={(e) => setFormBody(e.target.value)}
|
||||
placeholder="背景、已有的线索、验收标准…对方看到的就是这些"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="label" htmlFor="ticket-assignee">
|
||||
交给谁
|
||||
</label>
|
||||
<select
|
||||
id="ticket-assignee"
|
||||
className="input"
|
||||
value={formAssignee}
|
||||
onChange={(e) => setFormAssignee(e.target.value)}
|
||||
>
|
||||
<option value="">选择一个人…</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.user_id} value={m.user_id}>
|
||||
{m.nickname || m.user_id.slice(0, 8)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label" htmlFor="ticket-priority">
|
||||
优先级
|
||||
</label>
|
||||
<select
|
||||
id="ticket-priority"
|
||||
className="input"
|
||||
value={formPriority}
|
||||
onChange={(e) => setFormPriority(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>无</option>
|
||||
<option value={1}>低</option>
|
||||
<option value={2}>中</option>
|
||||
<option value={3}>高</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label" htmlFor="ticket-due">
|
||||
希望什么时候办好(可选)
|
||||
</label>
|
||||
<input
|
||||
id="ticket-due"
|
||||
type="datetime-local"
|
||||
className="input"
|
||||
value={formDue}
|
||||
onChange={(e) => setFormDue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="rounded-xl border border-line-200 bg-paper-200/60 px-3.5 py-2.5 text-2xs leading-5 text-ink-500">
|
||||
开出后对方会收到通知,并按「待接 → 办理中 → 待验收 → 完成」推进。
|
||||
手工开的单没有来源任务,验收通过后不会回流任何任务,但同样会沉淀一份知识草稿。
|
||||
</p>
|
||||
</div>
|
||||
</Sheet>
|
||||
|
||||
{openId && (
|
||||
<TaskDetail
|
||||
wsId={wsId}
|
||||
itemId={openId}
|
||||
onClose={() => setOpenId(null)}
|
||||
onClose={() => {
|
||||
setOpenId(null);
|
||||
if (searchParams.get("open")) setSearchParams({}, { replace: true });
|
||||
}}
|
||||
onChanged={(updated: Item) =>
|
||||
setTickets((prev) => prev.map((t) => (t.id === updated.id ? { ...t, ...updated } : t)))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user