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:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user