1049 lines
62 KiB
TypeScript
1049 lines
62 KiB
TypeScript
'use client'
|
||
|
||
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<string, string>
|
||
reason: 'created' | 'rotated'
|
||
}
|
||
|
||
interface RevealedSecret {
|
||
projectCode: string
|
||
secret: string
|
||
env: Record<string, string>
|
||
}
|
||
|
||
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<string, unknown>
|
||
const summary = (item.deliverySummary || {}) as Record<string, unknown>
|
||
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<string, unknown>
|
||
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<string, any> {
|
||
const value = input && typeof input === 'object' ? input as Record<string, any> : {}
|
||
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<HTMLDivElement>(null)
|
||
const revealRequestRef = useRef(0)
|
||
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
|
||
const [activeView, setActiveView] = useState<PanelView>('projects')
|
||
const [projects, setProjects] = useState<NotificationProject[]>([])
|
||
const [projectsLoading, setProjectsLoading] = useState(true)
|
||
const [projectsError, setProjectsError] = useState<string | null>(null)
|
||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||
const [editingCode, setEditingCode] = useState<string | null>(null)
|
||
const [projectForm, setProjectForm] = useState<ProjectFormState>(EMPTY_PROJECT_FORM)
|
||
const [savingProject, setSavingProject] = useState(false)
|
||
const [lastCreatedCode, setLastCreatedCode] = useState<string | null>(null)
|
||
const [oneTimeSecret, setOneTimeSecret] = useState<OneTimeSecret | null>(null)
|
||
const [rotateProject, setRotateProject] = useState<NotificationProject | null>(null)
|
||
const [rotatingSecret, setRotatingSecret] = useState(false)
|
||
const [revealedSecret, setRevealedSecret] = useState<RevealedSecret | null>(null)
|
||
const [revealingSecretCode, setRevealingSecretCode] = useState<string | null>(null)
|
||
const [showAdvancedProjectCode, setShowAdvancedProjectCode] = useState(false)
|
||
const [testProjectCode, setTestProjectCode] = useState('')
|
||
const [testRecipientMode, setTestRecipientMode] = useState<RecipientMode>('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<NotificationDelivery[]>([])
|
||
const [deliveriesLoading, setDeliveriesLoading] = useState(false)
|
||
const [deliveriesError, setDeliveriesError] = useState<string | null>(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<string, unknown>)
|
||
.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: (
|
||
<>
|
||
<p>删除项目配置并使密钥立即失效,历史投递记录保留。</p>
|
||
<p className="mt-2">如仍需继续发送通知,请在删除后重新创建项目并同步更新调用方环境变量。</p>
|
||
</>
|
||
),
|
||
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 (
|
||
<div ref={rootRef} className="min-w-0">
|
||
<div className="border-b border-[#e2e8f0] px-5 py-5 sm:px-6">
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||
<div>
|
||
<h2 className="text-[20px] font-semibold tracking-[-0.02em] text-[#101828]">
|
||
统一通知中心
|
||
</h2>
|
||
<p className="mt-1 max-w-[72ch] text-[14px] leading-6 text-[#66758b]">
|
||
这里维护统一钉钉单聊的接入项目、密钥、状态与投递记录。通知中心会统一在标题前加上项目名称,方便收件人识别来源;正文、按钮和跳转地址由外部项目自行决定。素材平台向外部系统发送 Webhook 的能力位于“素材外发”。
|
||
</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => void loadProjects()}
|
||
disabled={projectsLoading}
|
||
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] border border-[#d9e0e7] bg-white px-3 text-[14px] font-medium text-[#4f6077] transition-colors hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
<RefreshCw className={`h-4 w-4 ${projectsLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
|
||
刷新项目
|
||
</button>
|
||
</div>
|
||
|
||
<dl className="mt-5 grid grid-cols-1 divide-y divide-[#e2e8f0] overflow-hidden rounded-[10px] border border-[#e2e8f0] bg-[#f8fafc] sm:grid-cols-3 sm:divide-x sm:divide-y-0">
|
||
{[
|
||
['接入项目', String(projects.length), '当前已登记'],
|
||
['正在使用', String(enabledProjectCount), '已启用项目'],
|
||
['近期失败', String(failedCount), failedCount > 0 ? '需要处理' : '未发现异常'],
|
||
].map(([label, value, hint]) => (
|
||
<div key={label} className="min-w-0 px-4 py-3.5">
|
||
<dt className="text-[12px] font-medium text-[#66758b]">{label}</dt>
|
||
<dd className="mt-1 flex items-baseline gap-2">
|
||
<span className="text-[20px] font-semibold tabular-nums text-[#101828]">{value}</span>
|
||
<span className={`truncate text-[12px] ${label === '近期失败' && failedCount > 0 ? 'text-[#b42318]' : 'text-[#66758b]'}`}>
|
||
{hint}
|
||
</span>
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
</div>
|
||
|
||
<div className="grid min-w-0 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||
<nav
|
||
aria-label="统一通知中心管理菜单"
|
||
className="border-b border-[#e2e8f0] bg-[#f8fafc] p-2 lg:border-b-0 lg:border-r lg:p-3"
|
||
>
|
||
<div className="grid grid-cols-2 gap-1 sm:grid-cols-4 lg:grid-cols-1">
|
||
{VIEW_ITEMS.map((item) => {
|
||
const Icon = item.icon
|
||
const active = activeView === item.id
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
aria-current={active ? 'page' : undefined}
|
||
onClick={() => changeView(item.id)}
|
||
className={`min-h-[58px] rounded-[8px] px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6] ${
|
||
active
|
||
? 'bg-[#e6f4ff] text-[#0050cb]'
|
||
: 'text-[#4f6077] hover:bg-[#eef2f6] hover:text-[#101828]'
|
||
}`}
|
||
>
|
||
<span className="flex items-center gap-2 text-[14px] font-semibold">
|
||
<Icon className={`h-4 w-4 ${active ? 'text-[#1677ff]' : 'text-[#66758b]'}`} aria-hidden="true" />
|
||
{item.label}
|
||
</span>
|
||
<span className={`mt-1 hidden text-[12px] leading-5 lg:block ${active ? 'text-[#315f91]' : 'text-[#66758b]'}`}>
|
||
{item.description}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</nav>
|
||
|
||
<div data-nc-view className="min-w-0 p-4 sm:p-5 lg:p-6">
|
||
{projectsError && (
|
||
<div role="alert" className="mb-5 rounded-[8px] border border-[#f3c7c1] bg-[#fef6f5] px-4 py-3 text-[14px] text-[#8f2119]">
|
||
<div className="flex items-start gap-2">
|
||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-none" aria-hidden="true" />
|
||
<div>
|
||
<p className="font-semibold">统一通知中心暂时无法连接</p>
|
||
<p className="mt-1 leading-6">{projectsError}。项目配置没有被修改,可以稍后刷新重试。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{activeView === 'projects' && (
|
||
<section aria-labelledby="notification-projects-heading">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||
<div>
|
||
<h3 id="notification-projects-heading" className="text-[16px] font-semibold text-[#101828]">接入项目</h3>
|
||
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">这里只维护接入项目、启停状态和密钥;通知中心会统一在标题前加上项目名称,正文、按钮和跳转地址由外部项目自行决定。</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={openCreateProject}
|
||
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white transition-colors hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
|
||
>
|
||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||
新增接入项目
|
||
</button>
|
||
</div>
|
||
|
||
{projectsLoading ? (
|
||
<div className="mt-5 space-y-2" aria-label="正在加载接入项目">
|
||
{[0, 1, 2].map((item) => <div key={item} className="h-[74px] animate-pulse rounded-[8px] bg-[#eef2f6]" />)}
|
||
</div>
|
||
) : projects.length === 0 ? (
|
||
<div className="mt-5 rounded-[10px] border border-dashed border-[#cfd8e3] px-5 py-10 text-center">
|
||
<Settings2 className="mx-auto h-6 w-6 text-[#66758b]" aria-hidden="true" />
|
||
<p className="mt-3 text-[14px] font-semibold text-[#101828]">还没有接入项目</p>
|
||
<p className="mt-1 text-[13px] text-[#66758b]">新增项目后即可生成接入 URL、projectCode 和项目密钥,并向指定员工发送测试通知。</p>
|
||
<button type="button" onClick={openCreateProject} className="mt-4 min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">
|
||
新增第一个接入项目
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div className="mt-5 overflow-hidden rounded-[10px] border border-[#e2e8f0]">
|
||
<div className="hidden grid-cols-[minmax(180px,1.2fr)_minmax(150px,0.9fr)_130px_168px] gap-3 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-2.5 text-[12px] font-semibold text-[#66758b] md:grid">
|
||
<span>项目</span><span>最近投递</span><span>更新时间</span><span>项目资料</span>
|
||
</div>
|
||
<div className="divide-y divide-[#e2e8f0] bg-white">
|
||
{projects.map((project) => (
|
||
<div
|
||
key={project.projectCode}
|
||
data-project-code={project.projectCode}
|
||
className="grid min-w-0 gap-3 px-4 py-4 md:grid-cols-[minmax(180px,1.2fr)_minmax(150px,0.9fr)_130px_168px] md:items-center"
|
||
>
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="truncate text-[14px] font-semibold text-[#101828]">{project.name}</span>
|
||
<span className={`rounded-[6px] px-2 py-0.5 text-[11px] font-medium ${project.isActive ? 'bg-[#e8f7f2] text-[#087052]' : 'bg-[#eef2f6] text-[#66758b]'}`}>
|
||
{project.isActive ? '使用中' : '已停用'}
|
||
</span>
|
||
</div>
|
||
<code className="mt-1 block truncate text-[12px] text-[#66758b]">{project.projectCode}</code>
|
||
</div>
|
||
<div>
|
||
<span className={`inline-flex rounded-[6px] px-2 py-1 text-[11px] font-medium ${getStatusClassName(project.deliverySummary.lastStatus)}`}>
|
||
{getStatusLabel(project.deliverySummary.lastStatus)}
|
||
</span>
|
||
{project.deliverySummary.recentFailedCount > 0 && (
|
||
<p className="mt-1 text-[11px] text-[#b42318]">近期失败 {project.deliverySummary.recentFailedCount} 条</p>
|
||
)}
|
||
</div>
|
||
<div className="text-[12px] leading-5 text-[#66758b]">{formatDateTime(project.updatedAt)}</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => openEditProject(project)}
|
||
aria-label={`查看配置并编辑资料 ${project.name}`}
|
||
className="inline-flex min-h-10 items-center justify-center gap-1.5 justify-self-start rounded-[8px] border border-[#d9e0e7] px-3 text-[13px] font-medium text-[#344054] transition-colors hover:bg-[#f8fafc] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 md:justify-self-end"
|
||
>
|
||
查看配置 / 编辑资料
|
||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{activeView === 'test' && (
|
||
<section aria-labelledby="notification-test-heading" className="max-w-3xl">
|
||
<h3 id="notification-test-heading" className="text-[16px] font-semibold text-[#101828]">向指定员工发送测试通知</h3>
|
||
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">这是一次真实钉钉单聊。通知中心会统一在标题前加上项目名称;正文、按钮和跳转地址由本次测试内容决定。</p>
|
||
{projects.length === 0 ? (
|
||
<div className="mt-5 rounded-[8px] border border-dashed border-[#cfd8e3] p-6 text-[13px] text-[#66758b]">请先新增并启用接入项目,再发送测试通知。</div>
|
||
) : (
|
||
<div className="mt-5 grid gap-4 sm:grid-cols-2">
|
||
<div className="sm:col-span-2">
|
||
<label htmlFor="test-project" className="block text-[13px] font-semibold text-[#344054]">接入项目</label>
|
||
<select id="test-project" value={testProjectCode} onChange={(event) => setTestProjectCode(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
|
||
{projects.filter((project) => project.isActive).map((project) => <option key={project.projectCode} value={project.projectCode}>{project.name} · {project.projectCode}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="test-recipient-mode" className="block text-[13px] font-semibold text-[#344054]">收件人方式</label>
|
||
<select id="test-recipient-mode" value={testRecipientMode} onChange={(event) => { setTestRecipientMode(event.target.value as RecipientMode); setTestRecipient('') }} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
|
||
<option value="alias">准确花名</option><option value="userId">精确钉钉 userId</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="test-recipient" className="block text-[13px] font-semibold text-[#344054]">真实收件人</label>
|
||
<input id="test-recipient" value={testRecipient} onChange={(event) => 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" />
|
||
</div>
|
||
<div className="sm:col-span-2"><label htmlFor="test-title" className="block text-[13px] font-semibold text-[#344054]">通知标题</label><input id="test-title" value={testTitle} onChange={(event) => 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" /></div>
|
||
<div className="sm:col-span-2"><label htmlFor="test-body" className="block text-[13px] font-semibold text-[#344054]">通知正文</label><textarea id="test-body" value={testBody} onChange={(event) => setTestBody(event.target.value)} rows={4} className="mt-2 w-full resize-y rounded-[8px] border border-[#cfd8e3] px-3 py-2 text-[14px] leading-6 focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /></div>
|
||
<div><label htmlFor="test-button" className="block text-[13px] font-semibold text-[#344054]">按钮文字</label><input id="test-button" value={testButtonLabel} onChange={(event) => setTestButtonLabel(event.target.value)} placeholder="可选" 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" /></div>
|
||
<div><label htmlFor="test-url" className="block text-[13px] font-semibold text-[#344054]">按钮地址</label><input id="test-url" value={testActionUrl} onChange={(event) => setTestActionUrl(event.target.value)} placeholder="可选,由外部项目自行决定" 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" /></div>
|
||
<div className="sm:col-span-2 rounded-[8px] border border-[#d7e8fb] bg-[#f5f9ff] px-4 py-3 text-[13px] leading-6 text-[#315f91]">
|
||
{preparedTestRecipient
|
||
? testRecipientMode === 'alias'
|
||
? `将通过“${selectedTestProject?.name}”按当前花名册精确解析 ${preparedTestRecipient.label},并发送一条真实钉钉单聊。`
|
||
: `将通过“${selectedTestProject?.name}”向 ${preparedTestRecipient.label} 发送一条真实钉钉单聊。`
|
||
: '请填写准确花名或精确钉钉 userId。系统不会根据姓名模糊猜人。'}
|
||
</div>
|
||
<div className="sm:col-span-2"><button type="button" onClick={() => setConfirmTestOpen(true)} disabled={!preparedTestRecipient || !testTitle.trim() || !testBody.trim()} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"><Send className="h-4 w-4" aria-hidden="true" />向指定员工发送测试通知</button></div>
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{activeView === 'deliveries' && (
|
||
<section aria-labelledby="notification-deliveries-heading">
|
||
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
|
||
<div><h3 id="notification-deliveries-heading" className="text-[16px] font-semibold text-[#101828]">投递记录</h3><p className="mt-1 text-[13px] leading-5 text-[#66758b]">优先展示可理解的状态与处理建议。发送结果不确定时必须先人工核验。</p></div>
|
||
<div className="grid gap-2 sm:grid-cols-2">
|
||
<label className="sr-only" htmlFor="delivery-project-filter">按项目筛选</label><select id="delivery-project-filter" value={deliveryProjectFilter} onChange={(event) => setDeliveryProjectFilter(event.target.value)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20"><option value="all">全部项目</option>{projects.map((project) => <option key={project.projectCode} value={project.projectCode}>{project.name}</option>)}</select>
|
||
<label className="sr-only" htmlFor="delivery-status-filter">按状态筛选</label><select id="delivery-status-filter" value={deliveryStatusFilter} onChange={(event) => setDeliveryStatusFilter(event.target.value)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">{DELIVERY_STATUSES.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
|
||
</div>
|
||
</div>
|
||
{deliveriesError ? <div role="alert" className="mt-5 rounded-[8px] border border-[#f3c7c1] bg-[#fef6f5] px-4 py-3 text-[13px] leading-6 text-[#8f2119]">{deliveriesError}。没有自动重发任何通知,请稍后刷新。</div> : deliveriesLoading ? <div className="mt-5 space-y-2">{[0,1,2].map((item) => <div key={item} className="h-20 animate-pulse rounded-[8px] bg-[#eef2f6]" />)}</div> : deliveries.length === 0 ? <div className="mt-5 rounded-[8px] border border-dashed border-[#cfd8e3] p-8 text-center text-[13px] text-[#66758b]">当前筛选条件下没有投递记录。发送测试通知后可在这里查看状态。</div> : (
|
||
<div className="mt-5 divide-y divide-[#e2e8f0] overflow-hidden rounded-[10px] border border-[#e2e8f0]">
|
||
{deliveries.map((delivery) => { const advice = getDeliveryAdvice(delivery); return <div key={delivery.id} className="grid gap-3 bg-white px-4 py-4 lg:grid-cols-[minmax(150px,0.8fr)_minmax(210px,1.2fr)_120px_minmax(220px,1.2fr)] lg:items-start"><div><p className="text-[13px] font-semibold text-[#101828]">{delivery.projectCode}</p><p className="mt-1 text-[11px] text-[#66758b]">{formatDateTime(delivery.createdAt)}</p></div><div><p className="text-[13px] font-medium text-[#344054]">{delivery.title || '未命名通知'}</p><p className="mt-1 text-[12px] text-[#66758b]">收件人:{delivery.recipientAlias || delivery.recipientUserIdMasked || '未返回'}</p></div><div><span className={`inline-flex rounded-[6px] px-2 py-1 text-[11px] font-medium ${getStatusClassName(delivery.status)}`}>{getStatusLabel(delivery.status)}</span></div><div className="text-[12px] leading-5 text-[#66758b]">{advice ? <p className={delivery.lastErrorCode === 'DELIVERY_OUTCOME_UNKNOWN' ? 'font-medium text-[#9a4d08]' : 'text-[#8f2119]'}>{advice}</p> : <p>{delivery.lastErrorMessage || '暂无异常'}</p>}{delivery.lastErrorCode && <code className="mt-1 block text-[11px] text-[#8b95a5]">错误标识:{delivery.lastErrorCode}</code>}</div></div> })}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{drawerOpen && (
|
||
<div className="fixed inset-0 z-[70] flex justify-end bg-[#06192d]/35" onMouseDown={(event) => { if (event.currentTarget === event.target) closeDrawer() }}>
|
||
<section data-nc-drawer role="dialog" aria-modal="true" aria-labelledby="project-drawer-title" className="h-full w-full max-w-[560px] overflow-y-auto bg-white shadow-[0_18px_48px_rgba(6,25,45,0.2)]">
|
||
<div className="sticky top-0 z-10 flex items-start justify-between border-b border-[#e2e8f0] bg-white px-5 py-4">
|
||
<div><h3 id="project-drawer-title" className="text-[18px] font-semibold text-[#101828]">{editingCode ? '查看配置 / 编辑资料' : '新增接入项目'}</h3><p className="mt-1 text-[12px] text-[#66758b]">{editingCode ? '查看或修改项目名称与启停状态,密钥按需查看。' : '只需填写项目名称;通知中心会自动生成接入 code 和项目密钥。'}</p></div>
|
||
<button type="button" onClick={closeDrawer} aria-label="关闭项目编辑" className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#4f6077] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-5 w-5" aria-hidden="true" /></button>
|
||
</div>
|
||
<div className="space-y-5 px-5 py-5">
|
||
<div><label htmlFor="project-name" className="block text-[13px] font-semibold text-[#344054]">项目名称</label><input id="project-name" autoFocus value={projectForm.name} onChange={(event) => setProjectForm((current) => ({ ...current, name: event.target.value }))} placeholder="例如:固定资产系统" 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" /></div>
|
||
{editingCode ? (
|
||
<div><label htmlFor="project-code" className="block text-[13px] font-semibold text-[#344054]">projectCode</label><input id="project-code" disabled value={projectForm.projectCode} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 font-mono text-[13px] text-[#66758b] focus:outline-none disabled:bg-[#f2f4f7]" /><p className="mt-1 text-[12px] leading-5 text-[#66758b]">创建后固定,用于接入 URL、签名和调用方环境变量。</p></div>
|
||
) : (
|
||
<div className="rounded-[8px] border border-[#e2e8f0] bg-[#f8fafc] p-4">
|
||
<button type="button" onClick={() => setShowAdvancedProjectCode((current) => !current)} aria-expanded={showAdvancedProjectCode} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-2 text-[13px] font-medium text-[#344054] hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">
|
||
{showAdvancedProjectCode ? '收起高级设置' : '高级设置:自定义 projectCode'}
|
||
</button>
|
||
<p className="mt-1 text-[12px] leading-5 text-[#66758b]">默认留空,由通知中心后端自动生成。</p>
|
||
{showAdvancedProjectCode && (
|
||
<div className="mt-3">
|
||
<label htmlFor="project-code" className="block text-[13px] font-semibold text-[#344054]">projectCode</label>
|
||
<input id="project-code" value={projectForm.projectCode} onChange={(event) => setProjectForm((current) => ({ ...current, projectCode: event.target.value }))} placeholder="例如:fixed_assets" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 font-mono text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
<label className="flex items-center gap-3 rounded-[8px] border border-[#e2e8f0] px-3 py-3 text-[13px] font-medium text-[#344054]"><input type="checkbox" checked={projectForm.isActive} onChange={(event) => setProjectForm((current) => ({ ...current, isActive: event.target.checked }))} className="h-4 w-4 rounded border-[#b8c2ce] text-[#1677ff] focus:ring-[#1677ff]" />保存后启用该项目</label>
|
||
<div className="rounded-[8px] border border-[#d7e8fb] bg-[#f5f9ff] px-4 py-3 text-[12px] leading-5 text-[#315f91]">通知中心会统一在标题前加上项目名称,正文、按钮和跳转地址由外部项目后端自行决定。</div>
|
||
{editingCode && (
|
||
<div className="rounded-[8px] border border-[#e2e8f0] p-4">
|
||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||
<div className="min-w-0">
|
||
<p className="text-[13px] font-semibold text-[#344054]">当前项目密钥</p>
|
||
<p className="mt-1 text-[12px] leading-5 text-[#66758b]">
|
||
默认隐藏。查看只会读取当前密钥,不会轮换,也不会影响调用方继续使用。
|
||
</p>
|
||
</div>
|
||
{revealedSecret?.projectCode === editingCode ? (
|
||
<button
|
||
type="button"
|
||
onClick={hideProjectSecret}
|
||
className="inline-flex min-h-10 flex-none items-center justify-center gap-2 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
>
|
||
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||
隐藏密钥
|
||
</button>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
onClick={() => void revealProjectSecret(editingCode)}
|
||
disabled={Boolean(revealingSecretCode)}
|
||
className="inline-flex min-h-10 flex-none items-center justify-center gap-2 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||
{revealingSecretCode === editingCode ? '读取中…' : '查看当前密钥'}
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="mt-3 flex min-h-11 items-center gap-2 rounded-[8px] bg-[#f8fafc] px-3 py-2">
|
||
<code className="min-w-0 flex-1 break-all text-[12px] leading-5 text-[#344054]">
|
||
{revealedSecret?.projectCode === editingCode
|
||
? revealedSecret.secret
|
||
: '••••••••••••••••••••••••••••••••'}
|
||
</code>
|
||
{revealedSecret?.projectCode === editingCode && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void copyText(revealedSecret.secret, '项目密钥')}
|
||
aria-label={`复制 ${projectForm.name} 的当前项目密钥`}
|
||
className="inline-flex h-10 w-10 flex-none items-center justify-center rounded-[8px] text-[#1677ff] hover:bg-[#e6f4ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
>
|
||
<Clipboard className="h-4 w-4" aria-hidden="true" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{revealedSecret?.projectCode === editingCode && Object.keys(revealedSecret.env).length > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => void copyText(
|
||
Object.entries(revealedSecret.env).map(([key, value]) => `${key}=${value}`).join('\n'),
|
||
'调用方环境变量',
|
||
)}
|
||
className="mt-2 inline-flex min-h-10 items-center gap-2 rounded-[8px] px-2 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
>
|
||
<Clipboard className="h-4 w-4" aria-hidden="true" />
|
||
复制完整环境变量
|
||
</button>
|
||
)}
|
||
<div className="mt-4 border-t border-[#e2e8f0] pt-4">
|
||
<p className="text-[13px] font-semibold text-[#8f2119]">危险操作</p>
|
||
<p className="mt-1 text-[11px] leading-5 text-[#8f2119]">
|
||
轮换会使旧密钥立即失效;删除会移除项目配置,但历史投递记录仍会保留。
|
||
</p>
|
||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const project = projects.find((item) => item.projectCode === editingCode)
|
||
if (project) setRotateProject(project)
|
||
}}
|
||
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] border border-[#f3c7c1] px-3 text-[13px] font-medium text-[#b42318] hover:bg-[#fef6f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
>
|
||
<KeyRound className="h-4 w-4" aria-hidden="true" />
|
||
轮换项目密钥
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const project = projects.find((item) => item.projectCode === editingCode)
|
||
if (!project) {
|
||
showToast('当前项目不存在,请先刷新项目列表', 'error')
|
||
return
|
||
}
|
||
void confirmDeleteProject(project)
|
||
}}
|
||
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] border border-[#f3c7c1] px-3 text-[13px] font-medium text-[#b42318] hover:bg-[#fef6f5] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"
|
||
>
|
||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||
删除项目
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="sticky bottom-0 flex justify-end gap-2 border-t border-[#e2e8f0] bg-white px-5 py-4"><button type="button" onClick={closeDrawer} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">取消</button><button type="button" onClick={() => void saveProject()} disabled={savingProject} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:opacity-50">{savingProject ? '保存中…' : editingCode ? '保存项目' : '创建并生成密钥'}</button></div>
|
||
</section>
|
||
</div>
|
||
)}
|
||
|
||
{rotateProject && <div className="fixed inset-0 z-[80] flex items-center justify-center bg-[#06192d]/40 px-4"><section role="alertdialog" aria-modal="true" aria-labelledby="rotate-secret-title" className="w-full max-w-md rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.2)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#fff7e8] text-[#9a4d08]"><AlertTriangle className="h-5 w-5" aria-hidden="true" /></div><h3 id="rotate-secret-title" className="mt-4 text-[17px] font-semibold text-[#101828]">确认轮换“{rotateProject.name}”的项目密钥?</h3><p className="mt-2 text-[13px] leading-6 text-[#66758b]">轮换完成后,所有仍使用旧密钥的调用会立即鉴权失败。请确认你能够马上更新调用方环境变量。</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => setRotateProject(null)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054]">取消</button><button type="button" onClick={() => void confirmRotateSecret()} disabled={rotatingSecret} className="min-h-10 rounded-[8px] bg-[#b42318] px-4 text-[14px] font-semibold text-white disabled:opacity-50">{rotatingSecret ? '轮换中…' : '确认轮换,旧密钥立即失效'}</button></div></section></div>}
|
||
|
||
{confirmTestOpen && preparedTestRecipient && selectedTestProject && <div className="fixed inset-0 z-[80] flex items-center justify-center bg-[#06192d]/40 px-4"><section role="alertdialog" aria-modal="true" aria-labelledby="confirm-test-title" className="w-full max-w-lg rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.2)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e6f4ff] text-[#1677ff]"><Send className="h-5 w-5" aria-hidden="true" /></div><h3 id="confirm-test-title" className="mt-4 text-[17px] font-semibold text-[#101828]">确认发送真实测试通知</h3><dl className="mt-4 grid gap-3 rounded-[8px] bg-[#f8fafc] p-4 text-[13px]"><div><dt className="text-[#66758b]">项目</dt><dd className="mt-1 font-medium text-[#101828]">{selectedTestProject.name} · {selectedTestProject.projectCode}</dd></div><div><dt className="text-[#66758b]">真实收件人</dt><dd className="mt-1 font-medium text-[#101828]">{preparedTestRecipient.label}</dd></div><div><dt className="text-[#66758b]">标题</dt><dd className="mt-1 font-medium text-[#101828]">{testTitle}</dd></div></dl><p className="mt-3 text-[13px] leading-6 text-[#9a4d08]">通知中心会统一在标题前加上项目名称;确认后会真实调用钉钉,不是页面预览。</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => setConfirmTestOpen(false)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054]">取消</button><button type="button" onClick={() => void sendTestNotification()} disabled={sendingTest} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white disabled:opacity-50">{sendingTest ? '正在提交…' : '确认发送给该员工'}</button></div></section></div>}
|
||
|
||
{oneTimeSecret && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-[#06192d]/45 px-4"><section data-secret-reveal role="dialog" aria-modal="true" aria-labelledby="one-time-secret-title" className="max-h-[90dvh] w-full max-w-2xl overflow-y-auto rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.22)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e8f7f2] text-[#087052]"><Check className="h-5 w-5" aria-hidden="true" /></div><h3 id="one-time-secret-title" className="mt-4 text-[18px] font-semibold text-[#101828]">{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}</h3><p className="mt-2 text-[13px] leading-6 text-[#66758b]">把下面 3 项直接交给外部项目负责人,并要求只配置在项目后端。点击“复制全部配置”可一次复制;关闭后仍可在“查看配置 / 编辑资料”里按需查看当前密钥。</p><div className="mt-5 grid gap-4 sm:grid-cols-2"><div className="sm:col-span-2"><span className="text-[13px] font-semibold text-[#344054]">统一通知中心 URL</span><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.env.YYD_NOTIFICATION_CENTER_URL || '后端未返回'}</code></div><div><span className="text-[13px] font-semibold text-[#344054]">projectCode</span><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.projectCode || oneTimeSecret.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE || '后端未返回'}</code></div><div><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">项目密钥</span><button type="button" onClick={() => void copyText(oneTimeSecret.secret, '项目密钥')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制</button></div><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.secret}</code></div></div><div className="mt-5"><div className="flex items-center justify-between gap-3"><span className="text-[13px] font-semibold text-[#344054]">调用方环境变量</span><button type="button" aria-label="复制全部调用方配置" onClick={() => void copyText(Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n'), '全部配置')} className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制全部配置</button></div><pre className="mt-2 overflow-x-auto rounded-[8px] border border-[#d9e0e7] bg-[#06192d] p-3 text-[12px] leading-6 text-[#eaf1f8]">{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}</pre></div><div className="mt-5 flex justify-end"><button type="button" onClick={() => setOneTimeSecret(null)} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white">关闭</button></div></section></div>}
|
||
{confirmActionDialog}
|
||
</div>
|
||
)
|
||
}
|