添加智能分类功能,支持获取未分类账单数量和列表;实现AI分类逻辑;更新相关API和前端视图
This commit is contained in:
@@ -60,6 +60,19 @@ public interface ITransactionRecordRepository : IBaseRepository<TransactionRecor
|
||||
/// <param name="emailMessageId">邮件ID</param>
|
||||
/// <returns>交易记录列表</returns>
|
||||
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
|
||||
@@ -180,4 +193,20 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository<Tran
|
||||
.OrderBy(t => t.OccurredAt)
|
||||
.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
|
||||
))
|
||||
{
|
||||
#if DEBUG
|
||||
logger.LogDebug("DEBUG 模式下,跳过标记已读步骤");
|
||||
#else
|
||||
// 标记邮件为已读
|
||||
await emailFetchService.MarkAsReadAsync(uid);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Service;
|
||||
public interface IOpenAiService
|
||||
{
|
||||
Task<string?> ChatAsync(string systemPrompt, string userPrompt);
|
||||
IAsyncEnumerable<string> ChatStreamAsync(string systemPrompt, string userPrompt);
|
||||
}
|
||||
|
||||
public class OpenAiService(
|
||||
@@ -68,4 +69,79 @@ public class OpenAiService(
|
||||
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
|
||||
// 请使用 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'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/smart-classification',
|
||||
name: 'smart-classification',
|
||||
component: () => import('../views/SmartClassification.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
}
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<p>账单处理</p>
|
||||
</div>
|
||||
<van-cell-group inset>
|
||||
<van-cell title="智能分类" is-link />
|
||||
<van-cell title="智能分类" is-link @click="handleSmartClassification" />
|
||||
</van-cell-group>
|
||||
|
||||
<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]")]
|
||||
public class TransactionRecordController(
|
||||
ITransactionRecordRepository transactionRepository,
|
||||
ITransactionCategoryRepository categoryRepository,
|
||||
IOpenAiService openAiService,
|
||||
ILogger<TransactionRecordController> logger
|
||||
) : 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,
|
||||
int Count,
|
||||
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
|
||||
networks:
|
||||
- all_in
|
||||
ports:
|
||||
- 14904:8080
|
||||
environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- ASPNETCORE_URLS=http://+:8080
|
||||
|
||||
Reference in New Issue
Block a user