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: "travel", 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: "travel_request.approved", label: "travel_request.approved" }, { value: "travel_request.rejected", label: "travel_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 }))} />