'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useGSAP } from '@gsap/react' import gsap from 'gsap' import { Activity, AlertTriangle, Check, ChevronRight, Clipboard, KeyRound, Plus, RefreshCw, Send, Settings2, Users, X, } from 'lucide-react' gsap.registerPlugin(useGSAP) type PanelView = 'projects' | 'recipients' | 'test' | 'deliveries' type RecipientMode = 'userId' | 'alias' interface NotificationRecipient { alias: string userId: string } interface DeliverySummary { lastStatus: string | null lastSentAt: string | null recentFailedCount: number } export interface NotificationProject { name: string projectCode: string isActive: boolean allowedHosts: string[] recipientMode: RecipientMode recipientCount: number recipients: NotificationRecipient[] updatedAt: string | null deliverySummary: DeliverySummary } interface NotificationDelivery { id: string projectCode: string status: string recipientAlias: string | null recipientUserIdMasked: string | null title: string | null lastErrorCode: string | null lastErrorMessage: string | null createdAt: string updatedAt: string } interface ProjectFormState { name: string projectCode: string isActive: boolean allowedHostsText: string recipientMode: RecipientMode recipients: NotificationRecipient[] } interface OneTimeSecret { projectCode: string secret: string env: Record reason: 'created' | 'rotated' } interface NotificationCenterPanelProps { showToast: (message: string, type?: 'success' | 'error') => void } const EMPTY_PROJECT_FORM: ProjectFormState = { name: '', projectCode: '', isActive: true, allowedHostsText: '', recipientMode: 'userId', recipients: [], } const DELIVERY_STATUSES = [ ['all', '全部状态'], ['pending', '等待发送'], ['sending', '发送中'], ['retrying', '等待重试'], ['sent', '已送达钉钉'], ['unread', '员工未读'], ['read', '员工已读'], ['failed', '发送失败'], ['expired', '已过期'], ] as const const VIEW_ITEMS: Array<{ id: PanelView label: string description: string icon: typeof Activity }> = [ { id: 'projects', label: '接入项目', description: '项目、域名与密钥', icon: Settings2 }, { id: 'recipients', label: '收件人花名', description: '花名与钉钉账号', icon: Users }, { id: 'test', label: '测试通知', description: '向指定员工验证链路', icon: Send }, { id: 'deliveries', label: '投递记录', description: '状态与故障处理', icon: Activity }, ] function normalizeRecipient(input: unknown): NotificationRecipient | null { if (!input || typeof input !== 'object') return null const item = input as Record const alias = String(item.alias || item.recipientAlias || '').trim() const userId = String(item.userId || item.recipientUserId || '').trim() if (!alias || !userId) return null return { alias, userId } } function normalizeProject(input: unknown): NotificationProject { const item = (input || {}) as Record const recipients = Array.isArray(item.recipients) ? item.recipients.map(normalizeRecipient).filter((value): value is NotificationRecipient => Boolean(value)) : [] const summary = (item.deliverySummary || {}) as Record return { name: String(item.name || item.displayName || item.projectName || item.projectCode || '未命名项目'), projectCode: String(item.projectCode || item.code || ''), isActive: item.isActive !== false && item.enabled !== false, allowedHosts: Array.isArray(item.allowedHosts) ? item.allowedHosts.map((value) => String(value)).filter(Boolean) : [], recipientMode: item.recipientMode === 'alias' || item.recipientMode === 'userId_or_alias' ? 'alias' : 'userId', recipientCount: Number(item.recipientCount ?? recipients.length ?? 0), recipients, updatedAt: item.updatedAt ? String(item.updatedAt) : null, deliverySummary: { lastStatus: summary.lastStatus ? String(summary.lastStatus) : null, lastSentAt: summary.lastSentAt ? String(summary.lastSentAt) : null, recentFailedCount: Number(summary.recentFailedCount || 0), }, } } function normalizeDelivery(input: unknown): NotificationDelivery { const item = (input || {}) as Record const rawRecipientUserId = String(item.recipientUserId || '').trim() const recipientUserIdMasked = item.recipientUserIdMasked ? String(item.recipientUserIdMasked) : rawRecipientUserId ? rawRecipientUserId.length <= 6 ? '***' : `${rawRecipientUserId.slice(0, 3)}***${rawRecipientUserId.slice(-3)}` : null return { id: String(item.id || ''), projectCode: String(item.projectCode || ''), status: String(item.status || 'pending'), recipientAlias: item.recipientAlias ? String(item.recipientAlias) : null, recipientUserIdMasked, title: item.businessTitle ? String(item.businessTitle) : item.title ? String(item.title) : null, lastErrorCode: item.lastErrorCode ? String(item.lastErrorCode) : null, lastErrorMessage: item.lastErrorMessage ? String(item.lastErrorMessage) : null, createdAt: String(item.createdAt || ''), updatedAt: String(item.updatedAt || item.createdAt || ''), } } function formatDateTime(value: string | null) { if (!value) return '暂无' const date = new Date(value) return Number.isNaN(date.getTime()) ? '暂无' : date.toLocaleString('zh-CN') } function parseAllowedHosts(value: string) { return Array.from( new Set( value .split(/[\n,,]/) .map((host) => { const normalized = host.trim() if (!normalized) return '' try { return new URL(normalized.includes('://') ? normalized : `https://${normalized}`).hostname.toLowerCase() } catch { return normalized.toLowerCase() } }) .filter(Boolean), ), ) } function getStatusLabel(status: string | null) { return DELIVERY_STATUSES.find(([value]) => value === status)?.[1] || status || '暂无投递' } function getStatusClassName(status: string | null) { if (status === 'sent' || status === 'read') return 'bg-[#e8f7f2] text-[#087052]' if (status === 'failed' || status === 'expired') return 'bg-[#fef0ee] text-[#b42318]' if (status === 'sending' || status === 'retrying') return 'bg-[#fff7e8] text-[#9a4d08]' return 'bg-[#eef2f6] text-[#4f6077]' } function getDeliveryAdvice(delivery: NotificationDelivery) { if (delivery.lastErrorCode === 'DELIVERY_OUTCOME_UNKNOWN') { return '钉钉是否收到尚不确定。请先联系收件人或在钉钉侧人工核验,不要直接重复发送。' } if (delivery.lastErrorCode === 'RECIPIENT_NOT_FOUND') { return '未找到已登记的收件人。请核对花名或钉钉 userId 后重新测试。' } if (delivery.lastErrorCode === 'ACTION_URL_NOT_ALLOWED') { return '按钮地址不在该项目允许的域名内。请先更新 allowedHosts。' } if (delivery.status === 'failed') { return '通知发送失败。请根据下方原因修正配置,再重新发起测试。' } if (delivery.status === 'expired') { return '该通知已经超过有效期,不会继续重试。请确认业务是否仍需要通知。' } return null } async function readJsonResponse(response: Response) { const data = await response.json().catch(() => null) if (!response.ok) { const message = data?.error?.message || data?.message || (typeof data?.error === 'string' ? data.error : null) || '统一通知中心暂时不可用,请稍后重试' throw new Error(message) } return data || {} } function unwrapProxyData(input: unknown): Record { const value = input && typeof input === 'object' ? input as Record : {} return value.data && typeof value.data === 'object' ? value.data : value } function useDialogKeyboard(open: boolean, close: () => void) { useEffect(() => { if (!open) return const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') close() } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [close, open]) } export default function NotificationCenterPanel({ showToast }: NotificationCenterPanelProps) { const rootRef = useRef(null) const [activeView, setActiveView] = useState('projects') const [projects, setProjects] = useState([]) const [projectsLoading, setProjectsLoading] = useState(true) const [projectsError, setProjectsError] = useState(null) const [drawerOpen, setDrawerOpen] = useState(false) const [editingCode, setEditingCode] = useState(null) const [projectForm, setProjectForm] = useState(EMPTY_PROJECT_FORM) const [savingProject, setSavingProject] = useState(false) const [lastCreatedCode, setLastCreatedCode] = useState(null) const [oneTimeSecret, setOneTimeSecret] = useState(null) const [rotateProject, setRotateProject] = useState(null) const [rotatingSecret, setRotatingSecret] = useState(false) const [recipientProjectCode, setRecipientProjectCode] = useState('') const [recipientRows, setRecipientRows] = useState([]) const [savingRecipients, setSavingRecipients] = useState(false) const [testProjectCode, setTestProjectCode] = useState('') const [testRecipientMode, setTestRecipientMode] = useState('userId') const [testRecipient, setTestRecipient] = useState('') const [testTitle, setTestTitle] = useState('统一通知中心测试') const [testBody, setTestBody] = useState('这是一条由超级管理员发起的链路测试通知。') const [testButtonLabel, setTestButtonLabel] = useState('查看详情') const [testActionUrl, setTestActionUrl] = useState('') const [confirmTestOpen, setConfirmTestOpen] = useState(false) const [sendingTest, setSendingTest] = useState(false) const [deliveryProjectFilter, setDeliveryProjectFilter] = useState('all') const [deliveryStatusFilter, setDeliveryStatusFilter] = useState('all') const [deliveries, setDeliveries] = useState([]) const [deliveriesLoading, setDeliveriesLoading] = useState(false) const [deliveriesError, setDeliveriesError] = useState(null) const loadProjects = useCallback(async () => { setProjectsLoading(true) setProjectsError(null) try { const data = await readJsonResponse(await fetch('/api/admin/notification-center/projects?includeRecipients=1')) const payload = unwrapProxyData(data) const rawProjects = payload.projects || payload.data || [] const nextProjects = Array.isArray(rawProjects) ? rawProjects.map(normalizeProject) : [] setProjects(nextProjects) setRecipientProjectCode((current) => current || nextProjects[0]?.projectCode || '') setTestProjectCode((current) => current || nextProjects[0]?.projectCode || '') } catch (error) { const message = error instanceof Error ? error.message : '统一通知中心暂时不可用,请稍后重试' setProjectsError(message) } finally { setProjectsLoading(false) } }, []) const loadDeliveries = useCallback(async () => { setDeliveriesLoading(true) setDeliveriesError(null) try { const params = new URLSearchParams({ limit: '50' }) if (deliveryProjectFilter !== 'all') params.set('projectCode', deliveryProjectFilter) if (deliveryStatusFilter !== 'all') params.set('status', deliveryStatusFilter) const data = await readJsonResponse( await fetch(`/api/admin/notification-center/deliveries?${params.toString()}`), ) const payload = unwrapProxyData(data) const rawDeliveries = payload.deliveries || payload.data || [] setDeliveries(Array.isArray(rawDeliveries) ? rawDeliveries.map(normalizeDelivery) : []) } catch (error) { setDeliveriesError(error instanceof Error ? error.message : '投递记录加载失败,请稍后重试') } finally { setDeliveriesLoading(false) } }, [deliveryProjectFilter, deliveryStatusFilter]) useEffect(() => { void loadProjects() }, [loadProjects]) useEffect(() => { if (activeView === 'deliveries') void loadDeliveries() }, [activeView, loadDeliveries]) const selectedRecipientProject = useMemo( () => projects.find((project) => project.projectCode === recipientProjectCode) || null, [projects, recipientProjectCode], ) const selectedTestProject = useMemo( () => projects.find((project) => project.projectCode === testProjectCode) || null, [projects, testProjectCode], ) useEffect(() => { setRecipientRows(selectedRecipientProject?.recipients || []) }, [selectedRecipientProject]) useEffect(() => { if (!selectedTestProject) return setTestRecipientMode(selectedTestProject.recipientMode) setTestRecipient('') }, [selectedTestProject]) useGSAP( () => { const mm = gsap.matchMedia() mm.add( { reduceMotion: '(prefers-reduced-motion: reduce)' }, (context) => { const reduceMotion = Boolean(context.conditions?.reduceMotion) gsap.fromTo( '[data-nc-view]', { autoAlpha: 0, y: reduceMotion ? 0 : 8 }, { autoAlpha: 1, y: 0, duration: reduceMotion ? 0 : 0.2, ease: 'power2.out' }, ) }, ) return () => mm.revert() }, { scope: rootRef, dependencies: [activeView], revertOnUpdate: true }, ) useGSAP( () => { if (!drawerOpen && !oneTimeSecret) return const mm = gsap.matchMedia() mm.add( { reduceMotion: '(prefers-reduced-motion: reduce)' }, (context) => { const reduceMotion = Boolean(context.conditions?.reduceMotion) if (drawerOpen) { gsap.fromTo( '[data-nc-drawer]', { autoAlpha: 0, x: reduceMotion ? 0 : 24 }, { autoAlpha: 1, x: 0, duration: reduceMotion ? 0 : 0.22, ease: 'power2.out' }, ) } if (oneTimeSecret) { gsap.fromTo( '[data-secret-reveal]', { autoAlpha: 0, y: reduceMotion ? 0 : 10 }, { autoAlpha: 1, y: 0, duration: reduceMotion ? 0 : 0.2, ease: 'power2.out' }, ) } }, ) return () => mm.revert() }, { scope: rootRef, dependencies: [drawerOpen, oneTimeSecret], revertOnUpdate: true }, ) useGSAP( () => { if (!lastCreatedCode) return const mm = gsap.matchMedia() mm.add( { reduceMotion: '(prefers-reduced-motion: reduce)' }, (context) => { if (context.conditions?.reduceMotion) return gsap.fromTo( `[data-project-code="${lastCreatedCode}"]`, { autoAlpha: 0, y: 8 }, { autoAlpha: 1, y: 0, duration: 0.22, ease: 'power2.out', clearProps: 'opacity,visibility,transform' }, ) }, ) return () => mm.revert() }, { scope: rootRef, dependencies: [lastCreatedCode, projects], revertOnUpdate: true }, ) const closeDrawer = () => { setDrawerOpen(false) setEditingCode(null) setProjectForm(EMPTY_PROJECT_FORM) } useDialogKeyboard(drawerOpen, closeDrawer) useDialogKeyboard(Boolean(rotateProject), () => setRotateProject(null)) useDialogKeyboard(Boolean(oneTimeSecret), () => setOneTimeSecret(null)) useDialogKeyboard(confirmTestOpen, () => setConfirmTestOpen(false)) const openCreateProject = () => { setEditingCode(null) setProjectForm(EMPTY_PROJECT_FORM) setDrawerOpen(true) } const openEditProject = (project: NotificationProject) => { setEditingCode(project.projectCode) setProjectForm({ name: project.name, projectCode: project.projectCode, isActive: project.isActive, allowedHostsText: project.allowedHosts.join('\n'), recipientMode: project.recipientMode, recipients: project.recipients, }) setDrawerOpen(true) } const addProjectRecipient = () => { setProjectForm((current) => ({ ...current, recipients: [...current.recipients, { alias: '', userId: '' }], })) } const updateProjectRecipient = (index: number, field: keyof NotificationRecipient, value: string) => { setProjectForm((current) => ({ ...current, recipients: current.recipients.map((recipient, recipientIndex) => recipientIndex === index ? { ...recipient, [field]: value } : recipient, ), })) } const saveProject = async () => { if (!projectForm.name.trim()) { showToast('请填写项目中文名称', 'error') return } if (!projectForm.projectCode.trim()) { showToast('请填写 projectCode', 'error') return } const normalizedCode = projectForm.projectCode.trim() if (!/^[a-z][a-z0-9_-]{2,63}$/.test(normalizedCode)) { showToast('projectCode 需以小写字母开头,只能包含小写字母、数字、下划线和短横线', 'error') return } const recipients = projectForm.recipients .map((recipient) => ({ alias: recipient.alias.trim(), userId: recipient.userId.trim() })) .filter((recipient) => recipient.alias || recipient.userId) if (recipients.some((recipient) => !recipient.alias || !recipient.userId)) { showToast('每条花名都必须同时填写花名和钉钉 userId', 'error') return } const normalizedAliases = recipients.map((recipient) => recipient.alias.replace(/\s+/g, '').toLocaleLowerCase('zh-CN')) if (new Set(normalizedAliases).size !== normalizedAliases.length) { showToast('同一项目内不能登记重复花名', 'error') return } setSavingProject(true) try { const payload = { name: projectForm.name.trim(), projectCode: normalizedCode, isActive: projectForm.isActive, allowedHosts: parseAllowedHosts(projectForm.allowedHostsText), recipientMode: projectForm.recipientMode, recipients, } const response = await fetch( editingCode ? `/api/admin/notification-center/projects/${encodeURIComponent(editingCode)}` : '/api/admin/notification-center/projects', { method: editingCode ? 'PATCH' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, ) const data = await readJsonResponse(response) closeDrawer() await loadProjects() if (!editingCode) { const responsePayload = unwrapProxyData(data) setLastCreatedCode(normalizedCode) if (responsePayload.secret) { setOneTimeSecret({ projectCode: normalizedCode, secret: String(responsePayload.secret), env: responsePayload.env && typeof responsePayload.env === 'object' ? responsePayload.env : {}, reason: 'created', }) } } showToast(editingCode ? '接入项目已更新' : '接入项目已创建', 'success') } catch (error) { showToast(error instanceof Error ? error.message : '接入项目保存失败', 'error') } finally { setSavingProject(false) } } const confirmRotateSecret = async () => { if (!rotateProject) return setRotatingSecret(true) try { const data = await readJsonResponse( await fetch( `/api/admin/notification-center/projects/${encodeURIComponent(rotateProject.projectCode)}/rotate-secret`, { method: 'POST' }, ), ) const responsePayload = unwrapProxyData(data) setRotateProject(null) setOneTimeSecret({ projectCode: rotateProject.projectCode, secret: String(responsePayload.secret || ''), env: responsePayload.env && typeof responsePayload.env === 'object' ? responsePayload.env : {}, reason: 'rotated', }) showToast('项目密钥已轮换,请立即更新调用方环境变量', 'success') } catch (error) { showToast(error instanceof Error ? error.message : '项目密钥轮换失败', 'error') } finally { setRotatingSecret(false) } } const saveRecipients = async () => { if (!recipientProjectCode) return const rows = recipientRows .map((recipient) => ({ alias: recipient.alias.trim(), userId: recipient.userId.trim() })) .filter((recipient) => recipient.alias || recipient.userId) if (rows.some((row) => !row.alias || !row.userId)) { showToast('每条花名都必须同时填写花名和钉钉 userId', 'error') return } const aliases = rows.map((row) => row.alias.replace(/\s+/g, '').toLocaleLowerCase('zh-CN')) if (new Set(aliases).size !== aliases.length) { showToast('同一项目内不能登记重复花名', 'error') return } setSavingRecipients(true) try { await readJsonResponse( await fetch( `/api/admin/notification-center/projects/${encodeURIComponent(recipientProjectCode)}/recipients`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recipients: rows }), }, ), ) await loadProjects() showToast('收件人花名已保存', 'success') } catch (error) { showToast(error instanceof Error ? error.message : '花名保存失败', 'error') } finally { setSavingRecipients(false) } } const resolvedTestRecipient = useMemo(() => { if (!selectedTestProject || !testRecipient.trim()) return null if (testRecipientMode === 'userId') { return { label: `钉钉 userId:${testRecipient.trim()}`, userId: testRecipient.trim() } } const recipient = selectedTestProject.recipients.find( (item) => item.alias === testRecipient, ) return recipient ? { label: `${recipient.alias}(${recipient.userId})`, userId: recipient.userId } : null }, [selectedTestProject, testRecipient, testRecipientMode]) const sendTestNotification = async () => { if (!selectedTestProject || !resolvedTestRecipient) return setSendingTest(true) try { const payload = { recipientAlias: testRecipientMode === 'alias' ? testRecipient : undefined, recipientUserId: testRecipientMode === 'userId' ? testRecipient.trim() : undefined, title: testTitle.trim(), content: testBody.trim(), actionTitle: testButtonLabel.trim() || undefined, actionUrl: testActionUrl.trim() || undefined, } await readJsonResponse( await fetch( `/api/admin/notification-center/projects/${encodeURIComponent(selectedTestProject.projectCode)}/test`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, ), ) setConfirmTestOpen(false) showToast('测试通知已进入发送队列', 'success') } catch (error) { setConfirmTestOpen(false) const message = error instanceof Error ? error.message : '测试通知发送失败' showToast(`${message}。页面没有自动重发,请先到投递记录核验是否已经发送。`, 'error') } finally { setSendingTest(false) } } const copyText = async (value: string, label: string) => { try { await navigator.clipboard.writeText(value) showToast(`${label}已复制`, 'success') } catch { showToast('复制失败,请手动选择文本复制', 'error') } } const enabledProjectCount = projects.filter((project) => project.isActive).length const aliasCount = projects.reduce((total, project) => total + project.recipientCount, 0) const failedCount = projects.reduce( (total, project) => total + project.deliverySummary.recentFailedCount, 0, ) return (

统一通知中心

管理由统一钉钉机器人发送员工单聊的接入项目、花名和投递状态。素材平台向外部系统发送 Webhook 的能力位于“素材外发”。

{[ ['接入项目', String(projects.length), '当前已登记'], ['正在使用', String(enabledProjectCount), '已启用项目'], ['收件人花名', String(aliasCount), '已登记映射'], ['近期失败', String(failedCount), failedCount > 0 ? '需要处理' : '未发现异常'], ].map(([label, value, hint]) => (
{label}
{value} 0 ? 'text-[#b42318]' : 'text-[#66758b]'}`}> {hint}
))}
{projectsError && (
)} {activeView === 'projects' && (

接入项目

新增项目后只会展示一次项目密钥,请立即保存到调用方环境变量。

{projectsLoading ? (
{[0, 1, 2].map((item) =>
)}
) : projects.length === 0 ? (
) : (
项目允许跳转域名最近投递花名更新时间操作
{projects.map((project) => (
{project.name} {project.isActive ? '使用中' : '已停用'}
{project.projectCode}
允许域名: {project.allowedHosts.length > 0 ? project.allowedHosts.slice(0, 2).join('、') : '未配置'} {project.allowedHosts.length > 2 ? ` 等 ${project.allowedHosts.length} 个` : ''}
{getStatusLabel(project.deliverySummary.lastStatus)} {project.deliverySummary.recentFailedCount > 0 && (

近期失败 {project.deliverySummary.recentFailedCount} 条

)}
花名:{project.recipientCount}
{formatDateTime(project.updatedAt)}
))}
)}
)} {activeView === 'recipients' && (

收件人花名

花名必须由管理员明确登记,系统不会模糊猜测收件人。

{projects.length === 0 ? (
请先在“接入项目”中新建项目,再登记收件人花名。
) : (
花名钉钉 userId删除
{recipientRows.length === 0 &&

暂无花名。点击下方按钮登记明确的花名与钉钉 userId。

} {recipientRows.map((recipient, index) => (
setRecipientRows((current) => current.map((row, rowIndex) => rowIndex === index ? { ...row, alias: event.target.value } : row))} placeholder="例如:小王" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /> setRecipientRows((current) => current.map((row, rowIndex) => rowIndex === index ? { ...row, userId: event.target.value } : row))} placeholder="精确钉钉 userId" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
))}
)}
)} {activeView === 'test' && (

向指定员工发送测试通知

这是一次真实钉钉单聊。发送前会再次展示项目、收件人与通知内容。

{projects.length === 0 ? (
请先新增并启用接入项目,再发送测试通知。
) : (
{testRecipientMode === 'alias' ? ( ) : ( setTestRecipient(event.target.value)} placeholder="输入精确钉钉 userId" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /> )}
setTestTitle(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />