gongxiangp2/components/admin/NotificationCenterPanel.tsx

964 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 {
Activity,
AlertTriangle,
Check,
ChevronRight,
Clipboard,
KeyRound,
Plus,
RefreshCw,
Send,
Settings2,
Users,
X,
} from 'lucide-react'
gsap.registerPlugin(useGSAP)
type PanelView = 'projects' | 'recipients' | 'test' | 'deliveries'
type RecipientMode = 'userId' | 'alias'
interface NotificationRecipient {
alias: string
userId: string
}
interface DeliverySummary {
lastStatus: string | null
lastSentAt: string | null
recentFailedCount: number
}
export interface NotificationProject {
name: string
projectCode: string
isActive: boolean
allowedHosts: string[]
recipientMode: RecipientMode
recipientCount: number
recipients: NotificationRecipient[]
updatedAt: string | null
deliverySummary: DeliverySummary
}
interface NotificationDelivery {
id: string
projectCode: string
status: string
recipientAlias: string | null
recipientUserIdMasked: string | null
title: string | null
lastErrorCode: string | null
lastErrorMessage: string | null
createdAt: string
updatedAt: string
}
interface ProjectFormState {
name: string
projectCode: string
isActive: boolean
allowedHostsText: string
recipientMode: RecipientMode
recipients: NotificationRecipient[]
}
interface OneTimeSecret {
projectCode: string
secret: string
env: Record<string, string>
reason: 'created' | 'rotated'
}
interface NotificationCenterPanelProps {
showToast: (message: string, type?: 'success' | 'error') => void
}
const EMPTY_PROJECT_FORM: ProjectFormState = {
name: '',
projectCode: '',
isActive: true,
allowedHostsText: '',
recipientMode: 'userId',
recipients: [],
}
const DELIVERY_STATUSES = [
['all', '全部状态'],
['pending', '等待发送'],
['sending', '发送中'],
['retrying', '等待重试'],
['sent', '已送达钉钉'],
['unread', '员工未读'],
['read', '员工已读'],
['failed', '发送失败'],
['expired', '已过期'],
] as const
const VIEW_ITEMS: Array<{
id: PanelView
label: string
description: string
icon: typeof Activity
}> = [
{ id: 'projects', label: '接入项目', description: '项目、域名与密钥', icon: Settings2 },
{ id: 'recipients', label: '收件人花名', description: '花名与钉钉账号', icon: Users },
{ id: 'test', label: '测试通知', description: '向指定员工验证链路', icon: Send },
{ id: 'deliveries', label: '投递记录', description: '状态与故障处理', icon: Activity },
]
function normalizeRecipient(input: unknown): NotificationRecipient | null {
if (!input || typeof input !== 'object') return null
const item = input as Record<string, unknown>
const alias = String(item.alias || item.recipientAlias || '').trim()
const userId = String(item.userId || item.recipientUserId || '').trim()
if (!alias || !userId) return null
return { alias, userId }
}
function normalizeProject(input: unknown): NotificationProject {
const item = (input || {}) as Record<string, unknown>
const recipients = Array.isArray(item.recipients)
? item.recipients.map(normalizeRecipient).filter((value): value is NotificationRecipient => Boolean(value))
: []
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',
recipientCount: Number(item.recipientCount ?? recipients.length ?? 0),
recipients,
updatedAt: item.updatedAt ? String(item.updatedAt) : null,
deliverySummary: {
lastStatus: summary.lastStatus ? String(summary.lastStatus) : null,
lastSentAt: summary.lastSentAt ? String(summary.lastSentAt) : null,
recentFailedCount: Number(summary.recentFailedCount || 0),
},
}
}
function normalizeDelivery(input: unknown): NotificationDelivery {
const item = (input || {}) as Record<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 parseAllowedHosts(value: string) {
return Array.from(
new Set(
value
.split(/[\n,,]/)
.map((host) => {
const normalized = host.trim()
if (!normalized) return ''
try {
return new URL(normalized.includes('://') ? normalized : `https://${normalized}`).hostname.toLowerCase()
} catch {
return normalized.toLowerCase()
}
})
.filter(Boolean),
),
)
}
function getStatusLabel(status: string | null) {
return DELIVERY_STATUSES.find(([value]) => value === status)?.[1] || status || '暂无投递'
}
function getStatusClassName(status: string | null) {
if (status === 'sent' || status === 'read') return 'bg-[#e8f7f2] text-[#087052]'
if (status === 'failed' || status === 'expired') return 'bg-[#fef0ee] text-[#b42318]'
if (status === 'sending' || status === 'retrying') return 'bg-[#fff7e8] text-[#9a4d08]'
return 'bg-[#eef2f6] text-[#4f6077]'
}
function getDeliveryAdvice(delivery: NotificationDelivery) {
if (delivery.lastErrorCode === 'DELIVERY_OUTCOME_UNKNOWN') {
return '钉钉是否收到尚不确定。请先联系收件人或在钉钉侧人工核验,不要直接重复发送。'
}
if (delivery.lastErrorCode === 'RECIPIENT_NOT_FOUND') {
return '未找到已登记的收件人。请核对花名或钉钉 userId 后重新测试。'
}
if (delivery.lastErrorCode === 'ACTION_URL_NOT_ALLOWED') {
return '按钮地址不在该项目允许的域名内。请先更新 allowedHosts。'
}
if (delivery.status === 'failed') {
return '通知发送失败。请根据下方原因修正配置,再重新发起测试。'
}
if (delivery.status === 'expired') {
return '该通知已经超过有效期,不会继续重试。请确认业务是否仍需要通知。'
}
return null
}
async function readJsonResponse(response: Response) {
const data = await response.json().catch(() => null)
if (!response.ok) {
const message =
data?.error?.message ||
data?.message ||
(typeof data?.error === 'string' ? data.error : null) ||
'统一通知中心暂时不可用,请稍后重试'
throw new Error(message)
}
return data || {}
}
function unwrapProxyData(input: unknown): Record<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 [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 [recipientProjectCode, setRecipientProjectCode] = useState('')
const [recipientRows, setRecipientRows] = useState<NotificationRecipient[]>([])
const [savingRecipients, setSavingRecipients] = useState(false)
const [testProjectCode, setTestProjectCode] = useState('')
const [testRecipientMode, setTestRecipientMode] = useState<RecipientMode>('userId')
const [testRecipient, setTestRecipient] = useState('')
const [testTitle, setTestTitle] = useState('统一通知中心测试')
const [testBody, setTestBody] = useState('这是一条由超级管理员发起的链路测试通知。')
const [testButtonLabel, setTestButtonLabel] = useState('查看详情')
const [testActionUrl, setTestActionUrl] = useState('')
const [confirmTestOpen, setConfirmTestOpen] = useState(false)
const [sendingTest, setSendingTest] = useState(false)
const [deliveryProjectFilter, setDeliveryProjectFilter] = useState('all')
const [deliveryStatusFilter, setDeliveryStatusFilter] = useState('all')
const [deliveries, setDeliveries] = useState<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?includeRecipients=1'))
const payload = unwrapProxyData(data)
const rawProjects = payload.projects || payload.data || []
const nextProjects = Array.isArray(rawProjects) ? rawProjects.map(normalizeProject) : []
setProjects(nextProjects)
setRecipientProjectCode((current) => current || nextProjects[0]?.projectCode || '')
setTestProjectCode((current) => current || nextProjects[0]?.projectCode || '')
} catch (error) {
const message = error instanceof Error ? error.message : '统一通知中心暂时不可用,请稍后重试'
setProjectsError(message)
} finally {
setProjectsLoading(false)
}
}, [])
const loadDeliveries = useCallback(async () => {
setDeliveriesLoading(true)
setDeliveriesError(null)
try {
const params = new URLSearchParams({ limit: '50' })
if (deliveryProjectFilter !== 'all') params.set('projectCode', deliveryProjectFilter)
if (deliveryStatusFilter !== 'all') params.set('status', deliveryStatusFilter)
const data = await readJsonResponse(
await fetch(`/api/admin/notification-center/deliveries?${params.toString()}`),
)
const payload = unwrapProxyData(data)
const rawDeliveries = payload.deliveries || payload.data || []
setDeliveries(Array.isArray(rawDeliveries) ? rawDeliveries.map(normalizeDelivery) : [])
} catch (error) {
setDeliveriesError(error instanceof Error ? error.message : '投递记录加载失败,请稍后重试')
} finally {
setDeliveriesLoading(false)
}
}, [deliveryProjectFilter, deliveryStatusFilter])
useEffect(() => {
void loadProjects()
}, [loadProjects])
useEffect(() => {
if (activeView === 'deliveries') void loadDeliveries()
}, [activeView, loadDeliveries])
const selectedRecipientProject = useMemo(
() => projects.find((project) => project.projectCode === recipientProjectCode) || null,
[projects, recipientProjectCode],
)
const selectedTestProject = useMemo(
() => projects.find((project) => project.projectCode === testProjectCode) || null,
[projects, testProjectCode],
)
useEffect(() => {
setRecipientRows(selectedRecipientProject?.recipients || [])
}, [selectedRecipientProject])
useEffect(() => {
if (!selectedTestProject) return
setTestRecipientMode(selectedTestProject.recipientMode)
setTestRecipient('')
}, [selectedTestProject])
useGSAP(
() => {
const mm = gsap.matchMedia()
mm.add(
{ reduceMotion: '(prefers-reduced-motion: reduce)' },
(context) => {
const reduceMotion = Boolean(context.conditions?.reduceMotion)
gsap.fromTo(
'[data-nc-view]',
{ autoAlpha: 0, y: reduceMotion ? 0 : 8 },
{ autoAlpha: 1, y: 0, duration: reduceMotion ? 0 : 0.2, ease: 'power2.out' },
)
},
)
return () => mm.revert()
},
{ scope: rootRef, dependencies: [activeView], revertOnUpdate: true },
)
useGSAP(
() => {
if (!drawerOpen && !oneTimeSecret) return
const mm = gsap.matchMedia()
mm.add(
{ reduceMotion: '(prefers-reduced-motion: reduce)' },
(context) => {
const reduceMotion = Boolean(context.conditions?.reduceMotion)
if (drawerOpen) {
gsap.fromTo(
'[data-nc-drawer]',
{ autoAlpha: 0, x: reduceMotion ? 0 : 24 },
{ autoAlpha: 1, x: 0, duration: reduceMotion ? 0 : 0.22, ease: 'power2.out' },
)
}
if (oneTimeSecret) {
gsap.fromTo(
'[data-secret-reveal]',
{ autoAlpha: 0, y: reduceMotion ? 0 : 10 },
{ autoAlpha: 1, y: 0, duration: reduceMotion ? 0 : 0.2, ease: 'power2.out' },
)
}
},
)
return () => mm.revert()
},
{ scope: rootRef, dependencies: [drawerOpen, oneTimeSecret], revertOnUpdate: true },
)
useGSAP(
() => {
if (!lastCreatedCode) return
const mm = gsap.matchMedia()
mm.add(
{ reduceMotion: '(prefers-reduced-motion: reduce)' },
(context) => {
if (context.conditions?.reduceMotion) return
gsap.fromTo(
`[data-project-code="${lastCreatedCode}"]`,
{ autoAlpha: 0, y: 8 },
{ autoAlpha: 1, y: 0, duration: 0.22, ease: 'power2.out', clearProps: 'opacity,visibility,transform' },
)
},
)
return () => mm.revert()
},
{ scope: rootRef, dependencies: [lastCreatedCode, projects], revertOnUpdate: true },
)
const closeDrawer = () => {
setDrawerOpen(false)
setEditingCode(null)
setProjectForm(EMPTY_PROJECT_FORM)
}
useDialogKeyboard(drawerOpen, closeDrawer)
useDialogKeyboard(Boolean(rotateProject), () => setRotateProject(null))
useDialogKeyboard(Boolean(oneTimeSecret), () => setOneTimeSecret(null))
useDialogKeyboard(confirmTestOpen, () => setConfirmTestOpen(false))
const openCreateProject = () => {
setEditingCode(null)
setProjectForm(EMPTY_PROJECT_FORM)
setDrawerOpen(true)
}
const openEditProject = (project: NotificationProject) => {
setEditingCode(project.projectCode)
setProjectForm({
name: project.name,
projectCode: project.projectCode,
isActive: project.isActive,
allowedHostsText: project.allowedHosts.join('\n'),
recipientMode: project.recipientMode,
recipients: project.recipients,
})
setDrawerOpen(true)
}
const addProjectRecipient = () => {
setProjectForm((current) => ({
...current,
recipients: [...current.recipients, { alias: '', userId: '' }],
}))
}
const updateProjectRecipient = (index: number, field: keyof NotificationRecipient, value: string) => {
setProjectForm((current) => ({
...current,
recipients: current.recipients.map((recipient, recipientIndex) =>
recipientIndex === index ? { ...recipient, [field]: value } : recipient,
),
}))
}
const saveProject = async () => {
if (!projectForm.name.trim()) {
showToast('请填写项目中文名称', 'error')
return
}
if (!projectForm.projectCode.trim()) {
showToast('请填写 projectCode', 'error')
return
}
const normalizedCode = projectForm.projectCode.trim()
if (!/^[a-z][a-z0-9_-]{2,63}$/.test(normalizedCode)) {
showToast('projectCode 需以小写字母开头,只能包含小写字母、数字、下划线和短横线', 'error')
return
}
const recipients = projectForm.recipients
.map((recipient) => ({ alias: recipient.alias.trim(), userId: recipient.userId.trim() }))
.filter((recipient) => recipient.alias || recipient.userId)
if (recipients.some((recipient) => !recipient.alias || !recipient.userId)) {
showToast('每条花名都必须同时填写花名和钉钉 userId', 'error')
return
}
const normalizedAliases = recipients.map((recipient) => recipient.alias.replace(/\s+/g, '').toLocaleLowerCase('zh-CN'))
if (new Set(normalizedAliases).size !== normalizedAliases.length) {
showToast('同一项目内不能登记重复花名', 'error')
return
}
setSavingProject(true)
try {
const payload = {
name: projectForm.name.trim(),
projectCode: normalizedCode,
isActive: projectForm.isActive,
allowedHosts: parseAllowedHosts(projectForm.allowedHostsText),
recipientMode: projectForm.recipientMode,
recipients,
}
const response = await fetch(
editingCode
? `/api/admin/notification-center/projects/${encodeURIComponent(editingCode)}`
: '/api/admin/notification-center/projects',
{
method: editingCode ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
const data = await readJsonResponse(response)
closeDrawer()
await loadProjects()
if (!editingCode) {
const responsePayload = unwrapProxyData(data)
setLastCreatedCode(normalizedCode)
if (responsePayload.secret) {
setOneTimeSecret({
projectCode: normalizedCode,
secret: String(responsePayload.secret),
env: responsePayload.env && typeof responsePayload.env === 'object' ? responsePayload.env : {},
reason: 'created',
})
}
}
showToast(editingCode ? '接入项目已更新' : '接入项目已创建', 'success')
} catch (error) {
showToast(error instanceof Error ? error.message : '接入项目保存失败', 'error')
} finally {
setSavingProject(false)
}
}
const confirmRotateSecret = async () => {
if (!rotateProject) return
setRotatingSecret(true)
try {
const data = await readJsonResponse(
await fetch(
`/api/admin/notification-center/projects/${encodeURIComponent(rotateProject.projectCode)}/rotate-secret`,
{ method: 'POST' },
),
)
const responsePayload = unwrapProxyData(data)
setRotateProject(null)
setOneTimeSecret({
projectCode: rotateProject.projectCode,
secret: String(responsePayload.secret || ''),
env: responsePayload.env && typeof responsePayload.env === 'object' ? responsePayload.env : {},
reason: 'rotated',
})
showToast('项目密钥已轮换,请立即更新调用方环境变量', 'success')
} catch (error) {
showToast(error instanceof Error ? error.message : '项目密钥轮换失败', 'error')
} finally {
setRotatingSecret(false)
}
}
const saveRecipients = async () => {
if (!recipientProjectCode) return
const rows = recipientRows
.map((recipient) => ({ alias: recipient.alias.trim(), userId: recipient.userId.trim() }))
.filter((recipient) => recipient.alias || recipient.userId)
if (rows.some((row) => !row.alias || !row.userId)) {
showToast('每条花名都必须同时填写花名和钉钉 userId', 'error')
return
}
const aliases = rows.map((row) => row.alias.replace(/\s+/g, '').toLocaleLowerCase('zh-CN'))
if (new Set(aliases).size !== aliases.length) {
showToast('同一项目内不能登记重复花名', 'error')
return
}
setSavingRecipients(true)
try {
await readJsonResponse(
await fetch(
`/api/admin/notification-center/projects/${encodeURIComponent(recipientProjectCode)}/recipients`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipients: rows }),
},
),
)
await loadProjects()
showToast('收件人花名已保存', 'success')
} catch (error) {
showToast(error instanceof Error ? error.message : '花名保存失败', 'error')
} finally {
setSavingRecipients(false)
}
}
const resolvedTestRecipient = useMemo(() => {
if (!selectedTestProject || !testRecipient.trim()) return null
if (testRecipientMode === 'userId') {
return { label: `钉钉 userId:${testRecipient.trim()}`, userId: testRecipient.trim() }
}
const recipient = selectedTestProject.recipients.find(
(item) => item.alias === testRecipient,
)
return recipient ? { label: `${recipient.alias}(${recipient.userId})`, userId: recipient.userId } : null
}, [selectedTestProject, testRecipient, testRecipientMode])
const sendTestNotification = async () => {
if (!selectedTestProject || !resolvedTestRecipient) return
setSendingTest(true)
try {
const payload = {
recipientAlias: testRecipientMode === 'alias' ? testRecipient : undefined,
recipientUserId: testRecipientMode === 'userId' ? testRecipient.trim() : undefined,
title: testTitle.trim(),
content: testBody.trim(),
actionTitle: testButtonLabel.trim() || undefined,
actionUrl: testActionUrl.trim() || undefined,
}
await readJsonResponse(
await fetch(
`/api/admin/notification-center/projects/${encodeURIComponent(selectedTestProject.projectCode)}/test`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
),
)
setConfirmTestOpen(false)
showToast('测试通知已进入发送队列', 'success')
} catch (error) {
setConfirmTestOpen(false)
const message = error instanceof Error ? error.message : '测试通知发送失败'
showToast(`${message}。页面没有自动重发,请先到投递记录核验是否已经发送。`, 'error')
} finally {
setSendingTest(false)
}
}
const copyText = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value)
showToast(`${label}已复制`, 'success')
} catch {
showToast('复制失败,请手动选择文本复制', 'error')
}
}
const enabledProjectCount = projects.filter((project) => project.isActive).length
const aliasCount = projects.reduce((total, project) => total + project.recipientCount, 0)
const failedCount = projects.reduce(
(total, project) => total + project.deliverySummary.recentFailedCount,
0,
)
return (
<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-2 divide-x divide-y divide-[#e2e8f0] overflow-hidden rounded-[10px] border border-[#e2e8f0] bg-[#f8fafc] lg:grid-cols-4 lg:divide-y-0">
{[
['接入项目', String(projects.length), '当前已登记'],
['正在使用', String(enabledProjectCount), '已启用项目'],
['收件人花名', String(aliasCount), '已登记映射'],
['近期失败', 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={() => setActiveView(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]">新增项目后即可生成项目密钥、登记花名并发送测试通知。</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,1fr)_minmax(170px,1fr)_110px_130px_44px] 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><span>更新时间</span><span className="sr-only">操作</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,1fr)_minmax(170px,1fr)_110px_130px_44px] 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 className="min-w-0 text-[12px] leading-5 text-[#4f6077]">
<span className="font-medium text-[#66758b] md:hidden">允许域名:</span>
{project.allowedHosts.length > 0 ? project.allowedHosts.slice(0, 2).join('、') : '未配置'}
{project.allowedHosts.length > 2 ? ` 等 ${project.allowedHosts.length} 个` : ''}
</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-[13px] text-[#4f6077]"><span className="font-medium text-[#66758b] md:hidden">花名:</span>{project.recipientCount}</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 h-10 w-10 items-center justify-center justify-self-end rounded-[8px] border border-[#d9e0e7] text-[#4f6077] 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"
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
</div>
))}
</div>
</div>
)}
</section>
)}
{activeView === 'recipients' && (
<section aria-labelledby="notification-recipients-heading">
<h3 id="notification-recipients-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 max-w-3xl">
<label htmlFor="recipient-project" className="block text-[13px] font-semibold text-[#344054]">所属项目</label>
<select id="recipient-project" value={recipientProjectCode} onChange={(event) => setRecipientProjectCode(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] text-[#101828] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
{projects.map((project) => <option key={project.projectCode} value={project.projectCode}>{project.name} · {project.projectCode}</option>)}
</select>
<div className="mt-5 overflow-hidden rounded-[10px] border border-[#e2e8f0]">
<div className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2 bg-[#f8fafc] px-3 py-2 text-[12px] font-semibold text-[#66758b]"><span>花名</span><span>钉钉 userId</span><span className="sr-only">删除</span></div>
<div className="divide-y divide-[#e2e8f0]">
{recipientRows.length === 0 && <p className="px-4 py-6 text-center text-[13px] text-[#66758b]">暂无花名。点击下方按钮登记明确的花名与钉钉 userId。</p>}
{recipientRows.map((recipient, index) => (
<div key={`${index}-${recipient.alias}`} className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2 px-3 py-2">
<label className="sr-only" htmlFor={`recipient-alias-${index}`}>第 {index + 1} 条花名</label>
<input id={`recipient-alias-${index}`} value={recipient.alias} onChange={(event) => setRecipientRows((current) => current.map((row, rowIndex) => rowIndex === index ? { ...row, alias: event.target.value } : row))} placeholder="例如:小王" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
<label className="sr-only" htmlFor={`recipient-user-${index}`}>第 {index + 1} 条钉钉 userId</label>
<input id={`recipient-user-${index}`} value={recipient.userId} onChange={(event) => setRecipientRows((current) => current.map((row, rowIndex) => rowIndex === index ? { ...row, userId: event.target.value } : row))} placeholder="精确钉钉 userId" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
<button type="button" onClick={() => setRecipientRows((current) => current.filter((_, rowIndex) => rowIndex !== index))} aria-label={`删除花名 ${recipient.alias || index + 1}`} className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#b42318] hover:bg-[#fef0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-4 w-4" aria-hidden="true" /></button>
</div>
))}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button type="button" onClick={() => setRecipientRows((current) => [...current, { alias: '', userId: '' }])} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><Plus className="h-4 w-4" aria-hidden="true" />登记花名</button>
<button type="button" onClick={() => void saveRecipients()} disabled={savingRecipients} 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">{savingRecipients ? '保存中…' : '保存花名清单'}</button>
</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="userId">精确钉钉 userId</option><option value="alias">已登记花名</option>
</select>
</div>
<div>
<label htmlFor="test-recipient" className="block text-[13px] font-semibold text-[#344054]">真实收件人</label>
{testRecipientMode === 'alias' ? (
<select id="test-recipient" value={testRecipient} onChange={(event) => setTestRecipient(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">
<option value="">请选择已登记花名</option>
{(selectedTestProject?.recipients || []).map((recipient) => <option key={recipient.alias} value={recipient.alias}>{recipient.alias} · {recipient.userId}</option>)}
</select>
) : (
<input id="test-recipient" value={testRecipient} onChange={(event) => setTestRecipient(event.target.value)} placeholder="输入精确钉钉 userId" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
)}
</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="需属于 allowedHosts" 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]">
{resolvedTestRecipient ? `将通过“${selectedTestProject?.name}”向 ${resolvedTestRecipient.label} 发送一条真实钉钉单聊。` : '请选择或填写明确收件人。系统不会根据姓名模糊猜人。'}
</div>
<div className="sm:col-span-2"><button type="button" onClick={() => setConfirmTestOpen(true)} disabled={!resolvedTestRecipient || !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 ? '修改名称、域名、状态和收件人方式。' : '创建后会生成只展示一次的项目密钥。'}</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>
<div><label htmlFor="project-code" className="block text-[13px] font-semibold text-[#344054]">projectCode</label><input id="project-code" disabled={Boolean(editingCode)} 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 disabled:bg-[#f2f4f7] disabled:text-[#66758b]" /><p className="mt-1 text-[12px] leading-5 text-[#66758b]">创建后不可修改,用于签名和调用方环境变量。</p></div>
<div><label htmlFor="project-hosts" className="block text-[13px] font-semibold text-[#344054]">允许跳转域名 allowedHosts</label><textarea id="project-hosts" rows={4} value={projectForm.allowedHostsText} onChange={(event) => setProjectForm((current) => ({ ...current, allowedHostsText: event.target.value }))} placeholder={'example.com\ninternal.example.com'} className="mt-2 w-full resize-y rounded-[8px] border border-[#cfd8e3] px-3 py-2 font-mono text-[13px] leading-6 focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /><p className="mt-1 text-[12px] leading-5 text-[#66758b]">每行一个主机名,不含协议、端口或路径。通知按钮只能跳转到这些域名。</p></div>
<div><label htmlFor="project-recipient-mode" className="block text-[13px] font-semibold text-[#344054]">默认收件人方式</label><select id="project-recipient-mode" value={projectForm.recipientMode} onChange={(event) => setProjectForm((current) => ({ ...current, recipientMode: event.target.value as RecipientMode }))} 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="userId">精确钉钉 userId</option><option value="alias">已登记花名</option></select></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>
{!editingCode && projectForm.recipientMode === 'alias' && <div><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">初始花名清单</span><button type="button" onClick={addProjectRecipient} className="inline-flex min-h-10 items-center gap-1 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]"><Plus className="h-4 w-4" aria-hidden="true" />添加花名</button></div><div className="mt-2 space-y-2">{projectForm.recipients.map((recipient, index) => <div key={index} className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2"><input aria-label={`第 ${index + 1} 条花名`} value={recipient.alias} onChange={(event) => updateProjectRecipient(index, 'alias', event.target.value)} placeholder="花名" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] focus:border-[#1677ff] focus:outline-none" /><input aria-label={`第 ${index + 1} 条钉钉 userId`} value={recipient.userId} onChange={(event) => updateProjectRecipient(index, 'userId', event.target.value)} placeholder="钉钉 userId" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] focus:border-[#1677ff] focus:outline-none" /><button type="button" aria-label={`删除第 ${index + 1} 条花名`} onClick={() => setProjectForm((current) => ({ ...current, recipients: current.recipients.filter((_, rowIndex) => rowIndex !== index) }))} className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#b42318] hover:bg-[#fef0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-4 w-4" aria-hidden="true" /></button></div>)}</div></div>}
{editingCode && <div className="rounded-[8px] border border-[#e2e8f0] p-4"><div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-[13px] font-semibold text-[#344054]">项目密钥</p><p className="mt-1 text-[12px] leading-5 text-[#66758b]">现有密钥不会再次显示。需要更换时执行轮换。</p></div><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-[#cfd8e3] px-3 text-[13px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><KeyRound className="h-4 w-4" aria-hidden="true" />轮换项目密钥</button></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 && resolvedTestRecipient && 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]">{resolvedTestRecipient.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-[#b42318]">下面的项目密钥只完整展示这一次。关闭后无法再次查看,请现在复制并保存到调用方安全环境变量。</p><div className="mt-5"><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 className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">调用方环境变量</span><button type="button" onClick={() => void copyText(Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n'), '环境变量')} 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><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>}
</div>
)
}