feat: 邮件扫描功能增强与 Bug 修复
主要更新: - 新增邮箱工单检测模块,支持扫描邮箱并自动导入工单 - 新增扫描取消机制,支持用户中途取消扫描任务 - 新增扫描历史持久化,保存到 scan_history 表 - 修复 IMAP 连接管理问题,连接失败时正确重置 client - 修复邮件搜索范围,从今天改为最近 7 天 Bug 修复: - B-1: 修复 getClientIP 传入 null 问题,正确获取客户端 IP - B-2: 修复 toISOString() 违反规范,改用 formatBeijingTime - B-3: 修复 refreshStatus/refreshErrors 缺少 .catch() 处理 - E-1: 添加 detailsTruncated 字段,超过 500 条时显示截断警告 - E-2: 添加 5 分钟整体超时控制,避免扫描长时间挂起 - E-3: 优化 refreshErrors 路径依赖,使用安全的属性合并 - E-4: scanning 状态从 scanState.status 派生,保持状态一致 - 修复超时状态被覆盖为 completed 的 Bug 新增文件: - src/lib/monitor/scan-state.ts: 共享扫描状态管理 - src/app/api/monitor/scan-emails/route.ts: 邮件扫描 API - src/app/api/monitor/scan-status/route.ts: 扫描状态查询 API - src/app/api/monitor/scan-history/route.ts: 扫描历史查询 API
This commit is contained in:
parent
d64c950894
commit
b6f0393450
|
|
@ -1,7 +1,7 @@
|
||||||
'use client'
|
'use client'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import { Card } from '@/components/ui'
|
import { Card } from '@/components/ui'
|
||||||
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
|
import { Plus, Trash2, ChevronDown, ChevronUp, Search, RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
interface MonitorStatus {
|
interface MonitorStatus {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
|
|
@ -37,6 +37,31 @@ interface MonitorConfig {
|
||||||
wechat: { webhooks: WebhookConfig[] }
|
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'
|
type SectionKey = 'mail' | 'filter' | 'wechat' | 'monitor'
|
||||||
|
|
||||||
function generateId() {
|
function generateId() {
|
||||||
|
|
@ -56,22 +81,240 @@ export default function MonitorSettingsPage() {
|
||||||
const [expandedWebhooks, setExpandedWebhooks] = useState<Set<string>>(new Set())
|
const [expandedWebhooks, setExpandedWebhooks] = useState<Set<string>>(new Set())
|
||||||
const [testingWebhook, setTestingWebhook] = useState<string | null>(null)
|
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)
|
||||||
|
|
||||||
|
// 从 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(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
fetch('/api/monitor/status').then(r => r.json()),
|
fetch('/api/monitor/status').then(r => r.json()),
|
||||||
fetch('/api/monitor/settings').then(r => r.json()),
|
fetch('/api/monitor/settings').then(r => r.json()),
|
||||||
]).then(([s, c]) => {
|
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)
|
setStatus(s)
|
||||||
setConfig(c)
|
setConfig(c)
|
||||||
setOriginalConfig(JSON.parse(JSON.stringify(c)))
|
setOriginalConfig(JSON.parse(JSON.stringify(c)))
|
||||||
|
setScanState(scan)
|
||||||
|
setScanHistory(history)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}).catch(() => setLoading(false))
|
}).catch(() => setLoading(false))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshStatus = () => {
|
// 立即检查处理
|
||||||
fetch('/api/monitor/status').then(r => r.json()).then(setStatus)
|
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理扫描定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (scanTimerRef.current) clearInterval(scanTimerRef.current)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 其他原有函数保持不变...
|
||||||
const handleStart = async () => {
|
const handleStart = async () => {
|
||||||
await fetch('/api/monitor/start', { method: 'POST' })
|
await fetch('/api/monitor/start', { method: 'POST' })
|
||||||
refreshStatus()
|
refreshStatus()
|
||||||
|
|
@ -85,17 +328,6 @@ export default function MonitorSettingsPage() {
|
||||||
setMessage({ type: 'success', text: '监控已停用' })
|
setMessage({ type: 'success', text: '监控已停用' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTrigger = async () => {
|
|
||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTestMail = async () => {
|
const handleTestMail = async () => {
|
||||||
setSectionMessage(prev => ({ ...prev, mail: { type: 'success', text: '正在测试邮箱连接...' } }))
|
setSectionMessage(prev => ({ ...prev, mail: { type: 'success', text: '正在测试邮箱连接...' } }))
|
||||||
const res = await fetch('/api/monitor/test-mail', { method: 'POST' })
|
const res = await fetch('/api/monitor/test-mail', { method: 'POST' })
|
||||||
|
|
@ -150,8 +382,6 @@ export default function MonitorSettingsPage() {
|
||||||
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
|
setConfig({ ...config, filter: { ...config.filter, subject_keywords: config.filter.subject_keywords.filter((_, i) => i !== index) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Webhook 管理 ---
|
|
||||||
|
|
||||||
const addWebhook = () => {
|
const addWebhook = () => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
const newWh: WebhookConfig = {
|
const newWh: WebhookConfig = {
|
||||||
|
|
@ -163,13 +393,12 @@ export default function MonitorSettingsPage() {
|
||||||
}
|
}
|
||||||
const webhooks = [...config.wechat.webhooks, newWh]
|
const webhooks = [...config.wechat.webhooks, newWh]
|
||||||
setConfig({ ...config, wechat: { ...config.wechat, webhooks } })
|
setConfig({ ...config, wechat: { ...config.wechat, webhooks } })
|
||||||
// 自动展开新增的 webhook
|
|
||||||
setExpandedWebhooks(prev => new Set(prev).add(newWh.id))
|
setExpandedWebhooks(prev => new Set(prev).add(newWh.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeWebhook = (id: string) => {
|
const removeWebhook = (id: string) => {
|
||||||
if (!config) return
|
if (!config) return
|
||||||
if (config.wechat.webhooks.length <= 1) return // 至少保留一个
|
if (config.wechat.webhooks.length <= 1) return
|
||||||
setConfig({ ...config, wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } })
|
setConfig({ ...config, wechat: { ...config.wechat, webhooks: config.wechat.webhooks.filter(w => w.id !== id) } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -194,17 +423,14 @@ export default function MonitorSettingsPage() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查某个 section 是否有变化
|
|
||||||
function hasSectionChanged(original: MonitorConfig | null, current: MonitorConfig | null, section: SectionKey): boolean {
|
function hasSectionChanged(original: MonitorConfig | null, current: MonitorConfig | null, section: SectionKey): boolean {
|
||||||
if (!original || !current) return false
|
if (!original || !current) return false
|
||||||
return JSON.stringify(original[section]) !== JSON.stringify(current[section])
|
return JSON.stringify(original[section]) !== JSON.stringify(current[section])
|
||||||
}
|
}
|
||||||
|
|
||||||
// 输入框样式
|
|
||||||
const inputClass = (path: string, changed = false) =>
|
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'}`
|
`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'}`
|
||||||
|
|
||||||
// Section 保存按钮组件
|
|
||||||
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
const SectionSaveButton = ({ section }: { section: SectionKey }) => {
|
||||||
const changed = hasSectionChanged(originalConfig, config, section)
|
const changed = hasSectionChanged(originalConfig, config, section)
|
||||||
if (!changed) return null
|
if (!changed) return null
|
||||||
|
|
@ -400,7 +626,13 @@ export default function MonitorSettingsPage() {
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<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>}
|
{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} className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -471,6 +703,175 @@ export default function MonitorSettingsPage() {
|
||||||
<SectionSaveButton section="filter" />
|
<SectionSaveButton section="filter" />
|
||||||
</Card>
|
</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>
|
||||||
|
)}
|
||||||
|
</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 */}
|
{/* 微信推送 - 多 Webhook */}
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|
@ -492,7 +893,6 @@ export default function MonitorSettingsPage() {
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{config.wechat.webhooks.map((wh, index) => (
|
{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 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">
|
<div className="flex items-center gap-3 px-4 py-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleWebhookExpand(wh.id)}
|
onClick={() => toggleWebhookExpand(wh.id)}
|
||||||
|
|
@ -531,7 +931,6 @@ export default function MonitorSettingsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 展开的详情 */}
|
|
||||||
{expandedWebhooks.has(wh.id) && (
|
{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 className="px-4 pb-4 space-y-4 border-t border-slate-100 dark:border-slate-700/50 pt-3">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -588,7 +987,7 @@ export default function MonitorSettingsPage() {
|
||||||
<SectionSaveButton section="wechat" />
|
<SectionSaveButton section="wechat" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 全局运行参数(保持向后兼容) */}
|
{/* 全局运行参数 */}
|
||||||
<Card className="p-5">
|
<Card className="p-5">
|
||||||
<h2 className="text-lg font-semibold mb-4">全局运行参数</h2>
|
<h2 className="text-lg font-semibold mb-4">全局运行参数</h2>
|
||||||
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">未在推送组中单独配置的参数将使用以下全局默认值</p>
|
<p className="text-sm text-slate-500 dark:text-slate-400 mb-4">未在推送组中单独配置的参数将使用以下全局默认值</p>
|
||||||
|
|
@ -608,6 +1007,102 @@ export default function MonitorSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
<SectionSaveButton section="monitor" />
|
<SectionSaveButton section="monitor" />
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,369 @@
|
||||||
|
// src/app/api/monitor/scan-emails/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { getMonitorConfig } from '@/lib/monitor/settings-manager'
|
||||||
|
import { ImapFlow } from 'imapflow'
|
||||||
|
import { simpleParser } from 'mailparser'
|
||||||
|
import { formatBeijingTime } from '@/lib/monitor/types'
|
||||||
|
import { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||||
|
import { getScanState, setScanState, isCancelRequested, resetCancelFlag } from '@/lib/monitor/scan-state'
|
||||||
|
|
||||||
|
interface ScanResult {
|
||||||
|
status: '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
|
||||||
|
}
|
||||||
|
|
||||||
|
const VALID_UNITS = ['minute', 'hour', 'day', 'week', 'month', 'all']
|
||||||
|
const MAX_VALUE = 365
|
||||||
|
const MAX_DETAILS = 500
|
||||||
|
const SCAN_TIMEOUT_MS = 5 * 60 * 1000 // 5 分钟超时
|
||||||
|
|
||||||
|
// 辅助函数:添加 detail 并追踪是否被截断
|
||||||
|
function addDetail(
|
||||||
|
scanState: ScanResult,
|
||||||
|
detail: ScanResult['details'][0],
|
||||||
|
counter: { total: number }
|
||||||
|
): void {
|
||||||
|
counter.total++
|
||||||
|
if (scanState.details.length < MAX_DETAILS) {
|
||||||
|
scanState.details.push(detail)
|
||||||
|
} else {
|
||||||
|
scanState.detailsTruncated = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractOrderNumber(subject: string): string | null {
|
||||||
|
const match = subject.match(/【服务器故障单】(\d+),/)
|
||||||
|
return match?.[1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateRange(value: number | null, unit: string): Date {
|
||||||
|
const now = new Date()
|
||||||
|
if (!value || unit === 'all') {
|
||||||
|
// 全部:搜索最近 30 天
|
||||||
|
now.setDate(now.getDate() - 30)
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (unit) {
|
||||||
|
case 'minute':
|
||||||
|
now.setMinutes(now.getMinutes() - value)
|
||||||
|
break
|
||||||
|
case 'hour':
|
||||||
|
now.setHours(now.getHours() - value)
|
||||||
|
break
|
||||||
|
case 'day':
|
||||||
|
now.setDate(now.getDate() - value)
|
||||||
|
break
|
||||||
|
case 'week':
|
||||||
|
now.setDate(now.getDate() - value * 7)
|
||||||
|
break
|
||||||
|
case 'month':
|
||||||
|
now.setMonth(now.getMonth() - value)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
now.setDate(now.getDate() - 7)
|
||||||
|
}
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
|
||||||
|
// 检查是否有正在运行的扫描
|
||||||
|
const currentScan = getScanState()
|
||||||
|
if (currentScan?.status === 'running') {
|
||||||
|
return NextResponse.json({ error: '扫描正在进行中,请稍后再试' }, { status: 409 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { value, unit } = body.timeRange || { value: 7, unit: 'day' }
|
||||||
|
|
||||||
|
// 输入验证
|
||||||
|
if (unit !== 'all') {
|
||||||
|
if (typeof value !== 'number' || value < 1 || value > MAX_VALUE) {
|
||||||
|
return NextResponse.json({ error: `时间范围值必须在 1-${MAX_VALUE} 之间` }, { status: 400 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!VALID_UNITS.includes(unit)) {
|
||||||
|
return NextResponse.json({ error: `无效的时间单位: ${unit}` }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getMonitorConfig()
|
||||||
|
const db = getDb()
|
||||||
|
const since = getDateRange(value, unit)
|
||||||
|
|
||||||
|
// 初始化扫描结果
|
||||||
|
const scanState: ScanResult = {
|
||||||
|
status: 'running',
|
||||||
|
startedAt: formatBeijingTime(),
|
||||||
|
timeRange: { value, unit },
|
||||||
|
stats: { total: 0, matched: 0, imported: 0, skipped: 0, errors: 0 },
|
||||||
|
details: [],
|
||||||
|
detailsTruncated: false,
|
||||||
|
}
|
||||||
|
setScanState(scanState)
|
||||||
|
|
||||||
|
// 重置取消标志
|
||||||
|
resetCancelFlag()
|
||||||
|
|
||||||
|
// 异步执行扫描
|
||||||
|
scanEmails(config, db, since, scanState, clientIP, user.id).catch(err => {
|
||||||
|
// 内层已设置错误状态,此处不再重复设置
|
||||||
|
console.error('[Scan] Unexpected error:', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: '扫描已开始' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanEmails(
|
||||||
|
config: ReturnType<typeof getMonitorConfig>,
|
||||||
|
db: ReturnType<typeof getDb>,
|
||||||
|
since: Date,
|
||||||
|
scanState: ScanResult,
|
||||||
|
clientIP: string | null,
|
||||||
|
userId: number
|
||||||
|
): Promise<void> {
|
||||||
|
const client = new ImapFlow({
|
||||||
|
host: config.mail.imap_server,
|
||||||
|
port: config.mail.imap_port,
|
||||||
|
secure: true,
|
||||||
|
auth: { user: config.mail.address, pass: config.mail.password },
|
||||||
|
logger: false,
|
||||||
|
connectionTimeout: 15_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 整体超时控制
|
||||||
|
const startTime = Date.now()
|
||||||
|
const checkTimeout = (): boolean => {
|
||||||
|
if (Date.now() - startTime > SCAN_TIMEOUT_MS) {
|
||||||
|
scanState.status = 'error'
|
||||||
|
scanState.completedAt = formatBeijingTime()
|
||||||
|
scanState.error = `扫描超时(超过 ${SCAN_TIMEOUT_MS / 60000} 分钟)`
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect()
|
||||||
|
const lock = await client.getMailboxLock('INBOX')
|
||||||
|
|
||||||
|
// 详情计数器
|
||||||
|
const detailsCounter = { total: 0 }
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 搜索邮件
|
||||||
|
const uids = await client.search({ since }, { uid: true })
|
||||||
|
if (!uids || uids.length === 0) {
|
||||||
|
scanState.status = 'completed'
|
||||||
|
scanState.completedAt = formatBeijingTime()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scanState.stats.total = uids.length
|
||||||
|
|
||||||
|
// 处理每封邮件
|
||||||
|
for (const uid of uids) {
|
||||||
|
// 检查取消标志
|
||||||
|
if (isCancelRequested()) {
|
||||||
|
scanState.status = 'completed'
|
||||||
|
scanState.completedAt = formatBeijingTime()
|
||||||
|
scanState.error = '用户取消'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查超时
|
||||||
|
if (checkTimeout()) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const msg = await client.fetchOne(uid, { source: true, uid: true }, { uid: true })
|
||||||
|
if (!msg || !('source' in msg) || !(msg as any).source) continue
|
||||||
|
|
||||||
|
const parsed = await simpleParser((msg as any).source as Buffer)
|
||||||
|
const subject = parsed.subject || ''
|
||||||
|
const date = parsed.date ? formatBeijingTime(parsed.date) : formatBeijingTime()
|
||||||
|
const msgId = parsed.messageId || String(uid)
|
||||||
|
|
||||||
|
// 提取工单号
|
||||||
|
const orderNumber = extractOrderNumber(subject)
|
||||||
|
if (!orderNumber) {
|
||||||
|
scanState.stats.matched++
|
||||||
|
addDetail(scanState, {
|
||||||
|
msg_id: msgId,
|
||||||
|
subject,
|
||||||
|
date,
|
||||||
|
order_number: null,
|
||||||
|
status: 'skipped',
|
||||||
|
error: '无法提取工单号',
|
||||||
|
}, detailsCounter)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查工单是否已存在(tickets 表以 id 作为工单号)
|
||||||
|
const existing = db.prepare('SELECT id FROM tickets WHERE id = ?').get(parseInt(orderNumber))
|
||||||
|
if (existing) {
|
||||||
|
scanState.stats.skipped++
|
||||||
|
scanState.stats.matched++
|
||||||
|
addDetail(scanState, {
|
||||||
|
msg_id: msgId,
|
||||||
|
subject,
|
||||||
|
date,
|
||||||
|
order_number: orderNumber,
|
||||||
|
status: 'skipped',
|
||||||
|
ticket_no: orderNumber,
|
||||||
|
error: '工单已存在',
|
||||||
|
}, detailsCounter)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已处理过
|
||||||
|
const processed = db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(msgId)
|
||||||
|
if (processed) {
|
||||||
|
scanState.stats.skipped++
|
||||||
|
scanState.stats.matched++
|
||||||
|
addDetail(scanState, {
|
||||||
|
msg_id: msgId,
|
||||||
|
subject,
|
||||||
|
date,
|
||||||
|
order_number: orderNumber,
|
||||||
|
status: 'skipped',
|
||||||
|
error: '邮件已处理过',
|
||||||
|
}, detailsCounter)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建工单(使用 id 作为工单号)
|
||||||
|
const ticketId = parseInt(orderNumber)
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO tickets (id, content, current_status, created_by, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'open', ?, datetime('now', '+8 hours'), datetime('now', '+8 hours'))
|
||||||
|
`).run(ticketId, `从邮件导入:${subject}`, userId)
|
||||||
|
|
||||||
|
// 记录已处理邮件
|
||||||
|
db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(msgId, subject)
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
writeAuditLog({
|
||||||
|
userId: userId,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'import',
|
||||||
|
entityType: 'ticket',
|
||||||
|
entityId: ticketId,
|
||||||
|
details: { created: { ticket_no: orderNumber, source: 'email_scan' } },
|
||||||
|
ipAddress: clientIP,
|
||||||
|
})
|
||||||
|
|
||||||
|
scanState.stats.imported++
|
||||||
|
scanState.stats.matched++
|
||||||
|
addDetail(scanState, {
|
||||||
|
msg_id: msgId,
|
||||||
|
subject,
|
||||||
|
date,
|
||||||
|
order_number: orderNumber,
|
||||||
|
status: 'imported',
|
||||||
|
ticket_no: orderNumber,
|
||||||
|
}, detailsCounter)
|
||||||
|
} catch (err) {
|
||||||
|
scanState.stats.errors++
|
||||||
|
addDetail(scanState, {
|
||||||
|
msg_id: String(uid),
|
||||||
|
subject: '',
|
||||||
|
date: '',
|
||||||
|
order_number: null,
|
||||||
|
status: 'error',
|
||||||
|
error: err instanceof Error ? err.message : '处理失败',
|
||||||
|
}, detailsCounter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录截断信息
|
||||||
|
if (scanState.detailsTruncated) {
|
||||||
|
console.log(`[Scan] 详情列表已截断: 共 ${detailsCounter.total} 条,仅保存前 ${MAX_DETAILS} 条`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有状态仍然是 running 时才设置为 completed(避免覆盖超时/取消状态)
|
||||||
|
if (scanState.status === 'running') {
|
||||||
|
scanState.status = 'completed'
|
||||||
|
scanState.completedAt = formatBeijingTime()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
scanState.status = 'error'
|
||||||
|
scanState.completedAt = formatBeijingTime()
|
||||||
|
scanState.error = err instanceof Error ? err.message : '连接失败'
|
||||||
|
// 不再 re-throw,避免外层 catch 重复处理
|
||||||
|
} finally {
|
||||||
|
try { await client.logout() } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存扫描历史到数据库
|
||||||
|
saveScanHistory(scanState, userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存扫描历史到数据库
|
||||||
|
function saveScanHistory(scanState: ScanResult, userId: number): void {
|
||||||
|
try {
|
||||||
|
const db = getDb()
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO scan_history (
|
||||||
|
status, started_at, completed_at,
|
||||||
|
time_range_value, time_range_unit,
|
||||||
|
total_count, matched_count, imported_count, skipped_count, error_count,
|
||||||
|
details_truncated, error_message, details_json, created_by
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`).run(
|
||||||
|
scanState.status,
|
||||||
|
scanState.startedAt,
|
||||||
|
scanState.completedAt || null,
|
||||||
|
scanState.timeRange.value,
|
||||||
|
scanState.timeRange.unit,
|
||||||
|
scanState.stats.total,
|
||||||
|
scanState.stats.matched,
|
||||||
|
scanState.stats.imported,
|
||||||
|
scanState.stats.skipped,
|
||||||
|
scanState.stats.errors,
|
||||||
|
scanState.detailsTruncated ? 1 : 0,
|
||||||
|
scanState.error || null,
|
||||||
|
JSON.stringify(scanState.details),
|
||||||
|
userId
|
||||||
|
)
|
||||||
|
console.log(`[Scan] 扫描历史已保存: 状态=${scanState.status}, 导入=${scanState.stats.imported}`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Scan] 保存扫描历史失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
// src/app/api/monitor/scan-history/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getDb } from '@/lib/db'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
|
||||||
|
interface ScanHistoryItem {
|
||||||
|
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
|
||||||
|
details_json: string | null
|
||||||
|
created_by: number | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:read')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const page = parseInt(searchParams.get('page') || '1')
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '20')
|
||||||
|
const offset = (page - 1) * limit
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
|
||||||
|
// 获取总数
|
||||||
|
const countResult = db.prepare('SELECT COUNT(*) as total FROM scan_history').get() as { total: number }
|
||||||
|
const total = countResult.total
|
||||||
|
|
||||||
|
// 获取列表
|
||||||
|
const items = db.prepare(`
|
||||||
|
SELECT * FROM scan_history
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`).all(limit, offset) as ScanHistoryItem[]
|
||||||
|
|
||||||
|
// 解析 details_json
|
||||||
|
const itemsWithDetails = items.map(item => ({
|
||||||
|
...item,
|
||||||
|
details: item.details_json ? JSON.parse(item.details_json) : [],
|
||||||
|
details_json: undefined,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
items: itemsWithDetails,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE 清理旧历史(保留最近 N 天)
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => ({}))
|
||||||
|
const keepDays = body.keepDays || 30
|
||||||
|
|
||||||
|
const db = getDb()
|
||||||
|
const result = db.prepare(`
|
||||||
|
DELETE FROM scan_history
|
||||||
|
WHERE created_at < datetime('now', '+8 hours', ? || ' days')
|
||||||
|
`).run(-keepDays)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
deleted: result.changes,
|
||||||
|
message: `已清理 ${keepDays} 天前的扫描历史`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
// src/app/api/monitor/scan-status/route.ts
|
||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { initDatabase } from '@/lib/db-schema'
|
||||||
|
import { getCurrentUser } from '@/lib/auth'
|
||||||
|
import { hasPermission } from '@/lib/permissions'
|
||||||
|
import { getClientIP, writeAuditLog } from '@/lib/audit'
|
||||||
|
import { getScanState, requestCancel } from '@/lib/monitor/scan-state'
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:read')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = getScanState()
|
||||||
|
return NextResponse.json(state || { status: 'idle' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE 请求用于取消扫描
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
initDatabase()
|
||||||
|
const user = await getCurrentUser()
|
||||||
|
if (!user || !hasPermission(user, 'monitor:write')) {
|
||||||
|
return NextResponse.json({ error: '权限不足' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = getScanState()
|
||||||
|
if (state?.status !== 'running') {
|
||||||
|
return NextResponse.json({ error: '没有正在运行的扫描任务' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIP = getClientIP(request)
|
||||||
|
requestCancel()
|
||||||
|
state.status = 'cancelling'
|
||||||
|
|
||||||
|
// 审计日志
|
||||||
|
writeAuditLog({
|
||||||
|
userId: user.id || null,
|
||||||
|
apiKeyId: null,
|
||||||
|
action: 'cancel',
|
||||||
|
entityType: 'scan',
|
||||||
|
entityId: null,
|
||||||
|
details: { scan: { requestedBy: user.username } },
|
||||||
|
ipAddress: clientIP,
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: '取消请求已发送,扫描将在处理完当前邮件后停止' })
|
||||||
|
}
|
||||||
|
|
@ -166,4 +166,25 @@ export function initDatabase(): void {
|
||||||
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
||||||
)`)
|
)`)
|
||||||
try { db.exec("CREATE INDEX IF NOT EXISTS idx_monitor_logs_msg ON monitor_logs(email_msg_id)") } catch { /* 索引已存在 */ }
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_monitor_logs_msg ON monitor_logs(email_msg_id)") } catch { /* 索引已存在 */ }
|
||||||
|
|
||||||
|
// scan_history — 扫描历史记录
|
||||||
|
db.exec(`CREATE TABLE IF NOT EXISTS scan_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT,
|
||||||
|
time_range_value INTEGER,
|
||||||
|
time_range_unit TEXT,
|
||||||
|
total_count INTEGER DEFAULT 0,
|
||||||
|
matched_count INTEGER DEFAULT 0,
|
||||||
|
imported_count INTEGER DEFAULT 0,
|
||||||
|
skipped_count INTEGER DEFAULT 0,
|
||||||
|
error_count INTEGER DEFAULT 0,
|
||||||
|
details_truncated INTEGER DEFAULT 0,
|
||||||
|
error_message TEXT,
|
||||||
|
details_json TEXT,
|
||||||
|
created_by INTEGER,
|
||||||
|
created_at TEXT DEFAULT (datetime('now', '+8 hours'))
|
||||||
|
)`)
|
||||||
|
try { db.exec("CREATE INDEX IF NOT EXISTS idx_scan_history_created_at ON scan_history(created_at)") } catch { /* 索引已存在 */ }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,13 @@ export class MailMonitor {
|
||||||
logger: false,
|
logger: false,
|
||||||
connectionTimeout: 15_000,
|
connectionTimeout: 15_000,
|
||||||
})
|
})
|
||||||
|
try {
|
||||||
await this.client.connect()
|
await this.client.connect()
|
||||||
this.retryCount = 0
|
this.retryCount = 0
|
||||||
|
} catch (e) {
|
||||||
|
this.client = null
|
||||||
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async disconnect(): Promise<void> {
|
async disconnect(): Promise<void> {
|
||||||
|
|
@ -56,9 +61,9 @@ export class MailMonitor {
|
||||||
if (!this.client) return []
|
if (!this.client) return []
|
||||||
const lock = await this.client.getMailboxLock('INBOX')
|
const lock = await this.client.getMailboxLock('INBOX')
|
||||||
try {
|
try {
|
||||||
const today = new Date()
|
// 搜索最近 7 天的邮件(避免遗漏昨天未处理的邮件),通过 processed_emails 表去重
|
||||||
const since = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
const since = new Date()
|
||||||
// 不使用 unseen 条件(IMAP 标记不可靠),依赖 processed_emails 表去重
|
since.setDate(since.getDate() - 7)
|
||||||
const uids = await this.client.search({ since }, { uid: true })
|
const uids = await this.client.search({ since }, { uid: true })
|
||||||
if (!uids || uids.length === 0) return []
|
if (!uids || uids.length === 0) return []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
// src/lib/monitor/scan-state.ts
|
||||||
|
// 全局扫描状态管理(与 scan-emails 和 scan-status 路由共享)
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
status: '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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模块级变量存储扫描状态
|
||||||
|
let scanState: ScanResult | null = null
|
||||||
|
|
||||||
|
// 取消标志
|
||||||
|
let cancelRequested = false
|
||||||
|
|
||||||
|
export function getScanState(): ScanResult | null {
|
||||||
|
return scanState
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setScanState(state: ScanResult | null): void {
|
||||||
|
scanState = state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isCancelRequested(): boolean {
|
||||||
|
return cancelRequested
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requestCancel(): void {
|
||||||
|
cancelRequested = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetCancelFlag(): void {
|
||||||
|
cancelRequested = false
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue