feat: 邮件扫描导入功能增强
- 新增 scan-emails/import API 端点,支持手动导入跳过的邮件为工单 - 前端添加'导入跳过的邮件为工单'按钮,扫描完成后可选择导入 - 添加扫描详情日志,记录无内容邮件的跳过原因 - 修复 scan_history 表 details_json 为空的问题
This commit is contained in:
parent
75ca749db7
commit
c11e9fbc64
|
|
@ -90,6 +90,7 @@ export default function MonitorSettingsPage() {
|
|||
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'
|
||||
|
|
@ -307,6 +308,43 @@ export default function MonitorSettingsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// 导入跳过的邮件为工单
|
||||
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 () => {
|
||||
|
|
@ -861,6 +899,33 @@ export default function MonitorSettingsPage() {
|
|||
</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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
// src/app/api/monitor/scan-emails/import/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 { writeAuditLog, getClientIP } from '@/lib/audit'
|
||||
|
||||
interface ImportEmail {
|
||||
msg_id: string
|
||||
subject: string
|
||||
date: string
|
||||
order_number: string | null
|
||||
status: string
|
||||
ticket_no?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
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 body = await request.json()
|
||||
const { emails } = body as { emails: ImportEmail[] }
|
||||
|
||||
if (!emails || !Array.isArray(emails) || emails.length === 0) {
|
||||
return NextResponse.json({ error: '没有可导入的邮件' }, { status: 400 })
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
let imported = 0
|
||||
let skipped = 0
|
||||
let errors = 0
|
||||
const results: { order_number: string; status: string; error?: string }[] = []
|
||||
|
||||
for (const email of emails) {
|
||||
if (!email.order_number) {
|
||||
skipped++
|
||||
results.push({
|
||||
order_number: '-',
|
||||
status: 'skipped',
|
||||
error: '无工单号',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const ticketId = parseInt(email.order_number)
|
||||
|
||||
// 检查工单是否已存在
|
||||
const existing = db.prepare('SELECT id FROM tickets WHERE id = ?').get(ticketId)
|
||||
if (existing) {
|
||||
skipped++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'skipped',
|
||||
error: '工单已存在',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查是否已处理过
|
||||
const processed = db.prepare('SELECT msg_id FROM processed_emails WHERE msg_id = ?').get(email.msg_id)
|
||||
if (processed) {
|
||||
skipped++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'skipped',
|
||||
error: '邮件已处理过',
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
// 创建工单
|
||||
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, `从邮件导入:${email.subject}`, user.id)
|
||||
|
||||
// 记录已处理邮件
|
||||
db.prepare("INSERT OR IGNORE INTO processed_emails (msg_id, subject) VALUES (?, ?)").run(email.msg_id, email.subject)
|
||||
|
||||
// 审计日志
|
||||
writeAuditLog({
|
||||
userId: user.id,
|
||||
apiKeyId: null,
|
||||
action: 'import',
|
||||
entityType: 'ticket',
|
||||
entityId: ticketId,
|
||||
details: { created: { ticket_no: email.order_number, source: 'email_import' } },
|
||||
ipAddress: clientIP,
|
||||
})
|
||||
|
||||
imported++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'imported',
|
||||
})
|
||||
} catch (err) {
|
||||
errors++
|
||||
results.push({
|
||||
order_number: email.order_number,
|
||||
status: 'error',
|
||||
error: err instanceof Error ? err.message : '导入失败',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `导入完成:成功 ${imported} 封,跳过 ${skipped} 封,错误 ${errors} 封`,
|
||||
imported,
|
||||
skipped,
|
||||
errors,
|
||||
results,
|
||||
})
|
||||
}
|
||||
|
|
@ -209,7 +209,10 @@ async function scanEmails(
|
|||
|
||||
try {
|
||||
const msg = await client.fetchOne(uid, { source: true, uid: true }, { uid: true })
|
||||
if (!msg || !('source' in msg) || !(msg as any).source) continue
|
||||
if (!msg || !('source' in msg) || !(msg as any).source) {
|
||||
console.log(`[Scan] 邮件 ${uid} 无内容,跳过`)
|
||||
continue
|
||||
}
|
||||
|
||||
const parsed = await simpleParser((msg as any).source as Buffer)
|
||||
const subject = parsed.subject || ''
|
||||
|
|
|
|||
Loading…
Reference in New Issue