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:
+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