985 lines
47 KiB
TypeScript
985 lines
47 KiB
TypeScript
'use client'
|
||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { Card } from '@/components/ui'
|
||
import { Search, RefreshCw } from 'lucide-react'
|
||
import { useDirtyTracker } from '@shared/ui/hooks/useDirtyTracker'
|
||
|
||
interface MonitorStatus {
|
||
enabled: boolean
|
||
status: string
|
||
workerHealth: string
|
||
lastCheck: string | null
|
||
stats: { totalProcessed: number; todayProcessed: number; errors: number; lastError: string | null; lastErrorTime: string | null }
|
||
ongoingFaults: number
|
||
details: {
|
||
todayProcessed: { ticket_no?: string; error?: string; created_at: string }[]
|
||
ongoingFaults: { id: number; server_sn: string; server_ip: string | null; order_number: string | null; fault_type: string | null; fault_time: string; fault_detail: string | null }[]
|
||
recentErrors: { error?: string; details?: string; created_at: string }[]
|
||
}
|
||
}
|
||
|
||
interface MonitorConfig {
|
||
monitor: { enabled: boolean; interval_seconds: number; push_delay_ms: number; silent_start_hour: number; silent_end_hour: number }
|
||
mail: { address: string; imap_server: string; imap_port: number; pop3_server: string; pop3_port: number; smtp_server: string; smtp_port: number; password: string }
|
||
filter: { subject_keywords: string[]; sender_email: string; oem_repair_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string }; oem_diag_keywords: { base_keyword: string; type_keyword: string; exclude_keyword: string } }
|
||
}
|
||
|
||
interface ScanState {
|
||
status: 'idle' | 'running' | 'completed' | 'error' | 'cancelling'
|
||
startedAt?: string
|
||
completedAt?: string
|
||
timeRange?: { value: number | null; unit: string }
|
||
stats?: {
|
||
total: number
|
||
matched: number
|
||
imported: number
|
||
skipped: number
|
||
errors: number
|
||
}
|
||
details?: {
|
||
msg_id: string
|
||
subject: string
|
||
date: string
|
||
order_number: string | null
|
||
status: 'imported' | 'skipped' | 'error'
|
||
ticket_no?: string
|
||
error?: string
|
||
}[]
|
||
detailsTruncated?: boolean
|
||
error?: string
|
||
}
|
||
|
||
type SectionKey = 'mail' | 'filter' | 'monitor'
|
||
|
||
export default function MonitorSettingsPage() {
|
||
const [status, setStatus] = useState<MonitorStatus | null>(null)
|
||
const [config, setConfig] = useState<MonitorConfig | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const { isDirty, markClean } = useDirtyTracker()
|
||
const [saving, setSaving] = useState<SectionKey | null>(null)
|
||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
|
||
const [newKeyword, setNewKeyword] = useState('')
|
||
const [expandedPanel, setExpandedPanel] = useState<'today' | 'total' | 'errors' | 'ongoing' | null>(null)
|
||
const [sectionMessage, setSectionMessage] = useState<Partial<Record<SectionKey, { type: 'success' | 'error'; text: string }>>>({})
|
||
// 立即检查冷却状态
|
||
const [triggerCooldown, setTriggerCooldown] = useState(0)
|
||
const cooldownTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||
|
||
// 邮箱工单检测状态
|
||
const [scanState, setScanState] = useState<ScanState>({ status: 'idle' })
|
||
const [scanTimeValue, setScanTimeValue] = useState<number>(7)
|
||
const [scanTimeUnit, setScanTimeUnit] = useState<string>('day')
|
||
const scanTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||
const [importing, setImporting] = useState(false)
|
||
|
||
// 从 scanState.status 派生 scanning 状态
|
||
const scanning = scanState.status === 'running' || scanState.status === 'cancelling'
|
||
|
||
// 扫描历史状态
|
||
const [scanHistory, setScanHistory] = useState<{
|
||
items: Array<{
|
||
id: number
|
||
status: string
|
||
started_at: string
|
||
completed_at: string | null
|
||
time_range_value: number | null
|
||
time_range_unit: string | null
|
||
total_count: number
|
||
matched_count: number
|
||
imported_count: number
|
||
skipped_count: number
|
||
error_count: number
|
||
details_truncated: number
|
||
error_message: string | null
|
||
created_by: number | null
|
||
created_at: string
|
||
}>
|
||
pagination: { page: number; limit: number; total: number; totalPages: number }
|
||
} | null>(null)
|
||
const [historyPage, setHistoryPage] = useState(1)
|
||
const [loadingHistory, setLoadingHistory] = useState(false)
|
||
|
||
// 刷新扫描历史
|
||
const refreshScanHistory = useCallback(async (page = 1) => {
|
||
setLoadingHistory(true)
|
||
try {
|
||
const res = await fetch(`/api/monitor/scan-history?page=${page}&limit=10`)
|
||
const data = await res.json()
|
||
setScanHistory(data)
|
||
setHistoryPage(page)
|
||
} catch (err) {
|
||
console.error('[Scan] 历史加载失败:', err)
|
||
} finally {
|
||
setLoadingHistory(false)
|
||
}
|
||
}, [])
|
||
|
||
// 自动刷新定时器
|
||
const statusTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||
const errorTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||
|
||
// 页面可见性状态
|
||
const isVisibleRef = useRef(true)
|
||
|
||
// 刷新运行状态
|
||
const refreshStatus = useCallback(() => {
|
||
fetch('/api/monitor/status')
|
||
.then(r => r.json())
|
||
.then(setStatus)
|
||
.catch(err => console.error('[Monitor] 状态刷新失败:', err))
|
||
}, [])
|
||
|
||
// 刷新错误列表(更频繁)- 使用相同的 API,但只更新错误部分
|
||
const refreshErrors = useCallback(() => {
|
||
fetch('/api/monitor/status')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
// 安全地合并错误列表,避免路径依赖
|
||
setStatus(prev => {
|
||
if (!prev || !data) return prev
|
||
return {
|
||
...prev,
|
||
stats: data.stats || prev.stats,
|
||
details: {
|
||
...prev.details,
|
||
recentErrors: data.details?.recentErrors ?? prev.details?.recentErrors ?? [],
|
||
},
|
||
}
|
||
})
|
||
})
|
||
.catch(err => console.error('[Monitor] 错误列表刷新失败:', err))
|
||
}, [])
|
||
|
||
// 刷新扫描状态
|
||
const refreshScanState = useCallback(() => {
|
||
fetch('/api/monitor/scan-status')
|
||
.then(r => r.json())
|
||
.then(data => {
|
||
setScanState(data)
|
||
// 扫描结束时停止轮询(scanning 从 status 派生)
|
||
if (data.status !== 'running' && data.status !== 'cancelling') {
|
||
if (scanTimerRef.current) {
|
||
clearInterval(scanTimerRef.current)
|
||
scanTimerRef.current = null
|
||
}
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('[Scan] 状态刷新失败:', err)
|
||
// 网络异常时不停止轮询,等待下次重试
|
||
})
|
||
}, [])
|
||
|
||
// 页面可见性变化
|
||
useEffect(() => {
|
||
const handler = () => {
|
||
isVisibleRef.current = !document.hidden
|
||
if (isVisibleRef.current) {
|
||
// 页面恢复可见,立即刷新
|
||
refreshStatus()
|
||
refreshErrors()
|
||
if (scanning) refreshScanState()
|
||
}
|
||
}
|
||
document.addEventListener('visibilitychange', handler)
|
||
return () => document.removeEventListener('visibilitychange', handler)
|
||
}, [scanning, refreshStatus, refreshErrors, refreshScanState])
|
||
|
||
// 自动刷新定时器
|
||
useEffect(() => {
|
||
// 运行状态:60 秒刷新
|
||
statusTimerRef.current = setInterval(() => {
|
||
if (isVisibleRef.current) refreshStatus()
|
||
}, 60000)
|
||
|
||
// 错误列表:30 秒刷新
|
||
errorTimerRef.current = setInterval(() => {
|
||
if (isVisibleRef.current) refreshErrors()
|
||
}, 30000)
|
||
|
||
return () => {
|
||
if (statusTimerRef.current) clearInterval(statusTimerRef.current)
|
||
if (errorTimerRef.current) clearInterval(errorTimerRef.current)
|
||
}
|
||
}, [refreshStatus, refreshErrors])
|
||
|
||
// 初始加载
|
||
useEffect(() => {
|
||
Promise.all([
|
||
fetch('/api/monitor/status').then(r => r.json()),
|
||
fetch('/api/monitor/settings').then(r => r.json()),
|
||
fetch('/api/monitor/scan-status').then(r => r.json()),
|
||
fetch('/api/monitor/scan-history?page=1&limit=10').then(r => r.json()),
|
||
]).then(([s, c, scan, history]) => {
|
||
setStatus(s)
|
||
setConfig(c)
|
||
markClean('mail', { mail: c.mail })
|
||
markClean('filter', { filter: c.filter })
|
||
markClean('monitor', { monitor: c.monitor })
|
||
setScanState(scan)
|
||
setScanHistory(history)
|
||
setLoading(false)
|
||
}).catch(() => setLoading(false))
|
||
}, [])
|
||
|
||
// 立即检查处理
|
||
const handleTrigger = async () => {
|
||
if (triggerCooldown > 0) return
|
||
|
||
const res = await fetch('/api/monitor/trigger', { method: 'POST' })
|
||
const data = await res.json()
|
||
if (data.success) {
|
||
setMessage({ type: 'success', text: data.message })
|
||
setTimeout(refreshStatus, 2000)
|
||
} else {
|
||
setMessage({ type: 'error', text: data.error })
|
||
}
|
||
|
||
// 启动 30 秒冷却
|
||
setTriggerCooldown(30)
|
||
cooldownTimerRef.current = setInterval(() => {
|
||
setTriggerCooldown(prev => {
|
||
if (prev <= 1) {
|
||
if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current)
|
||
return 0
|
||
}
|
||
return prev - 1
|
||
})
|
||
}, 1000)
|
||
}
|
||
|
||
// 清理冷却定时器
|
||
useEffect(() => {
|
||
return () => {
|
||
if (cooldownTimerRef.current) clearInterval(cooldownTimerRef.current)
|
||
}
|
||
}, [])
|
||
|
||
// 启动扫描
|
||
const handleStartScan = async () => {
|
||
if (scanning) return
|
||
|
||
setScanState({ status: 'running' })
|
||
|
||
const res = await fetch('/api/monitor/scan-emails', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ timeRange: { value: scanTimeValue, unit: scanTimeUnit } }),
|
||
})
|
||
const data = await res.json()
|
||
|
||
if (!data.success) {
|
||
setMessage({ type: 'error', text: data.error })
|
||
setScanState({ status: 'error', error: data.error })
|
||
return
|
||
}
|
||
|
||
// 启动轮询
|
||
scanTimerRef.current = setInterval(refreshScanState, 3000)
|
||
}
|
||
|
||
// 取消扫描
|
||
const handleCancelScan = async () => {
|
||
const res = await fetch('/api/monitor/scan-status', { method: 'DELETE' })
|
||
const data = await res.json()
|
||
if (data.success) {
|
||
setMessage({ type: 'success', text: data.message })
|
||
setScanState(prev => ({ ...prev, status: 'cancelling' }))
|
||
} else {
|
||
setMessage({ type: 'error', text: data.error })
|
||
}
|
||
}
|
||
|
||
// 导入跳过的邮件为工单
|
||
const handleImportSkipped = async () => {
|
||
if (!scanState.details || scanState.status !== 'completed') return
|
||
|
||
const skippedEmails = scanState.details.filter(d =>
|
||
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
|
||
)
|
||
|
||
if (skippedEmails.length === 0) {
|
||
setMessage({ type: 'error', text: '没有可导入的邮件(已跳过的邮件需要有工单号)' })
|
||
return
|
||
}
|
||
|
||
if (!confirm(`确认导入 ${skippedEmails.length} 封邮件为工单?`)) return
|
||
|
||
setImporting(true)
|
||
try {
|
||
const res = await fetch('/api/monitor/scan-emails/import', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ emails: skippedEmails }),
|
||
})
|
||
const data = await res.json()
|
||
if (data.success) {
|
||
setMessage({ type: 'success', text: data.message })
|
||
// 刷新扫描状态
|
||
refreshScanState()
|
||
} else {
|
||
setMessage({ type: 'error', text: data.error })
|
||
}
|
||
} catch (err) {
|
||
setMessage({ type: 'error', text: '导入失败' })
|
||
} finally {
|
||
setImporting(false)
|
||
}
|
||
}
|
||
|
||
// 清理扫描定时器
|
||
useEffect(() => {
|
||
return () => {
|
||
if (scanTimerRef.current) clearInterval(scanTimerRef.current)
|
||
}
|
||
}, [])
|
||
|
||
// 其他原有函数保持不变...
|
||
const handleStart = async () => {
|
||
await fetch('/api/monitor/start', { method: 'POST' })
|
||
refreshStatus()
|
||
setMessage({ type: 'success', text: '监控已启用' })
|
||
}
|
||
|
||
const handleStop = async () => {
|
||
if (!confirm('确认停用邮件监控?')) return
|
||
await fetch('/api/monitor/stop', { method: 'POST' })
|
||
refreshStatus()
|
||
setMessage({ type: 'success', text: '监控已停用' })
|
||
}
|
||
|
||
const handleTestMail = async () => {
|
||
setSectionMessage(prev => ({ ...prev, mail: { type: 'success', text: '正在测试邮箱连接...' } }))
|
||
const res = await fetch('/api/monitor/test-mail', { method: 'POST' })
|
||
const data = await res.json()
|
||
setSectionMessage(prev => ({ ...prev, mail: { type: data.success ? 'success' : 'error', text: data.success ? data.message : data.error } }))
|
||
}
|
||
|
||
const handleSectionSave = async (section: SectionKey) => {
|
||
if (!config) return
|
||
setSaving(section)
|
||
setSectionMessage(prev => ({ ...prev, [section]: undefined }))
|
||
const sectionData = { [section]: config[section] }
|
||
const res = await fetch('/api/monitor/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sectionData) })
|
||
const data = await res.json()
|
||
setSaving(null)
|
||
if (data.success) {
|
||
markClean(section, { [section]: config[section] })
|
||
setSectionMessage(prev => ({ ...prev, [section]: { type: 'success', text: '已保存' } }))
|
||
} else {
|
||
setSectionMessage(prev => ({ ...prev, [section]: { type: 'error', text: data.error } }))
|
||
}
|
||
setTimeout(() => setSectionMessage(prev => ({ ...prev, [section]: undefined })), 3000)
|
||
}
|
||
|
||
const addKeyword = () => {
|
||
if (!config || !newKeyword.trim()) return
|
||
setConfig({ ...config, filter: { ...config.filter, subject_keywords: [...config.filter.subject_keywords, newKeyword.trim()] } })
|
||
setNewKeyword('')
|
||
}
|
||
|
||
const removeKeyword = (index: number) => {
|
||
if (!config) return
|
||
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
|
||
}
|
||
|
||
const inputClass = (path: string, changed = false) =>
|
||
`w-full px-3 py-2 border rounded-lg text-sm transition-colors ${changed ? 'border-amber-400 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-600' : 'border-slate-300 dark:border-slate-600'}`
|
||
|
||
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
||
if (!config) return null
|
||
const changed = isDirty(section, { [section]: config[section] })
|
||
if (!changed) return null
|
||
return (
|
||
<div className="mt-4 flex items-center gap-3">
|
||
<button
|
||
onClick={() => handleSectionSave(section)}
|
||
disabled={saving === section}
|
||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50 text-sm font-medium"
|
||
>
|
||
{saving === section ? '保存中...' : '保存设置'}
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (loading) return <div className="p-6">加载中...</div>
|
||
if (!config) return <div className="p-6">加载失败</div>
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">邮件监控设置</h1>
|
||
<p className="text-slate-500 dark:text-slate-400 mt-1">配置邮件监控、微信推送与提醒规则</p>
|
||
</div>
|
||
|
||
{message && (
|
||
<div className={`p-3 rounded-lg text-sm ${message.type === 'success' ? 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400' : 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-400'}`}>
|
||
{message.text}
|
||
<button onClick={() => setMessage(null)} className="float-right font-bold">×</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 运行状态 */}
|
||
<Card className="p-5">
|
||
<h2 className="text-lg font-semibold mb-4">运行状态</h2>
|
||
<div className="flex items-center gap-4 mb-4">
|
||
<span className={`inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium ${status?.workerHealth === 'healthy' ? 'bg-green-100 text-green-700 dark:bg-green-900/20 dark:text-green-400' : status?.workerHealth === 'error' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : status?.workerHealth === 'unresponsive' ? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}`}>
|
||
<span className={`w-2 h-2 rounded-full ${status?.workerHealth === 'healthy' ? 'bg-green-500' : status?.workerHealth === 'error' || status?.workerHealth === 'unresponsive' ? 'bg-red-500' : 'bg-slate-400'}`} />
|
||
{status?.workerHealth === 'healthy' ? '运行中' : status?.workerHealth === 'error' ? '运行异常' : status?.workerHealth === 'unresponsive' ? '无响应' : '已停止'}
|
||
</span>
|
||
{status?.lastCheck && <span className="text-sm text-slate-500">上次检查: {status.lastCheck}</span>}
|
||
</div>
|
||
|
||
{/* 统计指标 */}
|
||
<div className="grid grid-cols-4 gap-4 mb-4">
|
||
{[
|
||
{ key: 'today' as const, label: '今日处理', value: status?.stats.todayProcessed || 0, color: 'text-blue-600 dark:text-blue-400' },
|
||
{ key: 'total' as const, label: '累计', value: status?.stats.totalProcessed || 0, color: 'text-slate-700 dark:text-slate-300' },
|
||
{ key: 'errors' as const, label: '错误', value: status?.stats.errors || 0, color: 'text-red-600 dark:text-red-400' },
|
||
{ key: 'ongoing' as const, label: '进行中故障', value: status?.ongoingFaults || 0, color: 'text-amber-600 dark:text-amber-400' },
|
||
].map(item => (
|
||
<div key={item.key}>
|
||
<span className="text-slate-500 text-sm">{item.label}: </span>
|
||
<button
|
||
onClick={() => setExpandedPanel(expandedPanel === item.key ? null : item.key)}
|
||
className={`font-medium text-sm cursor-pointer hover:opacity-70 underline decoration-dotted underline-offset-2 transition-opacity ${item.color}`}
|
||
>
|
||
{item.value}
|
||
<span className="ml-1 text-xs">{expandedPanel === item.key ? '▲' : '▼'}</span>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* 展开面板 */}
|
||
{expandedPanel && (
|
||
<div className="mb-4 border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||
{expandedPanel === 'today' && status?.details?.todayProcessed && (
|
||
<div className="p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">今日处理记录</span>
|
||
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||
</div>
|
||
{status.details.todayProcessed.length === 0 ? (
|
||
<p className="text-sm text-slate-500">今日暂无处理记录</p>
|
||
) : (
|
||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||
{status.details.todayProcessed.map((item, i) => (
|
||
<div key={i} className="flex items-center justify-between text-sm py-1 border-b border-slate-100 dark:border-slate-800 last:border-0">
|
||
<span className="text-slate-700 dark:text-slate-300">
|
||
{item.ticket_no ? `工单 ${item.ticket_no}` : item.error || '处理完成'}
|
||
</span>
|
||
<span className="text-slate-400 text-xs">{item.created_at}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{expandedPanel === 'total' && (
|
||
<div className="p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">累计统计</span>
|
||
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||
</div>
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||
<span className="text-slate-500">总处理工单</span>
|
||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{status?.stats.totalProcessed || 0}</p>
|
||
</div>
|
||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||
<span className="text-slate-500">今日处理</span>
|
||
<p className="text-2xl font-bold text-blue-600">{status?.stats.todayProcessed || 0}</p>
|
||
</div>
|
||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||
<span className="text-slate-500">总错误数</span>
|
||
<p className="text-2xl font-bold text-red-600">{status?.stats.errors || 0}</p>
|
||
</div>
|
||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||
<span className="text-slate-500">进行中故障</span>
|
||
<p className="text-2xl font-bold text-amber-600">{status?.ongoingFaults || 0}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{expandedPanel === 'errors' && status?.details?.recentErrors && (
|
||
<div className="p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-sm font-medium text-red-700 dark:text-red-400">最近错误记录</span>
|
||
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||
</div>
|
||
{status.details.recentErrors.length === 0 ? (
|
||
<p className="text-sm text-slate-500">暂无错误记录</p>
|
||
) : (
|
||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||
{status.details.recentErrors.map((item, i) => (
|
||
<div key={i} className="p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-red-700 dark:text-red-400 font-medium">错误</span>
|
||
<span className="text-slate-400">{item.created_at}</span>
|
||
</div>
|
||
<pre className="text-red-600 dark:text-red-400 whitespace-pre-wrap break-all">
|
||
{(() => {
|
||
const errStr = item.error || item.details || JSON.stringify(item)
|
||
try { return JSON.stringify(JSON.parse(errStr), null, 2) }
|
||
catch { return errStr }
|
||
})()}
|
||
</pre>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{expandedPanel === 'ongoing' && status?.details?.ongoingFaults && (
|
||
<div className="p-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<span className="text-sm font-medium text-amber-700 dark:text-amber-400">进行中故障列表</span>
|
||
<button onClick={() => setExpandedPanel(null)} className="text-xs text-slate-500 hover:text-slate-700">收起</button>
|
||
</div>
|
||
{status.details.ongoingFaults.length === 0 ? (
|
||
<p className="text-sm text-slate-500">暂无进行中的故障</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-slate-200 dark:border-slate-700 text-left">
|
||
<th className="py-2 pr-4 text-slate-500 font-medium">工单号</th>
|
||
<th className="py-2 pr-4 text-slate-500 font-medium">服务器IP</th>
|
||
<th className="py-2 pr-4 text-slate-500 font-medium">SN</th>
|
||
<th className="py-2 pr-4 text-slate-500 font-medium">类型</th>
|
||
<th className="py-2 pr-4 text-slate-500 font-medium">故障时间</th>
|
||
<th className="py-2 text-slate-500 font-medium">故障信息</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{status.details.ongoingFaults.map((fault, i) => (
|
||
<tr key={i} className="border-b border-slate-100 dark:border-slate-800 last:border-0">
|
||
<td className="py-2 pr-4 font-mono text-xs">{fault.order_number || '-'}</td>
|
||
<td className="py-2 pr-4">{fault.server_ip || '未知'}</td>
|
||
<td className="py-2 pr-4 font-mono text-xs">{fault.server_sn}</td>
|
||
<td className="py-2 pr-4">
|
||
<span className={`px-2 py-0.5 rounded text-xs ${fault.fault_type === 'oem_diag' ? 'bg-blue-100 text-blue-700' : fault.fault_type === 'oem_repair' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600'}`}>
|
||
{fault.fault_type === 'oem_diag' ? '诊断' : fault.fault_type === 'oem_repair' ? '维修' : '未知'}
|
||
</span>
|
||
</td>
|
||
<td className="py-2 pr-4 text-xs">{fault.fault_time}</td>
|
||
<td className="py-2 text-xs max-w-xs truncate" title={fault.fault_detail || ''}>{fault.fault_detail || '-'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-2">
|
||
{status?.enabled ? <button onClick={handleStop} className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm">停止监控</button> : <button onClick={handleStart} className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm">启动监控</button>}
|
||
<button
|
||
onClick={handleTrigger}
|
||
disabled={triggerCooldown > 0}
|
||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{triggerCooldown > 0 ? `请等待 ${triggerCooldown} 秒` : '立即检查'}
|
||
</button>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* 邮箱配置 */}
|
||
<Card className="p-5">
|
||
<h2 className="text-lg font-semibold mb-4">邮箱配置</h2>
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">邮箱地址</label>
|
||
<input type="text" value={config.mail.address} onChange={e => setConfig({ ...config, mail: { ...config.mail, address: e.target.value } })} className={inputClass('mail.address')} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">邮箱密码</label>
|
||
<input type="password" value={config.mail.password} onChange={e => setConfig({ ...config, mail: { ...config.mail, password: e.target.value } })} className={inputClass('mail.password')} placeholder="••••••••" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">IMAP 服务器</label>
|
||
<input type="text" value={config.mail.imap_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_server: e.target.value } })} className={inputClass('mail.imap_server')} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">IMAP 端口</label>
|
||
<input type="number" value={config.mail.imap_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, imap_port: parseInt(e.target.value) || 993 } })} className={inputClass('mail.imap_port')} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">POP3 备用服务器</label>
|
||
<input type="text" value={config.mail.pop3_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, pop3_server: e.target.value } })} className={inputClass('mail.pop3_server')} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">POP3 备用端口</label>
|
||
<input type="number" value={config.mail.pop3_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, pop3_port: parseInt(e.target.value) || 995 } })} className={inputClass('mail.pop3_port')} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">SMTP 服务器</label>
|
||
<input type="text" value={config.mail.smtp_server} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_server: e.target.value } })} className={inputClass('mail.smtp_server')} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">SMTP 端口</label>
|
||
<input type="number" value={config.mail.smtp_port} onChange={e => setConfig({ ...config, mail: { ...config.mail, smtp_port: parseInt(e.target.value) || 465 } })} className={inputClass('mail.smtp_port')} />
|
||
</div>
|
||
</div>
|
||
<div className="mt-4 flex items-center gap-3">
|
||
<button onClick={handleTestMail} className="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm">测试邮箱连接</button>
|
||
{sectionMessage.mail && (
|
||
<span className={`text-sm ${sectionMessage.mail.type === 'success' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
||
{sectionMessage.mail.text}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<SectionSaveButton section="mail" />
|
||
</Card>
|
||
|
||
{/* 邮件过滤 */}
|
||
<Card className="p-5">
|
||
<h2 className="text-lg font-semibold mb-4">邮件过滤</h2>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">主题关键词(匹配任一即通过)</label>
|
||
<div className="flex flex-wrap gap-2 mb-2">
|
||
{config.filter.subject_keywords.map((kw, i) => (
|
||
<span key={i} className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
|
||
{kw}
|
||
<button onClick={() => removeKeyword(i)} className="hover:opacity-70">×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input type="text" value={newKeyword} onChange={e => setNewKeyword(e.target.value)} onKeyDown={e => e.key === 'Enter' && addKeyword()} className="flex-1 px-3 py-2 border rounded-lg text-sm" placeholder="输入关键词后回车" />
|
||
<button onClick={addKeyword} className="px-3 py-2 bg-blue-500 text-white rounded-lg text-sm">添加</button>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">发件人邮箱</label>
|
||
<input type="text" value={config.filter.sender_email} onChange={e => setConfig({ ...config, filter: { ...config.filter, sender_email: e.target.value } })} className={inputClass('filter.sender_email')} />
|
||
</div>
|
||
</div>
|
||
<SectionSaveButton section="filter" />
|
||
</Card>
|
||
|
||
{/* 邮箱工单检测(新模块) */}
|
||
<Card className="p-5">
|
||
<h2 className="text-lg font-semibold mb-4">邮箱工单检测</h2>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">扫描邮箱中的工单邮件,自动导入未存在的工单</p>
|
||
|
||
{/* 时间范围选择 */}
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<span className="text-sm text-slate-600 dark:text-slate-400">时间范围:</span>
|
||
<input
|
||
type="number"
|
||
value={scanTimeValue}
|
||
onChange={e => setScanTimeValue(parseInt(e.target.value) || 1)}
|
||
className="w-20 px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||
min={1}
|
||
max={365}
|
||
disabled={scanning}
|
||
/>
|
||
<select
|
||
value={scanTimeUnit}
|
||
onChange={e => setScanTimeUnit(e.target.value)}
|
||
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
|
||
disabled={scanning}
|
||
>
|
||
<option value="minute">分钟</option>
|
||
<option value="hour">小时</option>
|
||
<option value="day">天</option>
|
||
<option value="week">周</option>
|
||
<option value="month">月</option>
|
||
<option value="all">全部</option>
|
||
</select>
|
||
<button
|
||
onClick={handleStartScan}
|
||
disabled={scanning}
|
||
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center gap-2"
|
||
>
|
||
{scanning ? (
|
||
<>
|
||
<RefreshCw size={14} className="animate-spin" />
|
||
扫描中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Search size={14} />
|
||
开始扫描
|
||
</>
|
||
)}
|
||
</button>
|
||
{scanning && (
|
||
<button
|
||
onClick={handleCancelScan}
|
||
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 text-sm inline-flex items-center gap-2"
|
||
>
|
||
取消扫描
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* 扫描进度 */}
|
||
{scanState.status === 'running' && (
|
||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||
<div className="flex items-center gap-2 text-sm text-blue-700 dark:text-blue-400">
|
||
<RefreshCw size={14} className="animate-spin" />
|
||
扫描进行中...
|
||
</div>
|
||
{scanState.stats && (
|
||
<div className="mt-2 text-xs text-slate-600 dark:text-slate-400">
|
||
已扫描 {scanState.stats.total} 封,导入 {scanState.stats.imported} 封,跳过 {scanState.stats.skipped} 封
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 扫描取消中 */}
|
||
{scanState.status === 'cancelling' && (
|
||
<div className="mb-4 p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg">
|
||
<div className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-400">
|
||
<RefreshCw size={14} className="animate-spin" />
|
||
正在取消扫描...
|
||
</div>
|
||
{scanState.stats && (
|
||
<div className="mt-2 text-xs text-slate-600 dark:text-slate-400">
|
||
已扫描 {scanState.stats.total} 封,已处理 {scanState.stats.imported + scanState.stats.skipped} 封
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 扫描结果 */}
|
||
{(scanState.status === 'completed' || scanState.status === 'cancelling') && scanState.stats && (
|
||
<div className="space-y-4">
|
||
{scanState.error && (
|
||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
|
||
{scanState.error}
|
||
</div>
|
||
)}
|
||
{scanState.detailsTruncated && (
|
||
<div className="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-lg text-sm text-amber-700 dark:text-amber-400">
|
||
详情列表已截断(最多显示 500 条),完整统计请查看服务器日志
|
||
</div>
|
||
)}
|
||
<div className="grid grid-cols-5 gap-4 text-sm">
|
||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||
<span className="text-slate-500">已扫描</span>
|
||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{scanState.stats.total}</p>
|
||
</div>
|
||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||
<span className="text-slate-500">匹配工单号</span>
|
||
<p className="text-xl font-bold text-blue-600">{scanState.stats.matched}</p>
|
||
</div>
|
||
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||
<span className="text-green-600">已导入</span>
|
||
<p className="text-xl font-bold text-green-600">{scanState.stats.imported}</p>
|
||
</div>
|
||
<div className="p-3 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||
<span className="text-slate-500">已跳过</span>
|
||
<p className="text-xl font-bold text-slate-600">{scanState.stats.skipped}</p>
|
||
</div>
|
||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||
<span className="text-red-600">错误</span>
|
||
<p className="text-xl font-bold text-red-600">{scanState.stats.errors}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 详细结果列表 */}
|
||
{scanState.details && scanState.details.length > 0 && (
|
||
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||
<div className="max-h-64 overflow-y-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
|
||
<tr>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">工单号</th>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">主题</th>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">状态</th>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">说明</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{scanState.details.map((item, i) => (
|
||
<tr key={i} className="border-t border-slate-100 dark:border-slate-800">
|
||
<td className="py-2 px-4 font-mono text-xs">{item.order_number || '-'}</td>
|
||
<td className="py-2 px-4 text-xs max-w-xs truncate" title={item.subject}>{item.subject || '-'}</td>
|
||
<td className="py-2 px-4">
|
||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||
item.status === 'imported' ? 'bg-green-100 text-green-700' :
|
||
item.status === 'skipped' ? 'bg-slate-100 text-slate-600' :
|
||
'bg-red-100 text-red-700'
|
||
}`}>
|
||
{item.status === 'imported' ? '已导入' : item.status === 'skipped' ? '已跳过' : '错误'}
|
||
</span>
|
||
</td>
|
||
<td className="py-2 px-4 text-xs text-slate-500">{item.error || item.ticket_no || '-'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 导入按钮 */}
|
||
{scanState.status === 'completed' && scanState.details && scanState.details.some(d =>
|
||
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
|
||
) && (
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
onClick={handleImportSkipped}
|
||
disabled={importing}
|
||
className="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 text-sm disabled:opacity-50 inline-flex items-center gap-2"
|
||
>
|
||
{importing ? (
|
||
<>
|
||
<RefreshCw size={14} className="animate-spin" />
|
||
导入中...
|
||
</>
|
||
) : (
|
||
'导入跳过的邮件为工单'
|
||
)}
|
||
</button>
|
||
<span className="text-sm text-slate-500">
|
||
可导入 {scanState.details.filter(d =>
|
||
d.status === 'skipped' && d.order_number && d.error !== '工单已存在' && d.error !== '邮件已处理过'
|
||
).length} 封邮件
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 扫描错误 */}
|
||
{scanState.status === 'error' && (
|
||
<div className="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg text-sm text-red-700 dark:text-red-400">
|
||
扫描失败: {scanState.error || '未知错误'}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
|
||
{/* 全局运行参数 */}
|
||
<Card className="p-5">
|
||
<h2 className="text-lg font-semibold mb-4">全局运行参数</h2>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">未在推送组中单独配置的参数将使用以下全局默认值</p>
|
||
<div className="grid grid-cols-3 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">检查间隔(秒)</label>
|
||
<input type="number" value={config.monitor.interval_seconds} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, interval_seconds: parseInt(e.target.value) || 60 } })} className={inputClass('monitor.interval_seconds')} min={10} max={3600} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">静默期开始(时)</label>
|
||
<input type="number" value={config.monitor.silent_start_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_start_hour: parseInt(e.target.value) || 0 } })} className={inputClass('monitor.silent_start_hour')} min={0} max={23} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">静默期结束(时)</label>
|
||
<input type="number" value={config.monitor.silent_end_hour} onChange={e => setConfig({ ...config, monitor: { ...config.monitor, silent_end_hour: parseInt(e.target.value) || 7 } })} className={inputClass('monitor.silent_end_hour')} min={0} max={23} />
|
||
</div>
|
||
</div>
|
||
<SectionSaveButton section="monitor" />
|
||
</Card>
|
||
|
||
{/* 扫描历史 */}
|
||
<Card className="p-5">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<div>
|
||
<h2 className="text-lg font-semibold">扫描历史</h2>
|
||
<p className="text-sm text-slate-500 dark:text-slate-400 mt-1">查看历史扫描记录</p>
|
||
</div>
|
||
<button
|
||
onClick={() => refreshScanHistory(1)}
|
||
disabled={loadingHistory}
|
||
className="px-3 py-1.5 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-sm inline-flex items-center gap-1.5"
|
||
>
|
||
<RefreshCw size={14} className={loadingHistory ? 'animate-spin' : ''} />
|
||
刷新
|
||
</button>
|
||
</div>
|
||
|
||
{scanHistory && scanHistory.items.length > 0 ? (
|
||
<div className="space-y-4">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-slate-50 dark:bg-slate-800">
|
||
<tr>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">时间</th>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">范围</th>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">状态</th>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">扫描</th>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">导入</th>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">跳过</th>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">错误</th>
|
||
<th className="py-2 px-4 text-left text-slate-500 font-medium">操作人</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{scanHistory.items.map((item) => (
|
||
<tr key={item.id} className="border-t border-slate-100 dark:border-slate-800">
|
||
<td className="py-2 px-4 text-xs">{item.started_at}</td>
|
||
<td className="py-2 px-4 text-xs">
|
||
{item.time_range_unit === 'all' ? '全部' : `${item.time_range_value || ''}${item.time_range_unit === 'minute' ? '分钟' : item.time_range_unit === 'hour' ? '小时' : item.time_range_unit === 'day' ? '天' : item.time_range_unit === 'week' ? '周' : '月'}`}
|
||
</td>
|
||
<td className="py-2 px-4">
|
||
<span className={`px-2 py-0.5 rounded text-xs ${
|
||
item.status === 'completed' ? 'bg-green-100 text-green-700' :
|
||
item.status === 'error' ? 'bg-red-100 text-red-700' :
|
||
item.status === 'cancelling' ? 'bg-amber-100 text-amber-700' :
|
||
'bg-slate-100 text-slate-600'
|
||
}`}>
|
||
{item.status === 'completed' ? '完成' : item.status === 'error' ? '错误' : item.status === 'cancelling' ? '已取消' : item.status}
|
||
</span>
|
||
</td>
|
||
<td className="py-2 px-4 text-xs font-medium">{item.total_count}</td>
|
||
<td className="py-2 px-4 text-xs text-green-600">{item.imported_count}</td>
|
||
<td className="py-2 px-4 text-xs text-slate-500">{item.skipped_count}</td>
|
||
<td className="py-2 px-4 text-xs text-red-600">{item.error_count}</td>
|
||
<td className="py-2 px-4 text-xs text-slate-500">{item.created_by || '-'}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* 分页 */}
|
||
{scanHistory.pagination.totalPages > 1 && (
|
||
<div className="flex items-center justify-between text-sm">
|
||
<span className="text-slate-500">
|
||
共 {scanHistory.pagination.total} 条记录
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => refreshScanHistory(historyPage - 1)}
|
||
disabled={historyPage <= 1 || loadingHistory}
|
||
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50"
|
||
>
|
||
上一页
|
||
</button>
|
||
<span className="text-slate-500">
|
||
{historyPage} / {scanHistory.pagination.totalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => refreshScanHistory(historyPage + 1)}
|
||
disabled={historyPage >= scanHistory.pagination.totalPages || loadingHistory}
|
||
className="px-3 py-1 border border-slate-300 dark:border-slate-600 rounded disabled:opacity-50"
|
||
>
|
||
下一页
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<p className="text-sm text-slate-500 text-center py-4">
|
||
{loadingHistory ? '加载中...' : '暂无扫描历史'}
|
||
</p>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|