添加智能分类功能,支持获取未分类账单数量和列表;实现AI分类逻辑;更新相关API和前端视图
This commit is contained in:
@@ -60,6 +60,19 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
|||||||
/// <param name="emailMessageId">邮件ID</param>
|
/// <param name="emailMessageId">邮件ID</param>
|
||||||
/// <returns>交易记录列表</returns>
|
/// <returns>交易记录列表</returns>
|
||||||
Task<List<TransactionRecord>> GetByEmailIdAsync(long emailMessageId);
|
Task<List<TransactionRecord>> GetByEmailIdAsync(long emailMessageId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取未分类的账单数量
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>未分类账单数量</returns>
|
||||||
|
Task<int> GetUnclassifiedCountAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取未分类的账单列表
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="pageSize">每页数量</param>
|
||||||
|
/// <returns>未分类账单列表</returns>
|
||||||
|
Task<List<TransactionRecord>> GetUnclassifiedAsync(int pageSize = 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<TransactionRecord>(freeSql), ITransactionRecordRepository
|
||||||
@@ -180,4 +193,20 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
|||||||
.OrderBy(t => t.OccurredAt)
|
.OrderBy(t => t.OccurredAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int> GetUnclassifiedCountAsync()
|
||||||
|
{
|
||||||
|
return (int)await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(t => string.IsNullOrEmpty(t.Classify))
|
||||||
|
.CountAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<TransactionRecord>> GetUnclassifiedAsync(int pageSize = 10)
|
||||||
|
{
|
||||||
|
return await FreeSql.Select<TransactionRecord>()
|
||||||
|
.Where(t => string.IsNullOrEmpty(t.Classify))
|
||||||
|
.OrderByDescending(t => t.OccurredAt)
|
||||||
|
.Page(1, pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -197,8 +197,12 @@ public class EmailBackgroundService(
|
|||||||
message.TextBody ?? message.HtmlBody ?? string.Empty
|
message.TextBody ?? message.HtmlBody ?? string.Empty
|
||||||
))
|
))
|
||||||
{
|
{
|
||||||
|
#if DEBUG
|
||||||
|
logger.LogDebug("DEBUG 模式下,跳过标记已读步骤");
|
||||||
|
#else
|
||||||
// 标记邮件为已读
|
// 标记邮件为已读
|
||||||
await emailFetchService.MarkAsReadAsync(uid);
|
await emailFetchService.MarkAsReadAsync(uid);
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Service;
|
|||||||
public interface IOpenAiService
|
public interface IOpenAiService
|
||||||
{
|
{
|
||||||
Task<string?> ChatAsync(string systemPrompt, string userPrompt);
|
Task<string?> ChatAsync(string systemPrompt, string userPrompt);
|
||||||
|
IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt);
|
||||||
}
|
}
|
||||||
|
|
||||||
public class OpenAiService(
|
public class OpenAiService(
|
||||||
@@ -68,4 +69,79 @@ public class OpenAiService(
|
|||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt)
|
||||||
|
{
|
||||||
|
var cfg = aiSettings.Value;
|
||||||
|
if (string.IsNullOrWhiteSpace(cfg.Endpoint) ||
|
||||||
|
string.IsNullOrWhiteSpace(cfg.Key) ||
|
||||||
|
string.IsNullOrWhiteSpace(cfg.Model))
|
||||||
|
{
|
||||||
|
logger.LogWarning("未配置 OpenAI/DeepSeek 接口,无法调用 AI");
|
||||||
|
yield break;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var http = new HttpClient();
|
||||||
|
http.Timeout = TimeSpan.FromMinutes(5);
|
||||||
|
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", cfg.Key);
|
||||||
|
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
model = cfg.Model,
|
||||||
|
temperature = 0,
|
||||||
|
stream = true,
|
||||||
|
messages = new object[]
|
||||||
|
{
|
||||||
|
new { role = "system", content = systemPrompt },
|
||||||
|
new { role = "user", content = userPrompt }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var url = cfg.Endpoint.TrimEnd('/') + "/chat/completions";
|
||||||
|
var json = JsonSerializer.Serialize(payload);
|
||||||
|
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
// 使用 SendAsync 来支持 HttpCompletionOption
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, url)
|
||||||
|
{
|
||||||
|
Content = content
|
||||||
|
};
|
||||||
|
using var resp = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var err = await resp.Content.ReadAsStringAsync();
|
||||||
|
throw new InvalidOperationException($"AI接口调用失败: {(int)resp.StatusCode} {resp.ReasonPhrase}, {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var stream = await resp.Content.ReadAsStreamAsync();
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
|
string? line;
|
||||||
|
while ((line = await reader.ReadLineAsync()) != null)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: "))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var data = line.Substring(6).Trim();
|
||||||
|
if (data == "[DONE]")
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 解析JSON时不使用try-catch,因为在async iterator中不能使用
|
||||||
|
using var doc = JsonDocument.Parse(data);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
var delta = choices[0].GetProperty("delta");
|
||||||
|
if (delta.TryGetProperty("content", out var contentProp))
|
||||||
|
{
|
||||||
|
var contentText = contentProp.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(contentText))
|
||||||
|
{
|
||||||
|
yield return contentText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,3 +100,63 @@ export const getTransactionsByDate = (date) => {
|
|||||||
|
|
||||||
// 注意:分类相关的API已迁移到 transactionCategory.js
|
// 注意:分类相关的API已迁移到 transactionCategory.js
|
||||||
// 请使用 getCategoryTree 等新接口
|
// 请使用 getCategoryTree 等新接口
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取未分类的账单数量
|
||||||
|
* @returns {Promise<{success: boolean, data: number}>}
|
||||||
|
*/
|
||||||
|
export const getUnclassifiedCount = () => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionRecord/GetUnclassifiedCount',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取未分类的账单列表
|
||||||
|
* @param {number} pageSize - 每页数量,默认10条
|
||||||
|
* @returns {Promise<{success: boolean, data: Array}>}
|
||||||
|
*/
|
||||||
|
export const getUnclassified = (pageSize = 10) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionRecord/GetUnclassified',
|
||||||
|
method: 'get',
|
||||||
|
params: { pageSize }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 智能分类 - 使用AI对账单进行分类(EventSource流式响应)
|
||||||
|
* @param {number} pageSize - 每次分类的账单数量
|
||||||
|
* @returns {EventSource} 返回EventSource对象用于接收流式数据
|
||||||
|
*/
|
||||||
|
export const smartClassify = (pageSize = 10) => {
|
||||||
|
const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000'
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const url = `${baseURL}/api/TransactionRecord/SmartClassify`
|
||||||
|
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ pageSize })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新账单分类
|
||||||
|
* @param {Array} items - 要更新的账单分类数据数组
|
||||||
|
* @param {number} items[].id - 账单ID
|
||||||
|
* @param {string} items[].classify - 一级分类
|
||||||
|
* @param {string} items[].subClassify - 子分类
|
||||||
|
* @returns {Promise<{success: boolean, message: string}>}
|
||||||
|
*/
|
||||||
|
export const batchUpdateClassify = (items) => {
|
||||||
|
return request({
|
||||||
|
url: '/TransactionRecord/BatchUpdateClassify',
|
||||||
|
method: 'post',
|
||||||
|
data: items
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ const router = createRouter({
|
|||||||
component: () => import('../views/CalendarView.vue'),
|
component: () => import('../views/CalendarView.vue'),
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/smart-classification',
|
||||||
|
name: 'smart-classification',
|
||||||
|
component: () => import('../views/SmartClassification.vue'),
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
}
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<p>账单处理</p>
|
<p>账单处理</p>
|
||||||
</div>
|
</div>
|
||||||
<van-cell-group inset>
|
<van-cell-group inset>
|
||||||
<van-cell title="智能分类" is-link />
|
<van-cell title="智能分类" is-link @click="handleSmartClassification" />
|
||||||
</van-cell-group>
|
</van-cell-group>
|
||||||
|
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
@@ -101,6 +101,10 @@ const handleFileChange = async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSmartClassification = () => {
|
||||||
|
router.push({ name: 'smart-classification' })
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理退出登录
|
* 处理退出登录
|
||||||
*/
|
*/
|
||||||
|
|||||||
335
Web/src/views/SmartClassification.vue
Normal file
335
Web/src/views/SmartClassification.vue
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
<template>
|
||||||
|
<div class="smart-classification">
|
||||||
|
<van-nav-bar
|
||||||
|
title="智能分类"
|
||||||
|
left-text="返回"
|
||||||
|
left-arrow
|
||||||
|
style="position: fixed; top: 0; left: 0; right: 0; z-index: 100;"
|
||||||
|
@click-left="onClickLeft"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="container" style="padding-top: 46px;">
|
||||||
|
<!-- 统计信息 -->
|
||||||
|
<div class="stats-info">
|
||||||
|
<span class="stats-label">未分类账单:</span>
|
||||||
|
<span class="stats-value">{{ records.length }} / {{ unclassifiedCount }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 账单列表 -->
|
||||||
|
<TransactionList
|
||||||
|
:transactions="records"
|
||||||
|
:loading="false"
|
||||||
|
:finished="true"
|
||||||
|
:show-delete="false"
|
||||||
|
@click="viewDetail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 详情/编辑弹出层 -->
|
||||||
|
<TransactionDetail
|
||||||
|
v-model:show="detailVisible"
|
||||||
|
:transaction="currentTransaction"
|
||||||
|
@save="onDetailSave"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 底部操作按钮 -->
|
||||||
|
<div class="action-bar">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
:loading="classifying"
|
||||||
|
:disabled="records.length === 0"
|
||||||
|
@click="startClassify"
|
||||||
|
class="action-btn"
|
||||||
|
>
|
||||||
|
{{ classifying ? '分类中...' : '开始智能分类' }}
|
||||||
|
</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 classifying = ref(false)
|
||||||
|
const hasChanges = ref(false)
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const currentTransaction = ref(null)
|
||||||
|
|
||||||
|
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 () => {
|
||||||
|
const toast = showLoadingToast({
|
||||||
|
message: '加载中...',
|
||||||
|
forbidClick: true,
|
||||||
|
duration: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getUnclassified(10)
|
||||||
|
if (res.success) {
|
||||||
|
records.value = res.data
|
||||||
|
} else {
|
||||||
|
showToast(res.message || '加载失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载账单失败', error)
|
||||||
|
showToast('加载失败')
|
||||||
|
} finally {
|
||||||
|
closeToast()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始智能分类
|
||||||
|
const startClassify = async () => {
|
||||||
|
if (records.value.length === 0) {
|
||||||
|
showToast('没有需要分类的账单')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
classifying.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await smartClassify(10)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理SSE事件
|
||||||
|
const handleSSEEvent = (eventType, data) => {
|
||||||
|
if (eventType === 'data') {
|
||||||
|
// 尝试解析JSON数据并更新对应账单的分类
|
||||||
|
try {
|
||||||
|
// 累积JSON片段
|
||||||
|
if (!window.classifyBuffer) {
|
||||||
|
window.classifyBuffer = ''
|
||||||
|
}
|
||||||
|
window.classifyBuffer += data
|
||||||
|
|
||||||
|
// 尝试提取完整的JSON对象
|
||||||
|
const jsonMatches = window.classifyBuffer.match(/\{[^}]+\}/g)
|
||||||
|
if (jsonMatches) {
|
||||||
|
for (const jsonStr of jsonMatches) {
|
||||||
|
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 || ''
|
||||||
|
record.subClassify = result.subClassify || ''
|
||||||
|
hasChanges.value = true
|
||||||
|
}
|
||||||
|
// 移除已处理的JSON
|
||||||
|
window.classifyBuffer = window.classifyBuffer.replace(jsonStr, '')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 不是完整的JSON,继续累积
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析分类结果失败', error)
|
||||||
|
}
|
||||||
|
} else if (eventType === 'end') {
|
||||||
|
window.classifyBuffer = ''
|
||||||
|
showToast('分类完成')
|
||||||
|
} else if (eventType === 'error') {
|
||||||
|
window.classifyBuffer = ''
|
||||||
|
showToast(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存分类
|
||||||
|
const saveClassifications = async () => {
|
||||||
|
const itemsToUpdate = records.value
|
||||||
|
.filter(r => r.classify)
|
||||||
|
.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
classify: r.classify,
|
||||||
|
subClassify: r.subClassify
|
||||||
|
}))
|
||||||
|
|
||||||
|
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>
|
||||||
|
.smart-classification {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 统计信息 */
|
||||||
|
.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>
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
[Route("api/[controller]/[action]")]
|
[Route("api/[controller]/[action]")]
|
||||||
public class TransactionRecordController(
|
public class TransactionRecordController(
|
||||||
ITransactionRecordRepository transactionRepository,
|
ITransactionRecordRepository transactionRepository,
|
||||||
|
ITransactionCategoryRepository categoryRepository,
|
||||||
|
IOpenAiService openAiService,
|
||||||
ILogger<TransactionRecordController> logger
|
ILogger<TransactionRecordController> logger
|
||||||
) : ControllerBase
|
) : ControllerBase
|
||||||
{
|
{
|
||||||
@@ -264,6 +266,187 @@ public class TransactionRecordController(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取未分类的账单数量
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<BaseResponse<int>> GetUnclassifiedCountAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var count = await transactionRepository.GetUnclassifiedCountAsync();
|
||||||
|
return new BaseResponse<int>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取未分类账单数量失败");
|
||||||
|
return BaseResponse<int>.Fail($"获取未分类账单数量失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取未分类的账单列表
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<BaseResponse<List<Entity.TransactionRecord>>> GetUnclassifiedAsync([FromQuery] int pageSize = 10)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var records = await transactionRepository.GetUnclassifiedAsync(pageSize);
|
||||||
|
return new BaseResponse<List<Entity.TransactionRecord>>
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Data = records
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "获取未分类账单列表失败");
|
||||||
|
return BaseResponse<List<Entity.TransactionRecord>>.Fail($"获取未分类账单列表失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 智能分类 - 使用AI对账单进行分类(流式响应)
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task SmartClassifyAsync([FromBody] SmartClassifyRequest request)
|
||||||
|
{
|
||||||
|
Response.ContentType = "text/event-stream";
|
||||||
|
Response.Headers.Append("Cache-Control", "no-cache");
|
||||||
|
Response.Headers.Append("Connection", "keep-alive");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 获取要分类的账单
|
||||||
|
var records = await transactionRepository.GetUnclassifiedAsync(request.PageSize);
|
||||||
|
if (records.Count == 0)
|
||||||
|
{
|
||||||
|
await WriteEventAsync("error", "没有需要分类的账单");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有分类
|
||||||
|
var categories = await categoryRepository.GetAllAsync();
|
||||||
|
|
||||||
|
// 构建分类信息
|
||||||
|
var categoryInfo = string.Join("\n", categories
|
||||||
|
.Select(c =>
|
||||||
|
{
|
||||||
|
var children = categories.Where(x => x.ParentId == c.Id).ToList();
|
||||||
|
var childrenStr = children.Count > 0
|
||||||
|
? $",子分类:{string.Join("、", children.Select(x => x.Name))}"
|
||||||
|
: "";
|
||||||
|
return $"- {c.Name} ({(c.Type == TransactionType.Expense ? "支出" : "收入")}){childrenStr}";
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 构建账单信息
|
||||||
|
var billsInfo = string.Join("\n", records.Select((r, i) =>
|
||||||
|
$"{i + 1}. ID={r.Id}, 摘要={r.Reason}, 金额={r.Amount}, 类型={GetTypeName(r.Type)}"));
|
||||||
|
|
||||||
|
var systemPrompt = $@"你是一个专业的账单分类助手。请根据提供的账单信息和分类列表,为每个账单选择最合适的分类。
|
||||||
|
|
||||||
|
可用的分类列表:
|
||||||
|
{categoryInfo}
|
||||||
|
|
||||||
|
分类规则:
|
||||||
|
1. 根据账单的摘要和金额,选择最匹配的一级分类
|
||||||
|
2. 如果有合适的子分类,也要指定子分类
|
||||||
|
3. 如果无法确定分类,可以选择""其他""
|
||||||
|
|
||||||
|
请对每个账单进行分类,每次输出一个账单的分类结果,格式如下:
|
||||||
|
{{""id"": 账单ID, ""classify"": ""一级分类"", ""subClassify"": ""子分类""}}
|
||||||
|
|
||||||
|
只输出JSON,不要有其他文字说明。";
|
||||||
|
|
||||||
|
var userPrompt = $@"请为以下账单进行分类:
|
||||||
|
|
||||||
|
{billsInfo}
|
||||||
|
|
||||||
|
请逐个输出分类结果。";
|
||||||
|
|
||||||
|
// 流式调用AI
|
||||||
|
await WriteEventAsync("start", $"开始分类 {records.Count} 条账单");
|
||||||
|
|
||||||
|
await foreach (var chunk in openAiService.ChatStreamAsync(systemPrompt, userPrompt))
|
||||||
|
{
|
||||||
|
await WriteEventAsync("data", chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
await WriteEventAsync("end", "分类完成");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "智能分类失败");
|
||||||
|
await WriteEventAsync("error", $"智能分类失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量更新账单分类
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<BaseResponse> BatchUpdateClassifyAsync([FromBody] List<BatchUpdateClassifyItem> items)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var successCount = 0;
|
||||||
|
var failCount = 0;
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
var record = await transactionRepository.GetByIdAsync(item.Id);
|
||||||
|
if (record != null)
|
||||||
|
{
|
||||||
|
record.Classify = item.Classify ?? string.Empty;
|
||||||
|
record.SubClassify = item.SubClassify ?? string.Empty;
|
||||||
|
var success = await transactionRepository.UpdateAsync(record);
|
||||||
|
if (success)
|
||||||
|
successCount++;
|
||||||
|
else
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new BaseResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Message = $"批量更新完成,成功 {successCount} 条,失败 {failCount} 条"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "批量更新分类失败");
|
||||||
|
return BaseResponse.Fail($"批量更新分类失败: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteEventAsync(string eventType, string data)
|
||||||
|
{
|
||||||
|
var message = $"event: {eventType}\ndata: {data}\n\n";
|
||||||
|
await Response.WriteAsync(message);
|
||||||
|
await Response.Body.FlushAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetTypeName(TransactionType type)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
TransactionType.Expense => "支出",
|
||||||
|
TransactionType.Income => "收入",
|
||||||
|
TransactionType.None => "不计入收支",
|
||||||
|
_ => "未知"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,4 +482,20 @@ public record DailyStatisticsDto(
|
|||||||
string Date,
|
string Date,
|
||||||
int Count,
|
int Count,
|
||||||
decimal Amount
|
decimal Amount
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 智能分类请求DTO
|
||||||
|
/// </summary>
|
||||||
|
public record SmartClassifyRequest(
|
||||||
|
int PageSize = 10
|
||||||
|
);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 批量更新分类项DTO
|
||||||
|
/// </summary>
|
||||||
|
public record BatchUpdateClassifyItem(
|
||||||
|
long Id,
|
||||||
|
string? Classify,
|
||||||
|
string? SubClassify
|
||||||
);
|
);
|
||||||
@@ -7,8 +7,6 @@
|
|||||||
restart: always
|
restart: always
|
||||||
networks:
|
networks:
|
||||||
- all_in
|
- all_in
|
||||||
ports:
|
|
||||||
- 14904:8080
|
|
||||||
environment:
|
environment:
|
||||||
- ASPNETCORE_ENVIRONMENT=Production
|
- ASPNETCORE_ENVIRONMENT=Production
|
||||||
- ASPNETCORE_URLS=http://+:8080
|
- ASPNETCORE_URLS=http://+:8080
|
||||||
|
|||||||
Reference in New Issue
Block a user