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 [scanTimeValue, setScanTimeValue] = useState<number>(7)
|
||||||
const [scanTimeUnit, setScanTimeUnit] = useState<string>('day')
|
const [scanTimeUnit, setScanTimeUnit] = useState<string>('day')
|
||||||
const scanTimerRef = useRef<NodeJS.Timeout | null>(null)
|
const scanTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||||
|
const [importing, setImporting] = useState(false)
|
||||||
|
|
||||||
// 从 scanState.status 派生 scanning 状态
|
// 从 scanState.status 派生 scanning 状态
|
||||||
const scanning = scanState.status === 'running' || scanState.status === 'cancelling'
|
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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
|
|
@ -861,6 +899,33 @@ export default function MonitorSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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 {
|
try {
|
||||||
const msg = await client.fetchOne(uid, { source: true, uid: true }, { uid: true })
|
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 parsed = await simpleParser((msg as any).source as Buffer)
|
||||||
const subject = parsed.subject || ''
|
const subject = parsed.subject || ''
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue