1504 lines
65 KiB
TypeScript
1504 lines
65 KiB
TypeScript
'use client'
|
||
|
||
import Select from '@/components/ui/Select'
|
||
|
||
import { useCallback, useEffect, useState } from 'react'
|
||
import { parseFetchError } from '@/lib/error-handler'
|
||
import { BellRing, Copy, Eye, EyeOff, KeyRound, ShieldCheck, Webhook, Cpu } from 'lucide-react'
|
||
import NotificationCenterPanel from '@/components/admin/NotificationCenterPanel'
|
||
import AiModelManagementPanel from '@/components/admin/AiModelManagementPanel'
|
||
import { useConfirmActionDialog } from '@/components/ConfirmActionDialog'
|
||
|
||
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
|
||
canReveal: boolean
|
||
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>
|
||
authConfigured?: boolean
|
||
authMasked?: string
|
||
authSummary?: 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 getNotificationTargetAuthSummary(target: ExternalNotificationTarget) {
|
||
if (target.authSummary || target.authMasked) {
|
||
return target.authSummary || target.authMasked || '已安全配置'
|
||
}
|
||
|
||
if (target.authType === 'bearer') {
|
||
return target.authConfigured === false ? 'Bearer · 未配置' : 'Bearer · 已安全配置'
|
||
}
|
||
|
||
if (target.authType === 'header') {
|
||
const headerName = target.authConfig.headerName || '自定义请求头'
|
||
return `${headerName} · ${target.authConfigured === false ? '未配置' : '已安全配置'}`
|
||
}
|
||
|
||
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
|
||
onStateChange?: (state: { dirty: boolean; busy: boolean }) => void
|
||
}
|
||
|
||
type SuperAdminWorkspace = 'access' | 'notificationCenter' | 'outbound' | 'aiModels'
|
||
|
||
const SUPER_ADMIN_WORKSPACES: Array<{
|
||
id: SuperAdminWorkspace
|
||
label: string
|
||
description: string
|
||
icon: typeof ShieldCheck
|
||
}> = [
|
||
{ id: 'access', label: '权限与凭证', description: '超级管理员与素材平台 API Key', icon: ShieldCheck },
|
||
{ id: 'notificationCenter', label: '统一通知中心', description: '项目、花名、测试与员工单聊', icon: BellRing },
|
||
{ id: 'outbound', label: '素材外发', description: 'Webhook 目标与自动上架投递', icon: Webhook },
|
||
{ id: 'aiModels', label: 'AI 模型接入', description: '上游连接、模型用途与默认模型', icon: Cpu },
|
||
]
|
||
|
||
export default function SuperAdminsTab({ showToast, onStateChange }: SuperAdminsTabProps) {
|
||
const [activeWorkspace, setActiveWorkspace] = useState<SuperAdminWorkspace>('notificationCenter')
|
||
const [aiModelsState, setAiModelsState] = useState({ dirty: false, busy: false })
|
||
const activeEditState = activeWorkspace === 'aiModels' ? aiModelsState : null
|
||
useEffect(() => { onStateChange?.({ dirty: activeEditState?.dirty || false, busy: activeEditState?.busy || false }) }, [activeEditState?.dirty, activeEditState?.busy, onStateChange])
|
||
useEffect(() => () => { onStateChange?.({ dirty: false, busy: false }) }, [onStateChange])
|
||
const { confirmAction, confirmActionDialog } = useConfirmActionDialog()
|
||
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 [revealedApiKeys, setRevealedApiKeys] = useState<Record<string, string>>({})
|
||
const [revealingApiKeyId, setRevealingApiKeyId] = 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 changeWorkspace = async (next: SuperAdminWorkspace) => {
|
||
const editState = activeWorkspace === 'aiModels' ? aiModelsState : null
|
||
if (next === activeWorkspace || editState?.busy) return
|
||
if (editState?.dirty) {
|
||
const result = await confirmAction({ title: '离开模型接入?', description: '当前修改尚未保存,离开后将丢失。', confirmLabel: '放弃修改并离开', cancelLabel: '继续编辑', tone: 'danger' })
|
||
if (!result.confirmed) return
|
||
}
|
||
setActiveWorkspace(next)
|
||
}
|
||
|
||
// 加载超级管理员列表
|
||
const loadSuperAdmins = useCallback(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)
|
||
}
|
||
}, [showToast])
|
||
|
||
// 添加超级管理员
|
||
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 = useCallback(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)
|
||
}
|
||
}, [showToast])
|
||
|
||
const resetNotificationTargetForm = () => {
|
||
setEditingNotificationTargetId(null)
|
||
setNotificationTargetForm(EMPTY_NOTIFICATION_TARGET_FORM)
|
||
}
|
||
|
||
const fetchNotificationTargets = useCallback(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)
|
||
}
|
||
}, [showToast])
|
||
|
||
const fetchNotificationDeliveries = useCallback(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)
|
||
}
|
||
}, [showToast])
|
||
|
||
const handleSaveNotificationTarget = async () => {
|
||
if (!notificationTargetForm.name.trim()) {
|
||
showToast('请填写目标系统名称', 'error')
|
||
return
|
||
}
|
||
|
||
if (!notificationTargetForm.callbackUrl.trim()) {
|
||
showToast('请填写回调地址', 'error')
|
||
return
|
||
}
|
||
|
||
if (
|
||
notificationTargetForm.authType === 'bearer' &&
|
||
!notificationTargetForm.token.trim() &&
|
||
!editingNotificationTargetId
|
||
) {
|
||
showToast('请选择 Bearer 鉴权时必须填写 token', 'error')
|
||
return
|
||
}
|
||
|
||
if (
|
||
notificationTargetForm.authType === 'header' &&
|
||
(!notificationTargetForm.headerName.trim() ||
|
||
(!notificationTargetForm.headerValue.trim() && !editingNotificationTargetId))
|
||
) {
|
||
showToast('自定义 Header 鉴权需要填写请求头名称和值', 'error')
|
||
return
|
||
}
|
||
|
||
if (notificationTargetForm.eventTypes.length === 0) {
|
||
showToast('请至少选择 1 个通知事件', 'error')
|
||
return
|
||
}
|
||
|
||
setSavingNotificationTarget(true)
|
||
try {
|
||
const authConfig =
|
||
notificationTargetForm.authType === 'bearer'
|
||
? notificationTargetForm.token.trim()
|
||
? { token: notificationTargetForm.token.trim() }
|
||
: undefined
|
||
: notificationTargetForm.authType === 'header'
|
||
? notificationTargetForm.headerValue.trim()
|
||
? {
|
||
headerName: notificationTargetForm.headerName.trim(),
|
||
headerValue: notificationTargetForm.headerValue.trim(),
|
||
}
|
||
: undefined
|
||
: {}
|
||
|
||
const payload = {
|
||
name: notificationTargetForm.name.trim(),
|
||
callbackUrl: notificationTargetForm.callbackUrl.trim(),
|
||
isActive: notificationTargetForm.isActive,
|
||
authType: notificationTargetForm.authType,
|
||
...(authConfig === undefined ? {} : { 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: '',
|
||
headerName: target.authConfig.headerName || '',
|
||
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')
|
||
}
|
||
}
|
||
|
||
const handleRevealApiKey = async (apiKey: ApiKey) => {
|
||
if (revealedApiKeys[apiKey.id]) {
|
||
setRevealedApiKeys((current) => {
|
||
const next = { ...current }
|
||
delete next[apiKey.id]
|
||
return next
|
||
})
|
||
return
|
||
}
|
||
|
||
if (!apiKey.canReveal) {
|
||
showToast('该历史 API Key 只保存了哈希,请创建新 Key 后逐步替换', 'error')
|
||
return
|
||
}
|
||
|
||
try {
|
||
setRevealingApiKeyId(apiKey.id)
|
||
const response = await fetch(`/api/admin/api-keys/${apiKey.id}/reveal`, {
|
||
method: 'POST',
|
||
cache: 'no-store',
|
||
})
|
||
if (!response.ok) {
|
||
const errorData = await parseFetchError(response)
|
||
showToast(errorData.message, 'error')
|
||
return
|
||
}
|
||
|
||
const data = await response.json()
|
||
setRevealedApiKeys((current) => ({ ...current, [apiKey.id]: data.data.key }))
|
||
} catch (error) {
|
||
console.error('查看 API Key 失败:', error)
|
||
const errorMessage = error instanceof Error ? error.message : '查看失败'
|
||
showToast(errorMessage, 'error')
|
||
} finally {
|
||
setRevealingApiKeyId(null)
|
||
}
|
||
}
|
||
|
||
const handleCopyApiKey = async (apiKey: string) => {
|
||
try {
|
||
await navigator.clipboard.writeText(apiKey)
|
||
showToast('API Key 已复制', 'success')
|
||
} catch (error) {
|
||
console.error('复制 API Key 失败:', error)
|
||
showToast('复制失败,请手动选择密钥文本', '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')
|
||
setRevealedApiKeys((current) => {
|
||
const next = { ...current }
|
||
delete next[id]
|
||
return next
|
||
})
|
||
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')
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
void loadSuperAdmins()
|
||
void fetchApiKeys()
|
||
void fetchNotificationTargets()
|
||
}, [fetchApiKeys, fetchNotificationTargets, loadSuperAdmins])
|
||
|
||
useEffect(() => {
|
||
if (activeWorkspace !== 'access') {
|
||
setRevealedApiKeys({})
|
||
setCreatedKey(null)
|
||
setShowCreateModal(false)
|
||
}
|
||
}, [activeWorkspace])
|
||
|
||
useEffect(() => {
|
||
void fetchNotificationDeliveries(notificationDeliveryStatusFilter)
|
||
}, [fetchNotificationDeliveries, notificationDeliveryStatusFilter])
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="overflow-hidden rounded-[12px] border border-[#e2e8f0] bg-white" aria-label="正在加载超级管理员工作台">
|
||
<div className="border-b border-[#e2e8f0] px-6 py-5">
|
||
<div className="h-6 w-44 animate-pulse rounded-[6px] bg-[#e8edf3]" />
|
||
<div className="mt-3 h-4 w-full max-w-xl animate-pulse rounded-[6px] bg-[#eef2f6]" />
|
||
</div>
|
||
<div className="grid gap-4 p-6 lg:grid-cols-[220px_minmax(0,1fr)]">
|
||
<div className="h-44 animate-pulse rounded-[8px] bg-[#eef2f6]" />
|
||
<div className="space-y-3">
|
||
<div className="h-20 animate-pulse rounded-[8px] bg-[#eef2f6]" />
|
||
<div className="h-20 animate-pulse rounded-[8px] bg-[#eef2f6]" />
|
||
<div className="h-20 animate-pulse rounded-[8px] bg-[#eef2f6]" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="overflow-hidden rounded-[12px] border border-[#e2e8f0] bg-white">
|
||
{confirmActionDialog}
|
||
<div className="border-b border-[#e2e8f0] px-5 py-5 sm:px-6">
|
||
<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]">
|
||
集中管理高权限账号、平台凭证、统一钉钉通知和素材外发。各分区权限均绑定当前项目的超级管理员身份。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid min-w-0 lg:grid-cols-[232px_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">
|
||
{SUPER_ADMIN_WORKSPACES.map((workspace) => {
|
||
const Icon = workspace.icon
|
||
const active = activeWorkspace === workspace.id
|
||
return (
|
||
<button
|
||
key={workspace.id}
|
||
type="button"
|
||
aria-current={active ? 'page' : undefined}
|
||
disabled={(activeWorkspace === 'aiModels' && aiModelsState.busy)}
|
||
onClick={() => void changeWorkspace(workspace.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" />
|
||
{workspace.label}
|
||
</span>
|
||
<span className={`mt-1 hidden text-[12px] leading-5 lg:block ${active ? 'text-[#315f91]' : 'text-[#66758b]'}`}>
|
||
{workspace.description}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</nav>
|
||
|
||
<div className="min-w-0">
|
||
{activeWorkspace === 'aiModels' && <AiModelManagementPanel onStateChange={setAiModelsState} />}
|
||
{activeWorkspace === 'access' && (
|
||
<div className="space-y-6 p-4 sm:p-6">
|
||
<div className="border-b border-[#e2e8f0] pb-4">
|
||
<div className="flex items-center gap-2">
|
||
<KeyRound className="h-4 w-4 text-[#1677ff]" aria-hidden="true" />
|
||
<h3 className="text-[16px] font-semibold text-[#101828]">权限与凭证</h3>
|
||
</div>
|
||
<p className="mt-1 text-[13px] text-[#66758b]">
|
||
当前 {count} / 4 个超级管理员{!canAdd ? ',已达到上限' : ''}。本区 API Key 仅属于素材平台外部接口。
|
||
</p>
|
||
</div>
|
||
{/* 添加超级管理员 */}
|
||
{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 text-sm text-gray-500 dark:text-gray-400">
|
||
<div className="flex min-w-56 items-center gap-2">
|
||
<code className="break-all font-mono text-xs text-gray-700 dark:text-gray-300">
|
||
{revealedApiKeys[key.id] || key.key}
|
||
</code>
|
||
{revealedApiKeys[key.id] && (
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCopyApiKey(revealedApiKeys[key.id])}
|
||
className="shrink-0 rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 dark:hover:bg-gray-800 dark:hover:text-white"
|
||
aria-label={`复制 ${key.name} 的 API Key`}
|
||
title="复制完整 Key"
|
||
>
|
||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{!key.canReveal && (
|
||
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300">
|
||
历史 Key 无法恢复,需创建新 Key 替换
|
||
</p>
|
||
)}
|
||
</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
|
||
type="button"
|
||
onClick={() => handleRevealApiKey(key)}
|
||
disabled={!key.canReveal || revealingApiKeyId === key.id}
|
||
className="inline-flex items-center gap-1 text-gray-600 transition-colors hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-300 dark:hover:text-white"
|
||
aria-label={`${revealedApiKeys[key.id] ? '隐藏' : '查看'} ${key.name} 的完整 API Key`}
|
||
title={key.canReveal ? (revealedApiKeys[key.id] ? '隐藏完整 Key' : '查看完整 Key') : '历史 Key 无法恢复'}
|
||
>
|
||
{revealedApiKeys[key.id] ? (
|
||
<EyeOff className="h-4 w-4" aria-hidden="true" />
|
||
) : (
|
||
<Eye className="h-4 w-4" aria-hidden="true" />
|
||
)}
|
||
{revealingApiKeyId === key.id ? '读取中' : revealedApiKeys[key.id] ? '隐藏' : '查看'}
|
||
</button>
|
||
<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>
|
||
)}
|
||
|
||
{activeWorkspace === 'notificationCenter' && (
|
||
<NotificationCenterPanel showToast={showToast} />
|
||
)}
|
||
|
||
{activeWorkspace === 'outbound' && (
|
||
<div className="space-y-8 p-4 sm:p-6">
|
||
<div>
|
||
<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">
|
||
配置素材库向其他业务系统发送 Webhook 的目标,与统一通知中心向员工发送钉钉单聊不同。
|
||
</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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 创建 API Key 模态框 */}
|
||
{activeWorkspace === 'access' && 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>
|
||
<button
|
||
type="button"
|
||
onClick={() => handleCopyApiKey(createdKey)}
|
||
className="mb-4 inline-flex items-center gap-2 text-sm text-blue-600 transition-colors hover:text-blue-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 dark:text-blue-400 dark:hover:text-blue-300"
|
||
>
|
||
<Copy className="h-4 w-4" aria-hidden="true" />
|
||
复制 API Key
|
||
</button>
|
||
<p className="text-sm text-gray-600 dark:text-gray-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>
|
||
)
|
||
}
|