feat: 邮件扫描导入功能增强

- 新增 scan-emails/import API 端点,支持手动导入跳过的邮件为工单
- 前端添加'导入跳过的邮件为工单'按钮,扫描完成后可选择导入
- 添加扫描详情日志,记录无内容邮件的跳过原因
- 修复 scan_history 表 details_json 为空的问题
This commit is contained in:
gitadmin 2026-07-10 13:22:55 +08:00
parent 75ca749db7
commit c11e9fbc64
3 changed files with 190 additions and 1 deletions

View File

@ -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>
)}

View File

@ -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,
})
}

View File

@ -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 || ''