gongxiangp2/components/SuperAdminsTab.tsx

1293 lines
53 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 { useEffect, useState } from 'react'
import { parseFetchError } from '@/lib/error-handler'
interface SuperAdmin {
id: string
email: string
username: string
realName: string | null
department: string
role: string
createdAt: string
}
interface ApiKey {
id: string
name: string
key: string
isActive: boolean
createdBy: string
lastUsedAt: string | null
createdAt: string
}
type NotificationAuthType = 'none' | 'bearer' | 'header'
interface ExternalNotificationTarget {
id: string
name: string
callbackUrl: string
isActive: boolean
authType: NotificationAuthType
authConfig: Record<string, string>
eventTypes: string[]
createdAt: string
updatedAt: string
}
interface ExternalNotificationDelivery {
id: string
targetId: string
targetName: string
eventType: string
materialId: string
materialType: string
taskNo: string | null
status: string
attemptCount: number
maxAttempts: number
nextRetryAt: string | null
lastStatusCode: number | null
lastResponseBody: string | null
lastError: string | null
createdAt: string
updatedAt: string
completedAt: string | null
}
interface NotificationTargetFormState {
name: string
callbackUrl: string
authType: NotificationAuthType
token: string
headerName: string
headerValue: string
eventTypes: string[]
isActive: boolean
}
const NOTIFICATION_EVENT_OPTIONS = [
{
value: 'task-detail.full-set.ready',
label: '任务-详情全套入库完成',
description: '整套素材完成后推送成套资源信息。',
},
{
value: 'task-detail.barcode-images.updated',
label: '任务-详情条码图片更新',
description: '带产品条码的 SKU 预览图新增或更新后推送条码清单。',
},
]
const EMPTY_NOTIFICATION_TARGET_FORM: NotificationTargetFormState = {
name: '',
callbackUrl: '',
authType: 'none',
token: '',
headerName: '',
headerValue: '',
eventTypes: ['task-detail.full-set.ready'],
isActive: true,
}
const NOTIFICATION_DELIVERY_STATUS_OPTIONS = [
{ value: 'all', label: '全部状态' },
{ value: 'pending', label: '待发送' },
{ value: 'processing', label: '发送中' },
{ value: 'retrying', label: '重试中' },
{ value: 'succeeded', label: '已成功' },
{ value: 'exhausted', label: '待人工处理' },
]
function formatDateTime(value: string | null) {
if (!value) {
return '无'
}
return new Date(value).toLocaleString('zh-CN')
}
function maskSecret(value: string | undefined) {
const normalized = String(value || '').trim()
if (!normalized) {
return '未配置'
}
if (normalized.length <= 8) {
return '已配置'
}
return `${normalized.slice(0, 4)}****${normalized.slice(-4)}`
}
function getNotificationTargetAuthSummary(target: ExternalNotificationTarget) {
if (target.authType === 'bearer') {
return `Bearer · ${maskSecret(target.authConfig.token)}`
}
if (target.authType === 'header') {
const headerName = target.authConfig.headerName || '自定义请求头'
return `Header · ${headerName}`
}
return '无鉴权'
}
function getNotificationDeliveryStatusLabel(status: string) {
switch (status) {
case 'pending':
return '待发送'
case 'processing':
return '发送中'
case 'retrying':
return '重试中'
case 'succeeded':
return '已成功'
case 'exhausted':
return '待人工处理'
default:
return status
}
}
function getNotificationDeliveryStatusClassName(status: string) {
switch (status) {
case 'succeeded':
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
case 'retrying':
case 'processing':
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-200'
case 'exhausted':
return 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-200'
default:
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}
}
interface SuperAdminsTabProps {
showToast: (message: string, type?: 'success' | 'error') => void
}
export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
const [superAdmins, setSuperAdmins] = useState<SuperAdmin[]>([])
const [loading, setLoading] = useState(true)
const [count, setCount] = useState(0)
const [canAdd, setCanAdd] = useState(false)
const [addingUserId, setAddingUserId] = useState('')
const [adding, setAdding] = useState(false)
const [removingUserId, setRemovingUserId] = useState<string | null>(null)
// API Keys 相关状态
const [apiKeys, setApiKeys] = useState<ApiKey[]>([])
const [apiKeysLoading, setApiKeysLoading] = useState(true)
const [showCreateModal, setShowCreateModal] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [createdKey, setCreatedKey] = useState<string | null>(null)
const [notificationTargets, setNotificationTargets] = useState<ExternalNotificationTarget[]>([])
const [notificationTargetsLoading, setNotificationTargetsLoading] = useState(true)
const [notificationTargetForm, setNotificationTargetForm] = useState<NotificationTargetFormState>(
EMPTY_NOTIFICATION_TARGET_FORM,
)
const [editingNotificationTargetId, setEditingNotificationTargetId] = useState<string | null>(null)
const [savingNotificationTarget, setSavingNotificationTarget] = useState(false)
const [togglingNotificationTargetId, setTogglingNotificationTargetId] = useState<string | null>(null)
const [notificationDeliveries, setNotificationDeliveries] = useState<ExternalNotificationDelivery[]>([])
const [notificationDeliveriesLoading, setNotificationDeliveriesLoading] = useState(true)
const [notificationDeliveryStatusFilter, setNotificationDeliveryStatusFilter] = useState('all')
const [retryingNotificationDeliveryId, setRetryingNotificationDeliveryId] = useState<string | null>(null)
// 加载超级管理员列表
const loadSuperAdmins = async () => {
try {
const response = await fetch('/api/admin/super-admins')
if (response.ok) {
const data = await response.json()
setSuperAdmins(data.superAdmins)
setCount(data.count)
setCanAdd(data.canAdd)
} else {
showToast('加载超级管理员列表失败', 'error')
}
} catch (error) {
console.error('Load super admins error:', error)
showToast('加载超级管理员列表失败', 'error')
} finally {
setLoading(false)
}
}
useEffect(() => {
loadSuperAdmins()
fetchApiKeys()
fetchNotificationTargets()
}, [])
useEffect(() => {
fetchNotificationDeliveries(notificationDeliveryStatusFilter)
}, [notificationDeliveryStatusFilter])
// 添加超级管理员
const handleAddSuperAdmin = async () => {
if (!addingUserId.trim()) {
showToast('请输入用户ID', 'error')
return
}
setAdding(true)
try {
const response = await fetch('/api/admin/super-admins', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: addingUserId.trim() }),
})
if (response.ok) {
showToast('超级管理员添加成功', 'success')
setAddingUserId('')
await loadSuperAdmins()
} else {
const data = await response.json()
showToast(data.error || '添加失败', 'error')
}
} catch (error) {
console.error('Add super admin error:', error)
showToast('添加失败', 'error')
} finally {
setAdding(false)
}
}
// 移除超级管理员
const handleRemoveSuperAdmin = async (userId: string) => {
if (!confirm('确定要移除该超级管理员权限吗?')) {
return
}
setRemovingUserId(userId)
try {
const response = await fetch(`/api/admin/super-admins?userId=${userId}`, {
method: 'DELETE',
})
if (response.ok) {
showToast('超级管理员权限已移除', 'success')
await loadSuperAdmins()
} else {
const data = await response.json()
showToast(data.error || '移除失败', 'error')
}
} catch (error) {
console.error('Remove super admin error:', error)
showToast('移除失败', 'error')
} finally {
setRemovingUserId(null)
}
}
// 加载 API Keys
const fetchApiKeys = async () => {
try {
setApiKeysLoading(true)
const response = await fetch('/api/admin/api-keys')
if (response.ok) {
const data = await response.json()
setApiKeys(data.data || [])
} else {
const errorData = await parseFetchError(response)
showToast(errorData.message, 'error')
}
} catch (error) {
console.error('加载 API Keys 失败:', error)
const errorMessage = error instanceof Error ? error.message : '加载失败'
showToast(errorMessage, 'error')
} finally {
setApiKeysLoading(false)
}
}
const resetNotificationTargetForm = () => {
setEditingNotificationTargetId(null)
setNotificationTargetForm(EMPTY_NOTIFICATION_TARGET_FORM)
}
const fetchNotificationTargets = async () => {
try {
setNotificationTargetsLoading(true)
const response = await fetch('/api/admin/external-notification-targets')
if (response.ok) {
const data = await response.json()
setNotificationTargets(data.data || [])
} else {
const errorData = await parseFetchError(response)
showToast(errorData.message, 'error')
}
} catch (error) {
console.error('加载自动上架通知目标失败:', error)
const errorMessage = error instanceof Error ? error.message : '加载失败'
showToast(errorMessage, 'error')
} finally {
setNotificationTargetsLoading(false)
}
}
const fetchNotificationDeliveries = async (status: string) => {
try {
setNotificationDeliveriesLoading(true)
const searchParams = new URLSearchParams()
if (status && status !== 'all') {
searchParams.set('status', status)
}
searchParams.set('limit', '50')
const query = searchParams.toString()
const response = await fetch(
`/api/admin/external-notification-deliveries${query ? `?${query}` : ''}`,
)
if (response.ok) {
const data = await response.json()
setNotificationDeliveries(data.data || [])
} else {
const errorData = await parseFetchError(response)
showToast(errorData.message, 'error')
}
} catch (error) {
console.error('加载自动上架投递记录失败:', error)
const errorMessage = error instanceof Error ? error.message : '加载失败'
showToast(errorMessage, 'error')
} finally {
setNotificationDeliveriesLoading(false)
}
}
const handleSaveNotificationTarget = async () => {
if (!notificationTargetForm.name.trim()) {
showToast('请填写目标系统名称', 'error')
return
}
if (!notificationTargetForm.callbackUrl.trim()) {
showToast('请填写回调地址', 'error')
return
}
if (notificationTargetForm.authType === 'bearer' && !notificationTargetForm.token.trim()) {
showToast('请选择 Bearer 鉴权时必须填写 token', 'error')
return
}
if (
notificationTargetForm.authType === 'header' &&
(!notificationTargetForm.headerName.trim() || !notificationTargetForm.headerValue.trim())
) {
showToast('自定义 Header 鉴权需要填写请求头名称和值', 'error')
return
}
if (notificationTargetForm.eventTypes.length === 0) {
showToast('请至少选择 1 个通知事件', 'error')
return
}
setSavingNotificationTarget(true)
try {
const authConfig =
notificationTargetForm.authType === 'bearer'
? { token: notificationTargetForm.token.trim() }
: notificationTargetForm.authType === 'header'
? {
headerName: notificationTargetForm.headerName.trim(),
headerValue: notificationTargetForm.headerValue.trim(),
}
: {}
const payload = {
name: notificationTargetForm.name.trim(),
callbackUrl: notificationTargetForm.callbackUrl.trim(),
isActive: notificationTargetForm.isActive,
authType: notificationTargetForm.authType,
authConfig,
eventTypes: notificationTargetForm.eventTypes,
}
const response = await fetch(
editingNotificationTargetId
? `/api/admin/external-notification-targets/${editingNotificationTargetId}`
: '/api/admin/external-notification-targets',
{
method: editingNotificationTargetId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
if (response.ok) {
showToast(editingNotificationTargetId ? '通知目标已更新' : '通知目标已创建', 'success')
resetNotificationTargetForm()
await fetchNotificationTargets()
} else {
const errorData = await parseFetchError(response)
showToast(errorData.message, 'error')
}
} catch (error) {
console.error('保存自动上架通知目标失败:', error)
const errorMessage = error instanceof Error ? error.message : '保存失败'
showToast(errorMessage, 'error')
} finally {
setSavingNotificationTarget(false)
}
}
const handleEditNotificationTarget = (target: ExternalNotificationTarget) => {
setEditingNotificationTargetId(target.id)
setNotificationTargetForm({
name: target.name,
callbackUrl: target.callbackUrl,
authType: target.authType,
token: target.authConfig.token || '',
headerName: target.authConfig.headerName || '',
headerValue: target.authConfig.headerValue || '',
eventTypes: target.eventTypes?.length > 0 ? target.eventTypes : ['task-detail.full-set.ready'],
isActive: target.isActive,
})
}
const handleToggleNotificationTargetActive = async (target: ExternalNotificationTarget) => {
setTogglingNotificationTargetId(target.id)
try {
const response = await fetch(`/api/admin/external-notification-targets/${target.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
isActive: !target.isActive,
}),
})
if (response.ok) {
showToast(target.isActive ? '通知目标已停用' : '通知目标已启用', 'success')
await fetchNotificationTargets()
} else {
const errorData = await parseFetchError(response)
showToast(errorData.message, 'error')
}
} catch (error) {
console.error('切换通知目标状态失败:', error)
const errorMessage = error instanceof Error ? error.message : '更新失败'
showToast(errorMessage, 'error')
} finally {
setTogglingNotificationTargetId(null)
}
}
const handleRetryNotificationDelivery = async (deliveryId: string) => {
setRetryingNotificationDeliveryId(deliveryId)
try {
const response = await fetch(`/api/admin/external-notification-deliveries/${deliveryId}/retry`, {
method: 'POST',
})
if (response.ok) {
showToast('补发任务已重新入队', 'success')
await fetchNotificationDeliveries(notificationDeliveryStatusFilter)
} else {
const errorData = await parseFetchError(response)
showToast(errorData.message, 'error')
}
} catch (error) {
console.error('手工补发失败:', error)
const errorMessage = error instanceof Error ? error.message : '补发失败'
showToast(errorMessage, 'error')
} finally {
setRetryingNotificationDeliveryId(null)
}
}
// 创建 API Key
const handleCreateApiKey = async () => {
if (!newKeyName.trim()) {
showToast('请输入密钥名称', 'error')
return
}
try {
const response = await fetch('/api/admin/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName }),
})
if (response.ok) {
const data = await response.json()
setCreatedKey(data.data.key)
setNewKeyName('')
showToast('API Key 创建成功', 'success')
fetchApiKeys()
} else {
const errorData = await parseFetchError(response)
showToast(errorData.message, 'error')
}
} catch (error) {
console.error('创建失败:', error)
const errorMessage = error instanceof Error ? error.message : '创建失败'
showToast(errorMessage, 'error')
}
}
// 切换 API Key 状态
const handleToggleApiKeyActive = async (id: string, isActive: boolean) => {
try {
const response = await fetch(`/api/admin/api-keys/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isActive: !isActive }),
})
if (response.ok) {
showToast(isActive ? 'API Key 已禁用' : 'API Key 已启用', 'success')
fetchApiKeys()
} else {
const errorData = await parseFetchError(response)
showToast(errorData.message, 'error')
}
} catch (error) {
console.error('更新失败:', error)
const errorMessage = error instanceof Error ? error.message : '更新失败'
showToast(errorMessage, 'error')
}
}
// 删除 API Key
const handleDeleteApiKey = async (id: string) => {
if (!confirm('确定要删除此 API Key 吗?删除后无法恢复。')) return
try {
const response = await fetch(`/api/admin/api-keys/${id}`, {
method: 'DELETE',
})
if (response.ok) {
showToast('API Key 已删除', 'success')
fetchApiKeys()
} else {
const errorData = await parseFetchError(response)
showToast(errorData.message, 'error')
}
} catch (error) {
console.error('删除失败:', error)
const errorMessage = error instanceof Error ? error.message : '删除失败'
showToast(errorMessage, 'error')
}
}
if (loading) {
return (
<div className="card-apple">
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
加载中...
</div>
</div>
)
}
return (
<div className="card-apple">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
超级管理员管理
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
当前:{count} / 4 个超级管理员
{!canAdd && <span className="ml-2 text-orange-600 dark:text-orange-400">(已达上限)</span>}
</p>
</div>
<div className="p-6 space-y-6">
{/* 添加超级管理员 */}
{canAdd && (
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
添加超级管理员
</h3>
<div className="flex gap-2">
<input
type="text"
value={addingUserId}
onChange={(e) => setAddingUserId(e.target.value)}
placeholder="输入用户ID"
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
onClick={handleAddSuperAdmin}
disabled={adding || !addingUserId.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{adding ? '添加中...' : '添加'}
</button>
</div>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
提示:只能添加管理员角色的用户,且最多4个超级管理员
</p>
</div>
)}
{/* 超级管理员列表 */}
<div>
<h3 className="text-sm font-medium text-gray-900 dark:text-white mb-3">
超级管理员列表
</h3>
{superAdmins.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
暂无超级管理员
</div>
) : (
<div className="space-y-2">
{superAdmins.map((admin) => (
<div
key={admin.id}
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700"
>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{admin.realName || admin.username}
</span>
<span className="px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">
超级管理员
</span>
</div>
<div className="mt-1 text-sm text-gray-500 dark:text-gray-400">
<span>{admin.email}</span>
{admin.department && (
<span className="ml-2">· {admin.department}</span>
)}
</div>
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
创建时间:{new Date(admin.createdAt).toLocaleString('zh-CN')}
</div>
</div>
<button
onClick={() => handleRemoveSuperAdmin(admin.id)}
disabled={removingUserId === admin.id}
className="ml-4 px-3 py-1.5 text-sm text-red-600 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300 bg-red-50 dark:bg-red-900/20 rounded-lg hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors disabled:opacity-50"
>
{removingUserId === admin.id ? '移除中...' : '移除'}
</button>
</div>
))}
</div>
)}
</div>
{/* API Key 管理卡片 */}
<div className="mt-8">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
API Key 管理
</h3>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
创建 API Key
</button>
</div>
{apiKeysLoading ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
加载中...
</div>
) : apiKeys.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<p>暂无 API Key</p>
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
点击上方按钮创建第一个 API Key
</p>
</div>
) : (
<div className="overflow-x-auto bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-100 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
名称
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
密钥
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
状态
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
最后使用
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
创建时间
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{apiKeys.map((key) => (
<tr key={key.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{key.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-500 dark:text-gray-400">
{key.key.substring(0, 16)}...
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`px-2 py-1 text-xs rounded ${
key.isActive
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{key.isActive ? '启用' : '禁用'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleString('zh-CN')
: '从未使用'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{new Date(key.createdAt).toLocaleString('zh-CN')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm space-x-2">
<button
onClick={() => handleToggleApiKeyActive(key.id, key.isActive)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
{key.isActive ? '禁用' : '启用'}
</button>
<button
onClick={() => handleDeleteApiKey(key.id)}
className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300"
>
删除
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="mt-8">
<div className="flex flex-col gap-3 mb-4 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
外部系统通知目标
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
这里配置素材库主动通知的目标系统。`task-detail.full-set.ready` 主要给自动上架平台,`task-detail.barcode-images.updated` 主要给产品平台。
</p>
</div>
<button
onClick={() => {
resetNotificationTargetForm()
fetchNotificationTargets()
}}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-300 transition-colors"
>
刷新目标
</button>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_minmax(320px,0.8fr)]">
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
已配置目标
</h4>
</div>
<div className="p-4">
{notificationTargetsLoading ? (
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
加载中...
</div>
) : notificationTargets.length === 0 ? (
<div className="py-8 text-center text-gray-500 dark:text-gray-400">
暂无通知目标
</div>
) : (
<div className="space-y-3">
{notificationTargets.map((target) => (
<div
key={target.id}
className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4"
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{target.name}
</span>
<span
className={`px-2 py-0.5 text-xs rounded ${
target.isActive
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
}`}
>
{target.isActive ? '启用中' : '已停用'}
</span>
</div>
<div className="mt-2 text-xs font-mono break-all text-gray-500 dark:text-gray-400">
{target.callbackUrl}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-300">
鉴权:{getNotificationTargetAuthSummary(target)}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{target.eventTypes.map((eventType) => (
<span
key={eventType}
className="rounded bg-blue-50 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/20 dark:text-blue-200"
>
{eventType === 'task-detail.full-set.ready'
? '自动上架平台 / 全套入库'
: eventType === 'task-detail.barcode-images.updated'
? '产品平台 / 条码图片更新'
: eventType}
</span>
))}
</div>
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
更新时间:{formatDateTime(target.updatedAt)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEditNotificationTarget(target)}
className="px-3 py-1.5 text-sm text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
编辑
</button>
<button
onClick={() => handleToggleNotificationTargetActive(target)}
disabled={togglingNotificationTargetId === target.id}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50"
>
{togglingNotificationTargetId === target.id
? '处理中...'
: target.isActive
? '停用'
: '启用'}
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
{editingNotificationTargetId ? '编辑通知目标' : '新增通知目标'}
</h4>
</div>
<div className="p-4 space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
目标系统名称
</label>
<input
type="text"
value={notificationTargetForm.name}
onChange={(e) =>
setNotificationTargetForm((current) => ({
...current,
name: e.target.value,
}))
}
placeholder="例如:自动上架平台 / 产品平台"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
回调地址
</label>
<input
type="text"
value={notificationTargetForm.callbackUrl}
onChange={(e) =>
setNotificationTargetForm((current) => ({
...current,
callbackUrl: e.target.value,
}))
}
placeholder="https://example.com/callback"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
鉴权方式
</label>
<select
value={notificationTargetForm.authType}
onChange={(e) =>
setNotificationTargetForm((current) => ({
...current,
authType: e.target.value as NotificationAuthType,
}))
}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="none">无鉴权</option>
<option value="bearer">Bearer</option>
<option value="header">自定义 Header</option>
</select>
</div>
{notificationTargetForm.authType === 'bearer' && (
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
Bearer Token
</label>
<input
type="text"
value={notificationTargetForm.token}
onChange={(e) =>
setNotificationTargetForm((current) => ({
...current,
token: e.target.value,
}))
}
placeholder="请输入 token"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
)}
{notificationTargetForm.authType === 'header' && (
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
Header 名称
</label>
<input
type="text"
value={notificationTargetForm.headerName}
onChange={(e) =>
setNotificationTargetForm((current) => ({
...current,
headerName: e.target.value,
}))
}
placeholder="例如:X-Signature"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
Header 值
</label>
<input
type="text"
value={notificationTargetForm.headerValue}
onChange={(e) =>
setNotificationTargetForm((current) => ({
...current,
headerValue: e.target.value,
}))
}
placeholder="请输入请求头值"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
)}
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input
type="checkbox"
checked={notificationTargetForm.isActive}
onChange={(e) =>
setNotificationTargetForm((current) => ({
...current,
isActive: e.target.checked,
}))
}
className="rounded border-gray-300 dark:border-gray-600"
/>
创建后立即启用
</label>
<div className="space-y-2 rounded-lg border border-blue-200 dark:border-blue-900/40 bg-blue-50 dark:bg-blue-900/10 px-3 py-3 text-xs text-blue-700 dark:text-blue-200">
<div className="font-medium">订阅事件</div>
{NOTIFICATION_EVENT_OPTIONS.map((eventOption) => (
<label
key={eventOption.value}
className="flex items-start gap-2 text-sm text-blue-900 dark:text-blue-100"
>
<input
type="checkbox"
checked={notificationTargetForm.eventTypes.includes(eventOption.value)}
onChange={(e) =>
setNotificationTargetForm((current) => ({
...current,
eventTypes: e.target.checked
? Array.from(new Set([...current.eventTypes, eventOption.value]))
: current.eventTypes.filter((eventType) => eventType !== eventOption.value),
}))
}
className="mt-0.5 rounded border-blue-300 dark:border-blue-700"
/>
<span>
<span className="block font-medium">{eventOption.label}</span>
<span className="block text-xs text-blue-700/80 dark:text-blue-200/80">
{eventOption.description}
</span>
<span className="block font-mono text-[11px] text-blue-700/80 dark:text-blue-200/80">
{eventOption.value}
</span>
</span>
</label>
))}
</div>
<div className="flex gap-3">
<button
onClick={handleSaveNotificationTarget}
disabled={savingNotificationTarget}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{savingNotificationTarget
? '保存中...'
: editingNotificationTargetId
? '保存修改'
: '创建目标'}
</button>
<button
onClick={resetNotificationTargetForm}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
>
{editingNotificationTargetId ? '取消编辑' : '清空'}
</button>
</div>
</div>
</div>
</div>
</div>
<div className="mt-8">
<div className="flex flex-col gap-3 mb-4 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
自动上架投递记录
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
查看最近 50 条通知投递情况;失败或超上限的记录可以在这里手工补发。
</p>
</div>
<div className="flex gap-2">
<select
value={notificationDeliveryStatusFilter}
onChange={(e) => setNotificationDeliveryStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-sm text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{NOTIFICATION_DELIVERY_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button
onClick={() => fetchNotificationDeliveries(notificationDeliveryStatusFilter)}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-sm text-gray-700 dark:text-gray-300 transition-colors"
>
刷新记录
</button>
</div>
</div>
{notificationDeliveriesLoading ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
加载中...
</div>
) : notificationDeliveries.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
当前筛选条件下暂无投递记录
</div>
) : (
<div className="overflow-x-auto bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-100 dark:bg-gray-700">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
目标系统
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
素材
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
状态
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
尝试次数
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
最近结果
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
下一次重试
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
更新时间
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{notificationDeliveries.map((delivery) => (
<tr key={delivery.id} className="hover:bg-gray-50 dark:hover:bg-gray-800">
<td className="px-4 py-4 align-top">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{delivery.targetName || '未知目标'}
</div>
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500 font-mono">
{delivery.id}
</div>
</td>
<td className="px-4 py-4 align-top">
<div className="text-sm font-medium text-gray-900 dark:text-white">
{delivery.materialType}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 font-mono break-all">
materialId: {delivery.materialId}
</div>
<div className="mt-1 text-xs text-gray-400 dark:text-gray-500">
taskNo: {delivery.taskNo || '无'}
</div>
</td>
<td className="px-4 py-4 align-top">
<span
className={`px-2 py-1 text-xs rounded ${getNotificationDeliveryStatusClassName(delivery.status)}`}
>
{getNotificationDeliveryStatusLabel(delivery.status)}
</span>
</td>
<td className="px-4 py-4 align-top text-sm text-gray-600 dark:text-gray-300">
{delivery.attemptCount} / {delivery.maxAttempts}
</td>
<td className="px-4 py-4 align-top">
<div className="text-sm text-gray-700 dark:text-gray-300">
{delivery.lastStatusCode ? `HTTP ${delivery.lastStatusCode}` : '暂无'}
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400 max-w-xs break-words">
{delivery.lastError || delivery.lastResponseBody || '无'}
</div>
</td>
<td className="px-4 py-4 align-top text-sm text-gray-600 dark:text-gray-300">
{formatDateTime(delivery.nextRetryAt)}
</td>
<td className="px-4 py-4 align-top text-sm text-gray-600 dark:text-gray-300">
{formatDateTime(delivery.updatedAt)}
</td>
<td className="px-4 py-4 align-top text-right">
<button
onClick={() => handleRetryNotificationDelivery(delivery.id)}
disabled={
retryingNotificationDeliveryId === delivery.id ||
delivery.status === 'succeeded'
}
className="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{retryingNotificationDeliveryId === delivery.id ? '补发中...' : '手工补发'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* 创建 API Key 模态框 */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
创建 API Key
</h2>
{createdKey ? (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
API Key 已创建,请妥善保存:
</p>
<div className="bg-gray-100 dark:bg-gray-700 p-3 rounded font-mono text-sm break-all mb-4 text-gray-900 dark:text-white">
{createdKey}
</div>
<p className="text-sm text-red-600 dark:text-red-400 mb-4">
注意:此密钥只显示一次,关闭后将无法再次查看
</p>
<button
onClick={() => {
setCreatedKey(null)
setShowCreateModal(false)
}}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
关闭
</button>
</div>
) : (
<div>
<label className="block text-sm font-medium mb-2 text-gray-900 dark:text-white">
密钥名称
</label>
<input
type="text"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="例如:任务系统"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mb-4 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleCreateApiKey()
}
}}
/>
<div className="flex space-x-3">
<button
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 transition-colors"
>
取消
</button>
<button
onClick={handleCreateApiKey}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
创建
</button>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}