From faff86c947250621a7821054a6e9e86a07155bbd Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 8 Mar 2026 13:14:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(P19IM1-3,P19IM1-4):=20=E6=8E=A5=E5=85=A5?= =?UTF-8?q?=E4=B8=9A=E5=8A=A1=E4=BA=8B=E4=BB=B6=E4=B8=8E=E9=80=9A=E7=9F=A5?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E5=90=8E=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/notificationChannels.ts | 91 +++ client/src/components/navigation/NavLinks.tsx | 6 + client/src/pages/NotificationChannelsPage.tsx | 641 ++++++++++++++++++ client/src/router/index.tsx | 10 + server/src/routes/assetMovements.js | 143 ++++ server/src/routes/inventoryChecks.js | 60 ++ server/src/routes/purchaseRequests.js | 21 +- server/src/routes/reimbursements.js | 40 +- server/src/routes/workflow.js | 391 +++++++---- 9 files changed, 1268 insertions(+), 135 deletions(-) create mode 100644 client/src/api/notificationChannels.ts create mode 100644 client/src/pages/NotificationChannelsPage.tsx diff --git a/client/src/api/notificationChannels.ts b/client/src/api/notificationChannels.ts new file mode 100644 index 00000000..ce601916 --- /dev/null +++ b/client/src/api/notificationChannels.ts @@ -0,0 +1,91 @@ +import { apiRequestData } from "./http"; + +export type NotificationChannelType = "wecom" | "wechat_work"; +export type NotificationChannelMode = "mock" | "wecom_webhook"; +export type NotificationDomainScope = + | "workflow" + | "reimbursement" + | "leave" + | "procurement" + | "asset" + | "inventory"; + +export type NotificationChannelRouting = { + roleCodes: string[]; + domainScopes: NotificationDomainScope[]; + allowedTypes: string[]; +}; + +export type NotificationChannelConfig = { + mode: NotificationChannelMode; + webhookUrl: string | null; + secret: string | null; + timeoutMs: number; + routing: NotificationChannelRouting; +}; + +export type NotificationChannelTemplate = { + title: string; + body: string; + footer: string; + deepLinkLabel: string; +}; + +export type NotificationChannel = { + id: string; + channelType: NotificationChannelType; + name: string; + enabled: boolean; + configJson: NotificationChannelConfig; + templateJson: NotificationChannelTemplate; + createdAt: string | null; + updatedAt: string | null; +}; + +export type NotificationChannelListResponse = { + items: NotificationChannel[]; +}; + +export type NotificationChannelPayload = { + channelType: "wecom"; + name: string; + enabled: boolean; + configJson: NotificationChannelConfig; + templateJson: NotificationChannelTemplate; +}; + +export type NotificationChannelTestResponse = { + channel: NotificationChannel; + testResult: { + ok: boolean; + code: string; + previewMessage: string | null; + response: unknown; + skipped: boolean; + }; +}; + +export async function fetchNotificationChannels() { + return apiRequestData("/api/notifications/channels"); +} + +export async function createNotificationChannel(payload: NotificationChannelPayload) { + return apiRequestData<{ channel: NotificationChannel }>("/api/notifications/channels", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function updateNotificationChannel(id: string, payload: Omit) { + return apiRequestData<{ channel: NotificationChannel }>(`/api/notifications/channels/${id}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function testNotificationChannel(id: string) { + return apiRequestData(`/api/notifications/channels/${id}/test`, { + method: "POST", + body: JSON.stringify({}), + }); +} diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index af902882..26ed533f 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -102,6 +102,12 @@ export const NAV_ITEMS: NavItem[] = [ icon: "dataset", anyOf: ["masterdata.view", "masterdata.manage"], }, + { + to: "/system/notification-channels", + label: "通知渠道", + icon: "notifications_active", + anyOf: ["notification.channel.manage"], + }, { to: "/system/approval-rules", label: "审批规则", diff --git a/client/src/pages/NotificationChannelsPage.tsx b/client/src/pages/NotificationChannelsPage.tsx new file mode 100644 index 00000000..7426ca31 --- /dev/null +++ b/client/src/pages/NotificationChannelsPage.tsx @@ -0,0 +1,641 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { + createNotificationChannel, + fetchNotificationChannels, + testNotificationChannel, + updateNotificationChannel, + type NotificationChannel, + type NotificationChannelConfig, + type NotificationChannelPayload, + type NotificationChannelTemplate, +} from "../api/notificationChannels"; +import { isApiError } from "../api/types"; +import { PageHeader } from "../components/common/PageHeader"; +import { TableSkeleton } from "../components/common/TableSkeleton"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Label } from "../components/ui/label"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../components/ui/table"; +import { Textarea } from "../components/ui/textarea"; + +type ChannelFormState = { + channelType: "wecom"; + name: string; + enabled: boolean; + mode: NotificationChannelConfig["mode"]; + webhookUrl: string; + secret: string; + timeoutMs: string; + roleCodes: string[]; + domainScopes: NotificationChannelConfig["routing"]["domainScopes"]; + allowedTypes: string[]; + title: string; + body: string; + footer: string; + deepLinkLabel: string; +}; + +type TestResultState = { + channelId: string; + channelName: string; + ok: boolean; + code: string; + skipped: boolean; + previewMessage: string | null; + response: unknown; +}; + +const ROLE_OPTIONS = [ + { value: "employee", label: "employee" }, + { value: "manager", label: "manager" }, + { value: "ceo", label: "ceo" }, + { value: "finance", label: "finance" }, + { value: "finance_mgr", label: "finance_mgr" }, + { value: "administration", label: "administration" }, + { value: "hr", label: "hr" }, + { value: "admin", label: "admin" }, +] as const; + +const DOMAIN_SCOPE_OPTIONS = [ + { value: "workflow", label: "审批待办" }, + { value: "reimbursement", label: "报销" }, + { value: "leave", label: "请假" }, + { value: "procurement", label: "采购" }, + { value: "asset", label: "资产" }, + { value: "inventory", label: "盘点" }, +] as const; + +const NOTIFICATION_TYPE_OPTIONS = [ + { value: "workflow.task_assigned", label: "workflow.task_assigned" }, + { value: "reimbursement.approved", label: "reimbursement.approved" }, + { value: "reimbursement.rejected", label: "reimbursement.rejected" }, + { value: "reimbursement.returned", label: "reimbursement.returned" }, + { value: "reimbursement.paid", label: "reimbursement.paid" }, + { value: "leave.approved", label: "leave.approved" }, + { value: "leave.rejected", label: "leave.rejected" }, + { value: "purchase_request.approved", label: "purchase_request.approved" }, + { value: "purchase_request.rejected", label: "purchase_request.rejected" }, + { value: "asset.assigned", label: "asset.assigned" }, + { value: "asset.returned", label: "asset.returned" }, + { value: "asset.transferred", label: "asset.transferred" }, + { value: "inventory_check.completed", label: "inventory_check.completed" }, +] as const; + +function resolveError(error: unknown) { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + if (error instanceof Error) { + return error.message; + } + return "请求失败,请稍后重试。"; +} + +function buildInitialForm(): ChannelFormState { + return { + channelType: "wecom", + name: "", + enabled: true, + mode: "mock", + webhookUrl: "", + secret: "", + timeoutMs: "5000", + roleCodes: [], + domainScopes: [], + allowedTypes: [], + title: "{{title}}", + body: "{{body}}\n\n查看详情:{{detailUrl}}", + footer: "来自海音达工作台", + deepLinkLabel: "查看详情", + }; +} + +function mapChannelToForm(channel: NotificationChannel): ChannelFormState { + return { + channelType: "wecom", + name: channel.name, + enabled: channel.enabled, + mode: channel.configJson.mode, + webhookUrl: channel.configJson.webhookUrl ?? "", + secret: channel.configJson.secret ?? "", + timeoutMs: String(channel.configJson.timeoutMs ?? 5000), + roleCodes: channel.configJson.routing.roleCodes ?? [], + domainScopes: channel.configJson.routing.domainScopes ?? [], + allowedTypes: channel.configJson.routing.allowedTypes ?? [], + title: channel.templateJson.title, + body: channel.templateJson.body, + footer: channel.templateJson.footer, + deepLinkLabel: channel.templateJson.deepLinkLabel, + }; +} + +function toggleSelection(values: string[], value: string) { + return values.includes(value) ? values.filter((item) => item !== value) : [...values, value]; +} + +function formatDateTime(value: string | null) { + if (!value) return "-"; + return value.slice(0, 19).replace("T", " "); +} + +function formatChannelType(channelType: string) { + return channelType === "wechat_work" ? "企微(兼容别名)" : "企微"; +} + +function formatMode(mode: string) { + return mode === "wecom_webhook" ? "Webhook" : "Mock"; +} + +function formatSummary(values: string[], labelMap: Record, emptyLabel: string) { + if (!values.length) return emptyLabel; + return values.map((value) => labelMap[value] ?? value).join("、"); +} + +function buildPayload(form: ChannelFormState): NotificationChannelPayload { + const templateJson: NotificationChannelTemplate = { + title: form.title.trim() || "{{title}}", + body: form.body.trim() || "{{body}}\n\n查看详情:{{detailUrl}}", + footer: form.footer.trim() || "来自海音达工作台", + deepLinkLabel: form.deepLinkLabel.trim() || "查看详情", + }; + + return { + channelType: form.channelType, + name: form.name.trim(), + enabled: form.enabled, + configJson: { + mode: form.mode, + webhookUrl: form.webhookUrl.trim() || null, + secret: form.secret.trim() || null, + timeoutMs: Math.max(100, Number(form.timeoutMs || 5000)), + routing: { + roleCodes: form.roleCodes, + domainScopes: form.domainScopes, + allowedTypes: form.allowedTypes, + }, + }, + templateJson, + }; +} + +const roleLabelMap = Object.fromEntries(ROLE_OPTIONS.map((item) => [item.value, item.label])); +const domainLabelMap = Object.fromEntries(DOMAIN_SCOPE_OPTIONS.map((item) => [item.value, item.label])); + +export function NotificationChannelsPage() { + const queryClient = useQueryClient(); + const [error, setError] = useState(""); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState(buildInitialForm); + const [lastTestResult, setLastTestResult] = useState(null); + + const channelsQuery = useQuery({ + queryKey: ["notification-channels"], + queryFn: () => fetchNotificationChannels(), + }); + + const saveMutation = useMutation({ + mutationFn: async () => { + if (editingId) { + const payload = buildPayload(form); + return updateNotificationChannel(editingId, { + name: payload.name, + enabled: payload.enabled, + configJson: payload.configJson, + templateJson: payload.templateJson, + }); + } + return createNotificationChannel(buildPayload(form)); + }, + onSuccess: async ({ channel }) => { + await queryClient.invalidateQueries({ queryKey: ["notification-channels"] }); + setEditingId(channel.id); + setForm(mapChannelToForm(channel)); + setError(""); + toast.success(editingId ? "通知渠道已更新" : "通知渠道已创建"); + }, + onError: (mutationError) => { + const message = resolveError(mutationError); + setError(message); + toast.error(message); + }, + }); + + const testMutation = useMutation({ + mutationFn: async (channel: NotificationChannel) => { + const result = await testNotificationChannel(channel.id); + return { + channelName: channel.name, + ...result, + }; + }, + onSuccess: (result) => { + setLastTestResult({ + channelId: result.channel.id, + channelName: result.channelName, + ok: result.testResult.ok, + code: result.testResult.code, + skipped: result.testResult.skipped, + previewMessage: result.testResult.previewMessage, + response: result.testResult.response, + }); + if (result.testResult.ok) { + toast.success("测试发送成功"); + } else { + toast.error(`测试发送失败:${result.testResult.code}`); + } + }, + onError: (mutationError) => { + const message = resolveError(mutationError); + setError(message); + toast.error(message); + }, + }); + + function startCreate() { + setEditingId(null); + setForm(buildInitialForm()); + setLastTestResult(null); + setError(""); + } + + function startEdit(channel: NotificationChannel) { + setEditingId(channel.id); + setForm(mapChannelToForm(channel)); + setError(""); + } + + const channels = channelsQuery.data?.items ?? []; + + return ( +
+ + 新建渠道 + + } + /> + + {error ? ( +

{error}

+ ) : null} + +
+
+

渠道列表

+

当前共 {channels.length} 条渠道配置

+
+ + {channelsQuery.isLoading ? : null} + + {channelsQuery.isError ? ( +

+ {resolveError(channelsQuery.error)} +

+ ) : null} + + {!channelsQuery.isLoading && !channelsQuery.isError ? ( +
+ + + + 名称 + 类型 + 模式 + 启用状态 + 业务域 + 通知类型数 + 角色范围 + 最近更新时间 + 操作 + + + + {channels.length ? ( + channels.map((channel) => ( + + {channel.name} + {formatChannelType(channel.channelType)} + {formatMode(channel.configJson.mode)} + {channel.enabled ? "启用" : "停用"} + + {formatSummary(channel.configJson.routing.domainScopes, domainLabelMap, "全部业务域")} + + {channel.configJson.routing.allowedTypes.length || "全部"} + + {formatSummary(channel.configJson.routing.roleCodes, roleLabelMap, "全部角色")} + + {formatDateTime(channel.updatedAt)} + +
+ + +
+
+
+ )) + ) : ( + + + 暂无通知渠道,请先创建企微渠道。 + + + )} +
+
+
+ ) : null} +
+ +
+
+
+
+

+ {editingId ? "编辑通知渠道" : "新建通知渠道"} +

+

+ 通过角色 / 业务域 / 通知类型三维控制 IM 群路由,避免默认全量广播。 +

+
+ {editingId ? ( + + ) : null} +
+ +
+
+ + +
+ +
+ + setForm((prev) => ({ ...prev, name: event.target.value }))} + /> +
+ +
+ + +
+ +
+ + setForm((prev) => ({ ...prev, timeoutMs: event.target.value }))} + /> +
+ +
+ + setForm((prev) => ({ ...prev, webhookUrl: event.target.value }))} + /> +
+ +
+ + setForm((prev) => ({ ...prev, secret: event.target.value }))} + /> +
+
+ + + +
+
+
+

角色范围

+

留空表示不限角色。

+
+
+ {ROLE_OPTIONS.map((option) => ( + + ))} +
+
+ +
+
+

业务域

+

留空表示不限业务域。

+
+
+ {DOMAIN_SCOPE_OPTIONS.map((option) => ( + + ))} +
+
+ +
+
+

通知类型

+

留空表示消费所有 IM 一期支持类型。

+
+
+ {NOTIFICATION_TYPE_OPTIONS.map((option) => ( + + ))} +
+
+
+ +
+
+ + setForm((prev) => ({ ...prev, title: event.target.value }))} + /> +
+ +
+ + setForm((prev) => ({ ...prev, footer: event.target.value }))} + /> +
+ +
+ +