gongxiangp2/components/admin/NotificationCenterPanel.tsx

1049 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}