1293 lines
53 KiB
TypeScript
1293 lines
53 KiB
TypeScript
'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>
|
||
)
|
||
}
|