issue-ai/src/app/(app)/settings/monitor/page.tsx

1182 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
import { Card } from '@/components/ui'
import { Plus, Trash2, ChevronDown, ChevronUp, Search, RefreshCw } from 'lucide-react'
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 WebhookConfig {
id: string
name: string
url: string
enabled: boolean
params: {
interval_seconds: number
push_delay_ms: number
silent_start_hour: number
silent_end_hour: number
}
}
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 } }
wechat: { webhooks: WebhookConfig[] }
}
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' | 'wechat' | 'monitor'
function generateId() {
return 'wh_' + Math.random().toString(36).slice(2, 10)
}
export default function MonitorSettingsPage() {
const [status, setStatus] = useState<MonitorStatus | null>(null)
const [config, setConfig] = useState<MonitorConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<MonitorConfig | null>(null)
const [loading, setLoading] = useState(true)
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 [expandedWebhooks, setExpandedWebhooks] = useState<Set<string>>(new Set())
const [testingWebhook, setTestingWebhook] = useState<string | null>(null)
// 立即检查冷却状态
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)
setOriginalConfig(JSON.parse(JSON.stringify(c)))
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 handleTestWebhook = async (webhookId: string) => {
if (!config) return
const wh = config.wechat.webhooks.find(w => w.id === webhookId)
if (!wh || !wh.url) return
setTestingWebhook(webhookId)
const res = await fetch('/api/monitor/test-wechat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ webhook_url: wh.url }),
})
const data = await res.json()
setTestingWebhook(null)
setSectionMessage(prev => ({
...prev,
wechat: { type: data.success ? 'success' : 'error', text: data.success ? `${wh.name}」测试成功` : `${wh.name}${data.error}` },
}))
setTimeout(() => setSectionMessage(prev => ({ ...prev, wechat: undefined })), 5000)
}
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) {
setOriginalConfig(prev => prev ? { ...prev, [section]: JSON.parse(JSON.stringify(config[section])) } : prev)
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 addWebhook = () => {
if (!config) return
const newWh: WebhookConfig = {
id: generateId(),
name: `推送 ${config.wechat.webhooks.length + 1}`,
url: '',
enabled: true,
params: { interval_seconds: 60, push_delay_ms: 2000, silent_start_hour: 0, silent_end_hour: 7 },
}
const webhooks = [...config.wechat.webhooks, newWh]
setConfig({ ...config, wechat: { ...config.wechat, webhooks } })
setExpandedWebhooks(prev => new Set(prev).add(newWh.id))
}
const removeWebhook = (id: string) => {
if (!config) return
if (config.wechat.webhooks.length <= 1) return
setConfig({ ...config, wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } })
}
const updateWebhook = (id: string, updates: Partial<WebhookConfig>) => {
if (!config) return
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, ...updates } : w)
setConfig({ ...config, wechat: { ...config.wechat, webhooks } })
}
const updateWebhookParams = (id: string, paramUpdates: Partial<WebhookConfig['params']>) => {
if (!config) return
const webhooks = config.wechat.webhooks.map(w => w.id === id ? { ...w, params: { ...w.params, ...paramUpdates } } : w)
setConfig({ ...config, wechat: { ...config.wechat, webhooks } })
}
const toggleWebhookExpand = (id: string) => {
setExpandedWebhooks(prev => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
function hasSectionChanged(original: MonitorConfig | null, current: MonitorConfig | null, section: SectionKey): boolean {
if (!original || !current) return false
return JSON.stringify(original[section]) !== JSON.stringify(current[section])
}
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 }) => {
const changed = hasSectionChanged(originalConfig, 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>
{/* 微信推送 - 多 Webhook */}
<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={addWebhook} className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-blue-500 text-white rounded-lg hover:bg-blue-600 text-sm font-medium">
<Plus size={14} />
</button>
</div>
{sectionMessage.wechat && (
<div className={`mb-4 p-3 rounded-lg text-sm ${sectionMessage.wechat.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'}`}>
{sectionMessage.wechat.text}
</div>
)}
<div className="space-y-3">
{config.wechat.webhooks.map((wh, index) => (
<div key={wh.id} className={`border rounded-lg transition-colors ${wh.enabled ? 'border-slate-200 dark:border-slate-700' : 'border-slate-200 dark:border-slate-700 opacity-60'}`}>
<div className="flex items-center gap-3 px-4 py-3">
<button
onClick={() => toggleWebhookExpand(wh.id)}
className="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
{expandedWebhooks.has(wh.id) ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
<span className="text-sm font-medium text-slate-500 dark:text-slate-400 w-8">#{index + 1}</span>
<input
type="text"
value={wh.name}
onChange={e => updateWebhook(wh.id, { name: e.target.value })}
className="flex-1 px-2 py-1 text-sm border-0 bg-transparent focus:outline-none focus:ring-1 focus:ring-blue-400 rounded text-slate-900 dark:text-slate-100 font-medium"
placeholder="推送组名称"
/>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={wh.enabled}
onChange={e => updateWebhook(wh.id, { enabled: e.target.checked })}
className="sr-only peer"
/>
<div className="w-9 h-5 bg-slate-200 peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-slate-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all dark:border-slate-600 peer-checked:bg-blue-500"></div>
</label>
<button
onClick={() => handleTestWebhook(wh.id)}
disabled={!wh.url || testingWebhook === wh.id}
className="px-3 py-1 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 text-xs disabled:opacity-50"
>
{testingWebhook === wh.id ? '测试中...' : '测试'}
</button>
{config.wechat.webhooks.length > 1 && (
<button onClick={() => removeWebhook(wh.id)} className="p-1 text-slate-400 hover:text-red-500 rounded" title="删除">
<Trash2 size={14} />
</button>
)}
</div>
{expandedWebhooks.has(wh.id) && (
<div className="px-4 pb-4 space-y-4 border-t border-slate-100 dark:border-slate-700/50 pt-3">
<div>
<label className="block text-sm font-medium mb-1">Webhook </label>
<input
type="text"
value={wh.url}
onChange={e => updateWebhook(wh.id, { url: e.target.value })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-2"></label>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs text-slate-500 mb-1"></label>
<input
type="number"
value={wh.params.interval_seconds}
onChange={e => updateWebhookParams(wh.id, { interval_seconds: parseInt(e.target.value) || 60 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
min={10} max={3600}
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1"></label>
<input
type="number"
value={wh.params.silent_start_hour}
onChange={e => updateWebhookParams(wh.id, { silent_start_hour: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
min={0} max={23}
/>
</div>
<div>
<label className="block text-xs text-slate-500 mb-1"></label>
<input
type="number"
value={wh.params.silent_end_hour}
onChange={e => updateWebhookParams(wh.id, { silent_end_hour: parseInt(e.target.value) || 7 })}
className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg text-sm"
min={0} max={23}
/>
</div>
</div>
</div>
</div>
)}
</div>
))}
</div>
<SectionSaveButton section="wechat" />
</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>
)
}