'use client' import Select from '@/components/ui/Select' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useGSAP } from '@gsap/react' import gsap from 'gsap' import { useConfirmActionDialog } from '@/components/ConfirmActionDialog' import { Activity, AlertTriangle, Check, ChevronRight, Clipboard, Eye, EyeOff, KeyRound, Plus, RefreshCw, Send, Settings2, Trash2, X, } from 'lucide-react' gsap.registerPlugin(useGSAP) type PanelView = 'projects' | 'test' | 'deliveries' type RecipientMode = 'userId' | 'alias' interface DeliverySummary { lastStatus: string | null lastSentAt: string | null recentFailedCount: number } export interface NotificationProject { name: string projectCode: string isActive: boolean allowedHosts: string[] recipientMode: RecipientMode 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 allowedHosts: string[] recipientMode: RecipientMode } interface OneTimeSecret { projectCode: string secret: string env: Record reason: 'created' | 'rotated' } interface RevealedSecret { projectCode: string secret: string env: Record } interface NotificationCenterPanelProps { showToast: (message: string, type?: 'success' | 'error') => void } const EMPTY_PROJECT_FORM: ProjectFormState = { name: '', projectCode: '', isActive: true, allowedHosts: [], recipientMode: 'alias', } 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: 'test', label: '测试通知', description: '向指定员工验证链路', icon: Send }, { id: 'deliveries', label: '投递记录', description: '状态与故障处理', icon: Activity }, ] function normalizeProject(input: unknown): NotificationProject { const item = (input || {}) as Record 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', 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 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。请核对准确花名,或改用精确钉钉 userId 重新测试。' } if (delivery.lastErrorCode === 'ACTION_URL_NOT_ALLOWED') { return '按钮地址无法发送,请检查是否包含异常字符或内容过长。' } 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 revealRequestRef = useRef(0) const { confirmAction, confirmActionDialog } = useConfirmActionDialog() 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 [revealedSecret, setRevealedSecret] = useState(null) const [revealingSecretCode, setRevealingSecretCode] = useState(null) const [showAdvancedProjectCode, setShowAdvancedProjectCode] = useState(false) const [testProjectCode, setTestProjectCode] = useState('') const [testRecipientMode, setTestRecipientMode] = useState('alias') 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')) const payload = unwrapProxyData(data) const rawProjects = payload.projects || payload.data || [] const nextProjects = Array.isArray(rawProjects) ? rawProjects.map(normalizeProject) : [] const defaultProjectCode = nextProjects.find((project) => project.isActive)?.projectCode || nextProjects[0]?.projectCode || '' setProjects(nextProjects) setTestProjectCode((current) => (current && nextProjects.some((project) => project.projectCode === current) ? current : defaultProjectCode)) } 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(() => () => { revealRequestRef.current += 1 }, []) useEffect(() => { if (activeView === 'deliveries') void loadDeliveries() }, [activeView, loadDeliveries]) const selectedTestProject = useMemo( () => projects.find((project) => project.projectCode === testProjectCode) || null, [projects, testProjectCode], ) useEffect(() => { if (!selectedTestProject) return 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 = () => { revealRequestRef.current += 1 setRevealedSecret(null) setRevealingSecretCode(null) setDrawerOpen(false) setEditingCode(null) setShowAdvancedProjectCode(false) setProjectForm(EMPTY_PROJECT_FORM) } const changeView = (view: PanelView) => { if (view !== 'projects') { closeDrawer() } setActiveView(view) } useDialogKeyboard(drawerOpen, closeDrawer) useDialogKeyboard(Boolean(rotateProject), () => setRotateProject(null)) useDialogKeyboard(Boolean(oneTimeSecret), () => setOneTimeSecret(null)) useDialogKeyboard(confirmTestOpen, () => setConfirmTestOpen(false)) const openCreateProject = () => { revealRequestRef.current += 1 setRevealedSecret(null) setRevealingSecretCode(null) setEditingCode(null) setShowAdvancedProjectCode(false) setProjectForm(EMPTY_PROJECT_FORM) setDrawerOpen(true) } const openEditProject = (project: NotificationProject) => { revealRequestRef.current += 1 setRevealedSecret(null) setRevealingSecretCode(null) setEditingCode(project.projectCode) setShowAdvancedProjectCode(false) setProjectForm({ name: project.name, projectCode: project.projectCode, isActive: project.isActive, allowedHosts: project.allowedHosts, recipientMode: project.recipientMode, }) setDrawerOpen(true) } const revealProjectSecret = async (projectCode: string) => { if (revealingSecretCode) return const requestId = revealRequestRef.current + 1 revealRequestRef.current = requestId setRevealingSecretCode(projectCode) try { const data = await readJsonResponse( await fetch( `/api/admin/notification-center/projects/${encodeURIComponent(projectCode)}/reveal-secret`, { method: 'POST', cache: 'no-store' }, ), ) if (revealRequestRef.current !== requestId) return const payload = unwrapProxyData(data) const secret = String(payload.secret || payload.projectSecret || '').trim() if (!secret) throw new Error('通知中心没有返回当前项目密钥') const env = payload.env && typeof payload.env === 'object' ? Object.fromEntries( Object.entries(payload.env as Record) .map(([key, value]) => [key, String(value)]), ) : {} setRevealedSecret({ projectCode, secret, env }) } catch (error) { if (revealRequestRef.current !== requestId) return showToast(error instanceof Error ? error.message : '当前项目密钥读取失败', 'error') } finally { if (revealRequestRef.current === requestId) { setRevealingSecretCode(null) } } } const hideProjectSecret = () => { revealRequestRef.current += 1 setRevealedSecret(null) setRevealingSecretCode(null) } const saveProject = async () => { if (!projectForm.name.trim()) { showToast('请填写项目名称', 'error') return } const normalizedCode = projectForm.projectCode.trim() if (normalizedCode && !/^[a-z][a-z0-9_-]{2,63}$/.test(normalizedCode)) { showToast('projectCode 需以小写字母开头,只能包含小写字母、数字、下划线和短横线', 'error') return } setSavingProject(true) try { const payload = { name: projectForm.name.trim(), isActive: projectForm.isActive, allowedHosts: projectForm.allowedHosts, recipientMode: projectForm.recipientMode, ...(normalizedCode ? { projectCode: normalizedCode } : {}), } 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) const createdCode = String(responsePayload.projectCode || responsePayload.code || normalizedCode).trim() if (createdCode) setLastCreatedCode(createdCode) if (responsePayload.secret) { setOneTimeSecret({ projectCode: createdCode, 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) hideProjectSecret() 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 confirmDeleteProject = async (project: NotificationProject) => { await confirmAction({ title: `确认删除“${project.name}”项目?`, description: ( <>

删除项目配置并使密钥立即失效,历史投递记录保留。

如仍需继续发送通知,请在删除后重新创建项目并同步更新调用方环境变量。

), confirmLabel: '确认删除项目', loadingLabel: '删除中…', tone: 'danger', action: async () => { try { await readJsonResponse( await fetch( `/api/admin/notification-center/projects/${encodeURIComponent(project.projectCode)}`, { method: 'DELETE' }, ), ) closeDrawer() setProjects((current) => current.filter((item) => item.projectCode !== project.projectCode)) setTestProjectCode((current) => (current === project.projectCode ? '' : current)) await loadProjects() showToast('接入项目已删除,项目密钥已立即失效', 'success') } catch (error) { const message = error instanceof Error ? error.message : '接入项目删除失败' showToast(message, 'error') throw error instanceof Error ? error : new Error(message) } }, }) } const preparedTestRecipient = useMemo(() => { if (!selectedTestProject || !testRecipient.trim()) return null if (testRecipientMode === 'userId') { return { label: `钉钉 userId:${testRecipient.trim()}`, recipientAlias: null, recipientUserId: testRecipient.trim(), } } return { label: `花名:${testRecipient.trim()}`, recipientAlias: testRecipient.trim(), recipientUserId: null, } }, [selectedTestProject, testRecipient, testRecipientMode]) const sendTestNotification = async () => { if (!selectedTestProject || !preparedTestRecipient) return setSendingTest(true) try { const payload = { recipientAlias: preparedTestRecipient.recipientAlias || undefined, recipientUserId: preparedTestRecipient.recipientUserId || 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) => { const copyWithSelection = () => { const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null const textarea = document.createElement('textarea') textarea.value = value textarea.readOnly = true textarea.setAttribute('aria-hidden', 'true') textarea.style.position = 'fixed' textarea.style.inset = '0 auto auto -9999px' textarea.style.fontSize = '16px' textarea.style.opacity = '0' document.body.appendChild(textarea) try { textarea.focus() textarea.select() textarea.setSelectionRange(0, textarea.value.length) if (!document.execCommand('copy')) throw new Error('浏览器拒绝复制') } finally { textarea.remove() previousFocus?.focus({ preventScroll: true }) } } try { if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(value) } catch { copyWithSelection() } } else { copyWithSelection() } showToast(`${label}已复制`, 'success') } catch { showToast('复制失败,请长按或选中文本后手动复制', 'error') } } const enabledProjectCount = projects.filter((project) => project.isActive).length const failedCount = projects.reduce( (total, project) => total + project.deliverySummary.recentFailedCount, 0, ) return (

统一通知中心

这里维护统一钉钉单聊的接入项目、密钥、状态与投递记录。通知中心会统一在标题前加上项目名称,方便收件人识别来源;正文、按钮和跳转地址由外部项目自行决定。素材平台向外部系统发送 Webhook 的能力位于“素材外发”。

{[ ['接入项目', String(projects.length), '当前已登记'], ['正在使用', String(enabledProjectCount), '已启用项目'], ['近期失败', 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}
{getStatusLabel(project.deliverySummary.lastStatus)} {project.deliverySummary.recentFailedCount > 0 && (

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

)}
{formatDateTime(project.updatedAt)}
))}
)}
)} {activeView === 'test' && (

向指定员工发送测试通知

这是一次真实钉钉单聊。通知中心会统一在标题前加上项目名称;正文、按钮和跳转地址由本次测试内容决定。

{projects.length === 0 ? (
请先新增并启用接入项目,再发送测试通知。
) : (
setTestRecipient(event.target.value)} placeholder={testRecipientMode === 'alias' ? '输入当前花名册里的准确花名' : '输入精确钉钉 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" />