Files
EmailBill/Web/src/views/ClassificationSmart.vue
孙诚 c0264faca5
All checks were successful
Docker Build & Deploy / Build Docker Image (push) Successful in 13s
Docker Build & Deploy / Deploy to Production (push) Successful in 5s
样式统一
2025-12-26 17:29:17 +08:00

378 lines
9.4 KiB
Vue
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.
<template>
<div class="page-container smart-classification">
<van-nav-bar
title="智能分类"
left-text="返回"
left-arrow
@click-left="onClickLeft"
/>
<div class="page-content" style="padding-top: 5px;">
<!-- 统计信息 -->
<div class="stats-info">
<span class="stats-label">未分类账单</span>
<span class="stats-value">{{ records.length }} / {{ unclassifiedCount }}</span>
</div>
<!-- 账单列表 -->
<TransactionList
:transactions="records"
:loading="false"
:show-delete="false"
:show-checkbox="true"
:selected-ids="selectedIds"
@click="viewDetail"
@update:selected-ids="selectedIds = $event"
/>
</div>
<!-- 详情/编辑弹出层 -->
<TransactionDetail
v-model:show="detailVisible"
:transaction="currentTransaction"
@save="onDetailSave"
/>
<!-- 底部操作按钮 -->
<div class="action-bar">
<van-button
type="primary"
:loading="classifying"
:disabled="selectedIds.size === 0"
@click="startClassify"
class="action-btn"
>
{{ classifying ? '分类中...' : `开始分类 (${selectedIds.size}/${records.length})` }}
</van-button>
<van-button
type="success"
:disabled="!hasChanges || classifying"
@click="saveClassifications"
class="action-btn"
>
保存分类
</van-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
import {
getUnclassifiedCount,
getUnclassified,
smartClassify,
batchUpdateClassify,
getTransactionDetail
} from '@/api/transactionRecord'
import TransactionList from '@/components/TransactionList.vue'
import TransactionDetail from '@/components/TransactionDetail.vue'
const router = useRouter()
const unclassifiedCount = ref(0)
const records = ref([])
const selectedIds = ref(new Set()) // 选中的账单ID集合
const classifying = ref(false)
const hasChanges = ref(false)
const detailVisible = ref(false)
const currentTransaction = ref(null)
const classifyBuffer = ref('') // SSE数据缓冲区
const onClickLeft = () => {
if (hasChanges.value) {
showConfirmDialog({
title: '提示',
message: '有未保存的分类结果,确定要离开吗?',
}).then(() => {
router.back()
}).catch(() => {})
} else {
router.back()
}
}
// 加载未分类账单数量
const loadUnclassifiedCount = async () => {
try {
const res = await getUnclassifiedCount()
if (res.success) {
unclassifiedCount.value = res.data
}
} catch (error) {
console.error('获取未分类数量失败', error)
}
}
// 加载未分类账单列表
const loadUnclassified = async () => {
showLoadingToast({
message: '加载中...',
forbidClick: true,
duration: 0
})
try {
const res = await getUnclassified(10)
if (res.success) {
records.value = res.data
// 默认全选所有账单
selectedIds.value = new Set(res.data.map(r => r.id))
} else {
showToast(res.message || '加载失败')
}
} catch (error) {
console.error('加载账单失败', error)
showToast('加载失败')
} finally {
closeToast()
}
}
// 开始智能分类
const startClassify = async () => {
const idsToClassify = Array.from(selectedIds.value)
if (idsToClassify.length === 0) {
showToast('请先选择要分类的账单')
return
}
const toast = showLoadingToast({
message: '智能分类中...',
forbidClick: true,
duration: 0
})
classifying.value = true
classifyBuffer.value = '' // 重置缓冲区
try {
const response = await smartClassify(idsToClassify)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (!line.trim()) continue
const eventMatch = line.match(/^event: (.+)$/m)
const dataMatch = line.match(/^data: (.+)$/m)
if (eventMatch && dataMatch) {
const eventType = eventMatch[1]
const data = dataMatch[1]
handleSSEEvent(eventType, data)
}
}
}
} catch (error) {
console.error('智能分类失败', error)
showToast(`分类失败: ${error.message}`)
} finally {
classifying.value = false
classifyBuffer.value = ''
closeToast()
}
}
// 处理SSE事件
const handleSSEEvent = (eventType, data) => {
if (eventType === 'data') {
try {
// 累积AI输出的JSON片段
classifyBuffer.value += data
// 尝试查找并提取完整的JSON对象
// 使用更精确的方式:查找 { 和匹配的 }
let startIndex = 0
while (startIndex < classifyBuffer.value.length) {
const openBrace = classifyBuffer.value.indexOf('{', startIndex)
if (openBrace === -1) {
// 没有找到开始的 {,清理前面的无用字符
classifyBuffer.value = ''
break
}
// 尝试找到匹配的闭合括号
let braceCount = 0
let closeBrace = -1
for (let i = openBrace; i < classifyBuffer.value.length; i++) {
if (classifyBuffer.value[i] === '{') braceCount++
else if (classifyBuffer.value[i] === '}') {
braceCount--
if (braceCount === 0) {
closeBrace = i
break
}
}
}
if (closeBrace !== -1) {
// 找到了完整的JSON
const jsonStr = classifyBuffer.value.substring(openBrace, closeBrace + 1)
try {
const result = JSON.parse(jsonStr)
if (result.id) {
const record = records.value.find(r => r.id === result.id)
if (record) {
record.classify = result.classify || ''
// 如果AI返回了type字段也更新type
if (result.type !== undefined && result.type !== null) {
record.type = result.type
}
hasChanges.value = true
}
}
} catch (e) {
console.error('JSON解析失败:', e)
}
// 移除已处理的部分
classifyBuffer.value = classifyBuffer.value.substring(closeBrace + 1)
startIndex = 0 // 从头开始查找下一个JSON
} else {
// 没有找到闭合括号说明JSON还不完整等待更多数据
break
}
}
} catch (error) {
console.error('解析分类结果失败', error)
}
} else if (eventType === 'start') {
showToast(data)
} else if (eventType === 'end') {
classifyBuffer.value = ''
showToast('分类完成')
} else if (eventType === 'error') {
classifyBuffer.value = ''
showToast(data)
}
}
// 保存分类
const saveClassifications = async () => {
const itemsToUpdate = records.value
.filter(r => r.classify)
.map(r => ({
id: r.id,
classify: r.classify,
type: r.type
}))
if (itemsToUpdate.length === 0) {
showToast('没有需要保存的分类')
return
}
const toast = showLoadingToast({
message: '保存中...',
forbidClick: true,
duration: 0
})
try {
const res = await batchUpdateClassify(itemsToUpdate)
if (res.success) {
showToast('保存成功')
hasChanges.value = false
// 重新加载数据
await loadUnclassifiedCount()
await loadUnclassified()
} else {
showToast(res.message || '保存失败')
}
} catch (error) {
console.error('保存失败', error)
showToast('保存失败')
} finally {
closeToast()
}
}
// 查看详情
const viewDetail = async (transaction) => {
try {
const response = await getTransactionDetail(transaction.id)
if (response.success) {
currentTransaction.value = response.data
detailVisible.value = true
} else {
showToast(response.message || '获取详情失败')
}
} catch (error) {
console.error('获取详情出错:', error)
showToast('获取详情失败')
}
}
// 详情保存后的回调
const onDetailSave = async () => {
// 重新加载数据
await loadUnclassifiedCount()
await loadUnclassified()
}
onMounted(() => {
loadUnclassifiedCount()
loadUnclassified()
})
</script>
<style scoped>
/* 统计信息 */
.stats-info {
padding: 12px 16px;
font-size: 14px;
color: #969799;
}
.stats-value {
font-weight: 500;
}
/* 底部操作栏 */
.action-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 12px;
padding: 12px;
background-color: var(--van-background-2, #fff);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.08);
z-index: 100;
}
@media (prefers-color-scheme: dark) {
.action-bar {
background-color: var(--van-background-2, #2c2c2c);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
}
}
.action-btn {
flex: 1;
height: 44px;
}
</style>