Files
EmailBill/Web/src/views/ClassificationSmart.vue
孙诚 1f01d13ed3
Some checks failed
Docker Build & Deploy / Build Docker Image (push) Failing after 6s
Docker Build & Deploy / Deploy to Production (push) Has been skipped
样式统一
2025-12-30 17:02:30 +08:00

372 lines
9.1 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-flex smart-classification">
<van-nav-bar
title="智能分类"
left-text="返回"
left-arrow
@click-left="onClickLeft"
/>
<div class="scroll-content" style="padding-top: 5px;">
<!-- 统计信息 -->
<div class="stats-info">
<span class="stats-label">未分类账单 </span>
<span class="stats-value">{{ unclassifiedCount }} 共计 {{ totalGroups }} </span>
</div>
<!-- 分组列表组件 -->
<ReasonGroupList
ref="groupListRef"
:selectable="true"
@data-loaded="handleDataLoaded"
@data-changed="handleDataChanged"
/>
<!-- 底部安全距离 -->
<div style="height: calc(50px + env(safe-area-inset-bottom, 0px))"></div>
</div>
<!-- 底部操作按钮 -->
<div class="action-bar">
<van-button
type="primary"
:loading="classifying"
:disabled="selectedCount === 0"
@click="startClassify"
class="action-btn"
>
{{ classifying ? '分类中...' : `开始分类 (${selectedCount}组)` }}
</van-button>
<van-button
type="success"
:disabled="!hasChanges || classifying"
@click="saveClassifications"
class="action-btn"
>
保存分类
</van-button>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { showToast, showLoadingToast, closeToast, showConfirmDialog } from 'vant'
import {
getUnclassifiedCount,
smartClassify,
batchUpdateClassify
} from '@/api/transactionRecord'
import ReasonGroupList from '@/components/ReasonGroupList.vue'
const router = useRouter()
const groupListRef = ref(null)
const unclassifiedCount = ref(0)
const totalGroups = ref(0)
const classifying = ref(false)
const hasChanges = ref(false)
const classifyBuffer = ref('')
// 计算已选中的数量
const selectedCount = computed(() => {
if (!groupListRef.value) return 0
return groupListRef.value.getSelectedReasons().size
})
// 加载未分类账单数量
const loadUnclassifiedCount = async () => {
try {
const res = await getUnclassifiedCount()
if (res.success) {
unclassifiedCount.value = res.data
}
} catch (error) {
console.error('获取未分类数量失败', error)
}
}
// 处理数据加载完成
const handleDataLoaded = ({ groups, total }) => {
totalGroups.value = total
// 默认全选所有分组
if (groupListRef.value) {
groupListRef.value.selectAll()
}
}
// 处理数据变更
const handleDataChanged = async () => {
await loadUnclassifiedCount()
hasChanges.value = false
}
const onClickLeft = () => {
if (hasChanges.value) {
showConfirmDialog({
title: '提示',
message: '有未保存的分类结果,确定要离开吗?',
}).then(() => {
router.back()
}).catch(() => {})
} else {
router.back()
}
}
// 开始智能分类
const startClassify = async () => {
if (!groupListRef.value) return
// 获取所有选中分组
const selectedGroups = groupListRef.value.getList(true)
// 获取所有选中分组的账单ID
const idsToClassify = []
for (const group of selectedGroups) {
idsToClassify.push(...group.transactionIds)
}
if (idsToClassify.length === 0) {
showToast('请先选择要分类的账单组')
return
}
showLoadingToast({
message: '智能分类中...',
forbidClick: true,
duration: 0
})
classifying.value = true
classifyBuffer.value = ''
// 用于存储分类结果的临时对象
const classifyResults = new Map()
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, classifyResults)
}
}
}
} catch (error) {
console.error('智能分类失败', error)
showToast(`分类失败: ${error.message}`)
} finally {
classifying.value = false
classifyBuffer.value = ''
closeToast()
}
}
// 处理SSE事件
const handleSSEEvent = (eventType, data, classifyResults) => {
if (eventType === 'data') {
try {
classifyBuffer.value += data
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) {
const jsonStr = classifyBuffer.value.substring(openBrace, closeBrace + 1)
try {
const result = JSON.parse(jsonStr)
if (result.id && groupListRef.value) {
classifyResults.set(result.id, {
classify: result.classify || '',
type: result.type !== undefined ? result.type : null
})
// 更新组件内的分组显示状态
const groups = groupListRef.value.getList()
for (const group of groups) {
if (group.transactionIds.includes(result.id)) {
group.sampleClassify = result.classify || ''
if (result.type !== undefined && result.type !== null) {
group.sampleType = result.type
}
hasChanges.value = true
break
}
}
// 更新回组件
groupListRef.value.setList(groups)
}
} catch (e) {
console.error('JSON解析失败:', e)
}
classifyBuffer.value = classifyBuffer.value.substring(closeBrace + 1)
startIndex = 0
} else {
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 () => {
if (!groupListRef.value) return
// 收集所有已分类的账单
const groups = groupListRef.value.getList()
const itemsToUpdate = []
for (const group of groups) {
if (group.sampleClassify) {
// 为该分组的所有账单添加分类
for (const id of group.transactionIds) {
itemsToUpdate.push({
id: id,
classify: group.sampleClassify,
type: group.sampleType
})
}
}
}
if (itemsToUpdate.length === 0) {
showToast('没有需要保存的分类')
return
}
showLoadingToast({
message: '保存中...',
forbidClick: true,
duration: 0
})
try {
const res = await batchUpdateClassify(itemsToUpdate)
if (res.success) {
showToast('保存成功')
hasChanges.value = false
// 重新加载数据
await loadUnclassifiedCount()
await groupListRef.value.refresh()
} else {
showToast(res.message || '保存失败')
}
} catch (error) {
console.error('保存失败', error)
showToast('保存失败')
} finally {
closeToast()
}
}
onMounted(async () => {
await loadUnclassifiedCount()
// 触发组件加载数据
if (groupListRef.value) {
await groupListRef.value.loadData()
}
})
</script>
<style scoped>
/* 统计信息 */
.stats-info {
padding: 12px 12px 0 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;
}
/* 设置页面容器背景色 */
:deep(.van-nav-bar) {
background: transparent !important;
}
</style>