diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..5bbb269 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "vue3snippets.enable-compile-vue-file-on-did-save-code": false +} \ No newline at end of file diff --git a/Entity/EmailMessage.cs b/Entity/EmailMessage.cs index 877110e..3390b23 100644 --- a/Entity/EmailMessage.cs +++ b/Entity/EmailMessage.cs @@ -1,4 +1,6 @@ -namespace Entity; +using System.Security.Cryptography; + +namespace Entity; /// /// 邮件消息实体 @@ -29,4 +31,14 @@ public class EmailMessage : BaseEntity /// 邮件接收时间 /// public DateTime ReceivedDate { get; set; } + + public string Md5 { get; set; } = string.Empty; + + public string ComputeBodyHash() + { + using var md5 = MD5.Create(); + var inputBytes = System.Text.Encoding.UTF8.GetBytes(Body + HtmlBody); + var hashBytes = md5.ComputeHash(inputBytes); + return Convert.ToHexString(hashBytes); + } } diff --git a/Entity/TransactionCategory.cs b/Entity/TransactionCategory.cs index fcffe45..dca3517 100644 --- a/Entity/TransactionCategory.cs +++ b/Entity/TransactionCategory.cs @@ -1,7 +1,7 @@ namespace Entity; /// -/// 交易分类(层级结构:类型 -> 分类 -> 子分类) +/// 交易分类 /// public class TransactionCategory : BaseEntity { @@ -10,38 +10,8 @@ public class TransactionCategory : BaseEntity /// public string Name { get; set; } = string.Empty; - /// - /// 父分类ID(0表示顶级分类) - /// - public long ParentId { get; set; } - /// /// 交易类型(支出/收入) /// public TransactionType Type { get; set; } - - /// - /// 层级(1=类型级, 2=分类级, 3=子分类级) - /// - public int Level { get; set; } - - /// - /// 排序号 - /// - public int SortOrder { get; set; } - - /// - /// 图标(可选) - /// - public string? Icon { get; set; } - - /// - /// 是否启用 - /// - public bool IsEnabled { get; set; } = true; - - /// - /// 备注 - /// - public string? Remark { get; set; } } diff --git a/Entity/TransactionRecord.cs b/Entity/TransactionRecord.cs index 06c6760..df4e157 100644 --- a/Entity/TransactionRecord.cs +++ b/Entity/TransactionRecord.cs @@ -50,11 +50,6 @@ public class TransactionRecord : BaseEntity /// public string Classify { get; set; } = string.Empty; - /// - /// 交易子分类 - /// - public string SubClassify { get; set; } = string.Empty; - /// /// 导入编号 /// diff --git a/Repository/EmailMessageRepository.cs b/Repository/EmailMessageRepository.cs index 9dee5c1..ea1a09c 100644 --- a/Repository/EmailMessageRepository.cs +++ b/Repository/EmailMessageRepository.cs @@ -2,11 +2,7 @@ public interface IEmailMessageRepository : IBaseRepository { - Task ExistsAsync( - string from, - string subject, - DateTime receivedDate, - string body); + Task ExistsAsync(string md5); /// /// 分页获取邮件列表(游标分页) @@ -25,14 +21,10 @@ public interface IEmailMessageRepository : IBaseRepository public class EmailMessageRepository(IFreeSql freeSql) : BaseRepository(freeSql), IEmailMessageRepository { - public async Task ExistsAsync( - string from, - string subject, - DateTime receivedDate, - string body) + public async Task ExistsAsync(string md5) { return await FreeSql.Select() - .Where(m => m.From == from && m.Subject == subject && m.ReceivedDate == receivedDate && m.Body == body) + .Where(e => e.Md5 == md5) .FirstAsync(); } diff --git a/Repository/TransactionCategoryRepository.cs b/Repository/TransactionCategoryRepository.cs index 717ac77..f643d80 100644 --- a/Repository/TransactionCategoryRepository.cs +++ b/Repository/TransactionCategoryRepository.cs @@ -6,24 +6,14 @@ public interface ITransactionCategoryRepository : IBaseRepository { /// - /// 根据类型获取所有顶级分类(Level=2,即类型下的分类) + /// 根据类型获取所有分类 /// - Task> GetTopLevelCategoriesByTypeAsync(TransactionType type); + Task> GetCategoriesByTypeAsync(TransactionType type); /// - /// 根据父分类ID获取子分类 + /// 根据名称和类型查找分类(防止重复) /// - Task> GetChildCategoriesAsync(long parentId); - - /// - /// 获取完整的分类树(按类型) - /// - Task> GetCategoryTreeAsync(TransactionType? type = null); - - /// - /// 根据名称和父ID查找分类(防止重复) - /// - Task GetByNameAndParentAsync(string name, long parentId, TransactionType type); + Task GetByNameAndTypeAsync(string name, TransactionType type); /// /// 检查分类是否被使用 @@ -37,101 +27,23 @@ public interface ITransactionCategoryRepository : IBaseRepository(freeSql), ITransactionCategoryRepository { /// - /// 根据类型获取所有顶级分类(Level=2) + /// 根据类型获取所有分类 /// - public async Task> GetTopLevelCategoriesByTypeAsync(TransactionType type) + public async Task> GetCategoriesByTypeAsync(TransactionType type) { return await FreeSql.Select() - .Where(c => c.Type == type && c.Level == 2 && c.IsEnabled) - .OrderBy(c => c.SortOrder) + .Where(c => c.Type == type) .OrderBy(c => c.Name) .ToListAsync(); } /// - /// 根据父分类ID获取子分类 + /// 根据名称和类型查找分类 /// - public async Task> GetChildCategoriesAsync(long parentId) + public async Task GetByNameAndTypeAsync(string name, TransactionType type) { return await FreeSql.Select() - .Where(c => c.ParentId == parentId && c.IsEnabled) - .OrderBy(c => c.SortOrder) - .OrderBy(c => c.Name) - .ToListAsync(); - } - - /// - /// 获取完整的分类树 - /// - public async Task> GetCategoryTreeAsync(TransactionType? type = null) - { - var query = FreeSql.Select() - .Where(c => c.IsEnabled); - - if (type.HasValue) - { - query = query.Where(c => c.Type == type.Value); - } - - var allCategories = await query - .OrderBy(c => c.Type) - .OrderBy(c => c.SortOrder) - .OrderBy(c => c.Name) - .ToListAsync(); - - // 构建树形结构(Level 2为根节点,即各个分类) - var result = new List(); - var level2Categories = allCategories.Where(c => c.Level == 2).ToList(); - - foreach (var category in level2Categories) - { - var treeNode = new TransactionCategoryTreeDto - { - Id = category.Id, - Name = category.Name, - Type = category.Type, - Level = category.Level, - Icon = category.Icon, - Children = BuildChildrenTree(category.Id, allCategories) - }; - result.Add(treeNode); - } - - return result; - } - - /// - /// 递归构建子分类树 - /// - private List BuildChildrenTree(long parentId, List allCategories) - { - var children = allCategories.Where(c => c.ParentId == parentId).ToList(); - var result = new List(); - - foreach (var child in children) - { - var treeNode = new TransactionCategoryTreeDto - { - Id = child.Id, - Name = child.Name, - Type = child.Type, - Level = child.Level, - Icon = child.Icon, - Children = BuildChildrenTree(child.Id, allCategories) - }; - result.Add(treeNode); - } - - return result; - } - - /// - /// 根据名称和父ID查找分类 - /// - public async Task GetByNameAndParentAsync(string name, long parentId, TransactionType type) - { - return await FreeSql.Select() - .Where(c => c.Name == name && c.ParentId == parentId && c.Type == type) + .Where(c => c.Name == name && c.Type == type) .FirstAsync(); } @@ -140,35 +52,13 @@ public class TransactionCategoryRepository(IFreeSql freeSql) : BaseRepository public async Task IsCategoryInUseAsync(long categoryId) { - // 检查是否有交易记录使用此分类 var category = await GetByIdAsync(categoryId); if (category == null) return false; - // 根据层级检查不同的字段 - var count = category.Level switch - { - 2 => await FreeSql.Select() - .Where(r => r.Classify == category.Name && r.Type == category.Type) - .CountAsync(), - 3 => await FreeSql.Select() - .Where(r => r.SubClassify == category.Name && r.Type == category.Type) - .CountAsync(), - _ => 0 - }; + var count = await FreeSql.Select() + .Where(r => r.Classify == category.Name && r.Type == category.Type) + .CountAsync(); return count > 0; } } - -/// -/// 分类树DTO -/// -public class TransactionCategoryTreeDto -{ - public long Id { get; set; } - public string Name { get; set; } = string.Empty; - public TransactionType Type { get; set; } - public int Level { get; set; } - public string? Icon { get; set; } - public List Children { get; set; } = new(); -} diff --git a/Repository/TransactionRecordRepository.cs b/Repository/TransactionRecordRepository.cs index 94abdd2..535d3db 100644 --- a/Repository/TransactionRecordRepository.cs +++ b/Repository/TransactionRecordRepository.cs @@ -26,11 +26,6 @@ public interface ITransactionRecordRepository : IBaseRepository Task> GetDistinctClassifyAsync(); - /// - /// 获取所有不同的交易子分类 - /// - Task> GetDistinctSubClassifyAsync(); - /// /// 获取指定月份每天的消费统计 /// @@ -73,6 +68,30 @@ public interface ITransactionRecordRepository : IBaseRepository每页数量 /// 未分类账单列表 Task> GetUnclassifiedAsync(int pageSize = 10); + + /// + /// 获取按交易摘要(Reason)分组的统计信息(支持分页) + /// + /// 页码,从1开始 + /// 每页数量 + /// 分组统计列表和总数 + Task<(List list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20); + + /// + /// 按摘要批量更新交易记录的分类 + /// + /// 交易摘要 + /// 交易类型 + /// 分类名称 + /// 更新的记录数量 + Task BatchUpdateByReasonAsync(string reason, TransactionType type, string classify); + + /// + /// 根据关键词查询交易记录(模糊匹配Reason字段) + /// + /// 关键词 + /// 匹配的交易记录列表 + Task> QueryBySqlAsync(string sql); } public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository(freeSql), ITransactionRecordRepository @@ -100,7 +119,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository t.Reason.Contains(searchKeyword) || t.Classify.Contains(searchKeyword) || - t.SubClassify.Contains(searchKeyword) || t.Card.Contains(searchKeyword) || t.ImportFrom.Contains(searchKeyword)); } @@ -135,14 +153,6 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository t.Classify); } - public async Task> GetDistinctSubClassifyAsync() - { - return await FreeSql.Select() - .Where(t => !string.IsNullOrEmpty(t.SubClassify)) - .Distinct() - .ToListAsync(t => t.SubClassify); - } - public async Task> GetDailyStatisticsAsync(int year, int month) { var startDate = new DateTime(year, month, 1); @@ -209,4 +219,93 @@ public class TransactionRecordRepository(IFreeSql freeSql) : BaseRepository list, int total)> GetReasonGroupsAsync(int pageIndex = 1, int pageSize = 20) + { + // 先按照Reason分组,统计每个Reason的数量 + var groups = await FreeSql.Select() + .Where(t => !string.IsNullOrEmpty(t.Reason)) + .Where(t => string.IsNullOrEmpty(t.Classify)) // 只统计未分类的 + .GroupBy(t => t.Reason) + .ToListAsync(g => new + { + Reason = g.Key, + Count = g.Count() + }); + + // 按数量降序排序 + var sortedGroups = groups.OrderByDescending(g => g.Count).ToList(); + var total = sortedGroups.Count; + + // 分页 + var pagedGroups = sortedGroups + .Skip((pageIndex - 1) * pageSize) + .Take(pageSize) + .ToList(); + + // 为每个分组获取示例记录 + var result = new List(); + foreach (var group in pagedGroups) + { + var sample = await FreeSql.Select() + .Where(t => t.Reason == group.Reason) + .FirstAsync(); + + if (sample != null) + { + result.Add(new ReasonGroupDto + { + Reason = group.Reason, + Count = (int)group.Count, + SampleType = sample.Type, + SampleClassify = sample.Classify ?? string.Empty + }); + } + } + + return (result, total); + } + + public async Task BatchUpdateByReasonAsync(string reason, TransactionType type, string classify) + { + return await FreeSql.Update() + .Set(t => t.Type, type) + .Set(t => t.Classify, classify) + .Where(t => t.Reason == reason) + .ExecuteAffrowsAsync(); + } + + public async Task> QueryBySqlAsync(string sql) + { + return await FreeSql.Select() + .Where(sql) + .OrderByDescending(t => t.OccurredAt) + .ToListAsync(); + } +} + +/// +/// 按Reason分组统计DTO +/// +public class ReasonGroupDto +{ + /// + /// 交易摘要 + /// + public string Reason { get; set; } = string.Empty; + + /// + /// 该摘要的记录数量 + /// + public int Count { get; set; } + + /// + /// 示例交易类型(该分组中第一条记录的类型) + /// + public TransactionType SampleType { get; set; } + + /// + /// 示例分类(该分组中第一条记录的分类) + /// + public string SampleClassify { get; set; } = string.Empty; } \ No newline at end of file diff --git a/Service/EmailBackgroundService.cs b/Service/EmailBackgroundService.cs index 2c94137..29efd7a 100644 --- a/Service/EmailBackgroundService.cs +++ b/Service/EmailBackgroundService.cs @@ -195,7 +195,7 @@ public class EmailBackgroundService( message.Subject, message.Date.DateTime, message.TextBody ?? message.HtmlBody ?? string.Empty - )) + ) || (DateTime.Now - message.Date.DateTime > TimeSpan.FromDays(3))) { #if DEBUG logger.LogDebug("DEBUG 模式下,跳过标记已读步骤"); diff --git a/Service/EmailHandleService.cs b/Service/EmailHandleService.cs index 83c9a1f..fd256d5 100644 --- a/Service/EmailHandleService.cs +++ b/Service/EmailHandleService.cs @@ -29,24 +29,24 @@ public class EmailHandleService( string body ) { - var emailMessage = await SaveEmailAsync(from, subject, date, body); - - if (emailMessage == null) - { - throw new InvalidOperationException("邮件保存失败,无法继续处理"); - } - var filterForm = emailSettings.Value.FilterFromAddresses; if (filterForm.Length == 0) { logger.LogWarning("未配置邮件过滤条件,跳过账单处理"); - return true; + return false; } if (!filterForm.Any(f => from.Contains(f))) { logger.LogInformation("邮件不符合发件人过滤条件,跳过账单处理"); - return true; + return false; + } + + var emailMessage = await SaveEmailAsync(from, subject, date, body); + + if (emailMessage == null) + { + throw new InvalidOperationException("邮件保存失败,无法继续处理"); } var parsed = await ParseEmailBodyAsync( @@ -161,7 +161,6 @@ public class EmailHandleService( { From = from, Subject = subject, - ReceivedDate = date, }; @@ -177,13 +176,15 @@ public class EmailHandleService( try { - var existsEmail = await emailRepo.ExistsAsync(from, subject, date, body); + var emailMd5 = emailEntity.ComputeBodyHash(); + var existsEmail = await emailRepo.ExistsAsync(emailMd5); if (existsEmail != null) { logger.LogInformation("检测到重复邮件,跳过入库:{From} | {Subject} | {Date}", from, subject, date); return existsEmail; } + emailEntity.Md5 = emailMd5; var ok = await emailRepo.AddAsync(emailEntity); if (ok) { diff --git a/Web/src/App.vue b/Web/src/App.vue index 4a32b2f..26914e2 100644 --- a/Web/src/App.vue +++ b/Web/src/App.vue @@ -3,16 +3,19 @@
- + 日历 - + + 统计 + + 账单 - + 邮件 - + 设置 @@ -53,7 +56,8 @@ const showTabbar = computed(() => { return route.path === '/' || route.path === '/calendar' || route.path === '/email' || - route.path === '/setting' + route.path === '/setting' || + route.path === '/statistics' }) const active = ref(0) diff --git a/Web/src/api/transactionCategory.js b/Web/src/api/transactionCategory.js index 1c998fa..4736c33 100644 --- a/Web/src/api/transactionCategory.js +++ b/Web/src/api/transactionCategory.js @@ -1,44 +1,18 @@ import request from './request' /** - * 获取分类树(支持按类型筛选) + * 获取分类列表(支持按类型筛选) * @param {string|null} type - 交易类型(Expense=0/Income=1),null表示获取全部 * @returns {Promise<{success: boolean, data: Array}>} */ -export const getCategoryTree = (type = null) => { +export const getCategoryList = (type = null) => { return request({ - url: '/TransactionCategory/GetTree', + url: '/TransactionCategory/GetList', method: 'get', params: type !== null ? { type } : {} }) } -/** - * 获取顶级分类列表(按类型) - * @param {number} type - 交易类型(Expense=0/Income=1) - * @returns {Promise<{success: boolean, data: Array}>} - */ -export const getTopLevelCategories = (type) => { - return request({ - url: '/TransactionCategory/GetTopLevel', - method: 'get', - params: { type } - }) -} - -/** - * 获取子分类列表 - * @param {number} parentId - 父分类ID - * @returns {Promise<{success: boolean, data: Array}>} - */ -export const getChildCategories = (parentId) => { - return request({ - url: '/TransactionCategory/GetChildren', - method: 'get', - params: { parentId } - }) -} - /** * 根据ID获取分类详情 * @param {number} id - 分类ID @@ -53,7 +27,7 @@ export const getCategoryById = (id) => { /** * 创建分类 - * @param {object} data - 分类数据 + * @param {object} data - 分类数据 { name, type } * @returns {Promise<{success: boolean, data: number}>} 返回新创建的分类ID */ export const createCategory = (data) => { @@ -66,7 +40,7 @@ export const createCategory = (data) => { /** * 更新分类 - * @param {object} data - 分类数据 + * @param {object} data - 分类数据 { id, name } * @returns {Promise<{success: boolean}>} */ export const updateCategory = (data) => { @@ -92,7 +66,7 @@ export const deleteCategory = (id) => { /** * 批量创建分类(用于初始化) - * @param {Array} dataList - 分类数据数组 + * @param {Array} dataList - 分类数据数组 [{ name, type }, ...] * @returns {Promise<{success: boolean, data: number}>} 返回创建的数量 */ export const batchCreateCategories = (dataList) => { diff --git a/Web/src/api/transactionRecord.js b/Web/src/api/transactionRecord.js index 1e716b3..6d552e0 100644 --- a/Web/src/api/transactionRecord.js +++ b/Web/src/api/transactionRecord.js @@ -41,7 +41,6 @@ export const getTransactionDetail = (id) => { * @param {number} data.balance - 交易后余额 * @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支) * @param {string} data.classify - 交易分类 - * @param {string} data.subClassify - 交易子分类 * @returns {Promise<{success: boolean}>} */ export const createTransaction = (data) => { @@ -60,7 +59,6 @@ export const createTransaction = (data) => { * @param {number} data.balance - 交易后余额 * @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支) * @param {string} data.classify - 交易分类 - * @param {string} data.subClassify - 交易子分类 * @returns {Promise<{success: boolean}>} */ export const updateTransaction = (data) => { @@ -99,7 +97,7 @@ export const getTransactionsByDate = (date) => { // 注意:分类相关的API已迁移到 transactionCategory.js -// 请使用 getCategoryTree 等新接口 +// 请使用 getCategoryList 等新接口 /** * 获取未分类的账单数量 @@ -127,13 +125,13 @@ export const getUnclassified = (pageSize = 10) => { /** * 智能分类 - 使用AI对账单进行分类(EventSource流式响应) - * @param {number} pageSize - 每次分类的账单数量 - * @returns {EventSource} 返回EventSource对象用于接收流式数据 + * @param {Array} transactionIds - 要分类的账单ID列表 + * @returns {Promise} 返回响应对象用于接收流式数据 */ -export const smartClassify = (pageSize = 10) => { - const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000' +export const smartClassify = (transactionIds = []) => { + const baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5071/api' const token = localStorage.getItem('token') - const url = `${baseURL}/api/TransactionRecord/SmartClassify` + const url = `${baseURL}/TransactionRecord/SmartClassify` return fetch(url, { method: 'POST', @@ -141,7 +139,7 @@ export const smartClassify = (pageSize = 10) => { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify({ pageSize }) + body: JSON.stringify({ transactionIds }) }) } @@ -149,8 +147,7 @@ export const smartClassify = (pageSize = 10) => { * 批量更新账单分类 * @param {Array} items - 要更新的账单分类数据数组 * @param {number} items[].id - 账单ID - * @param {string} items[].classify - 一级分类 - * @param {string} items[].subClassify - 子分类 + * @param {string} items[].classify - 分类 * @returns {Promise<{success: boolean, message: string}>} */ export const batchUpdateClassify = (items) => { @@ -160,3 +157,46 @@ export const batchUpdateClassify = (items) => { data: items }) } + +/** + * 获取按交易摘要分组的统计信息(支持分页) + * @param {number} pageIndex - 页码,从1开始 + * @param {number} pageSize - 每页数量,默认20 + * @returns {Promise<{success: boolean, data: Array, total: number}>} + */ +export const getReasonGroups = (pageIndex = 1, pageSize = 20) => { + return request({ + url: '/TransactionRecord/GetReasonGroups', + method: 'get', + params: { pageIndex, pageSize } + }) +} + +/** + * 按摘要批量更新分类 + * @param {Object} data - 批量更新数据 + * @param {string} data.reason - 交易摘要 + * @param {number} data.type - 交易类型 (0:支出, 1:收入, 2:不计入收支) + * @param {string} data.classify - 分类名称 + * @returns {Promise<{success: boolean, data: number, message: string}>} + */ +export const batchUpdateByReason = (data) => { + return request({ + url: '/TransactionRecord/BatchUpdateByReason', + method: 'post', + data + }) +} + +/** + * NLP分析 - 根据用户自然语言输入查询交易记录并预设分类 + * @param {string} userInput - 用户的自然语言输入 + * @returns {Promise<{success: boolean, data: Object}>} + */ +export const nlpAnalysis = (userInput) => { + return request({ + url: '/TransactionRecord/NlpAnalysis', + method: 'post', + data: { userInput } + }) +} diff --git a/Web/src/components/TransactionDetail.vue b/Web/src/components/TransactionDetail.vue index 40782b9..4475a0b 100644 --- a/Web/src/components/TransactionDetail.vue +++ b/Web/src/components/TransactionDetail.vue @@ -57,24 +57,43 @@ @click="showTypePicker = true" :rules="[{ required: true, message: '请选择交易类型' }]" /> - - + + + + + +
+ + {{ item.text }} + + + + 新增 + + + 清空 + +
@@ -96,42 +115,6 @@ /> - - - - - - - - - - - - - - - - - - - \ No newline at end of file + diff --git a/Web/src/views/ClassificationBatch.vue b/Web/src/views/ClassificationBatch.vue new file mode 100644 index 0000000..1267385 --- /dev/null +++ b/Web/src/views/ClassificationBatch.vue @@ -0,0 +1,515 @@ + + + + + diff --git a/Web/src/views/ClassificationEdit.vue b/Web/src/views/ClassificationEdit.vue new file mode 100644 index 0000000..acced73 --- /dev/null +++ b/Web/src/views/ClassificationEdit.vue @@ -0,0 +1,338 @@ + + + + + + diff --git a/Web/src/views/ClassificationNLP.vue b/Web/src/views/ClassificationNLP.vue new file mode 100644 index 0000000..eee3ef3 --- /dev/null +++ b/Web/src/views/ClassificationNLP.vue @@ -0,0 +1,371 @@ + + + + + diff --git a/Web/src/views/SmartClassification.vue b/Web/src/views/ClassificationSmart.vue similarity index 71% rename from Web/src/views/SmartClassification.vue rename to Web/src/views/ClassificationSmart.vue index ba01000..687f849 100644 --- a/Web/src/views/SmartClassification.vue +++ b/Web/src/views/ClassificationSmart.vue @@ -7,7 +7,7 @@ @click-left="onClickLeft" /> -
+
未分类账单: @@ -18,9 +18,11 @@
@@ -36,11 +38,11 @@ - {{ classifying ? '分类中...' : '开始智能分类' }} + {{ classifying ? '分类中...' : `开始分类 (${selectedIds.size}/${records.length})` }} { if (hasChanges.value) { @@ -104,7 +108,7 @@ const loadUnclassifiedCount = async () => { // 加载未分类账单列表 const loadUnclassified = async () => { - const toast = showLoadingToast({ + showLoadingToast({ message: '加载中...', forbidClick: true, duration: 0 @@ -114,6 +118,8 @@ const loadUnclassified = async () => { 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 || '加载失败') } @@ -127,15 +133,24 @@ const loadUnclassified = async () => { // 开始智能分类 const startClassify = async () => { - if (records.value.length === 0) { - showToast('没有需要分类的账单') + 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(10) + const response = await smartClassify(idsToClassify) if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) @@ -173,49 +188,83 @@ const startClassify = async () => { showToast(`分类失败: ${error.message}`) } finally { classifying.value = false + classifyBuffer.value = '' + closeToast() } } // 处理SSE事件 const handleSSEEvent = (eventType, data) => { if (eventType === 'data') { - // 尝试解析JSON数据并更新对应账单的分类 try { - // 累积JSON片段 - if (!window.classifyBuffer) { - window.classifyBuffer = '' - } - window.classifyBuffer += data + // 累积AI输出的JSON片段 + classifyBuffer.value += data - // 尝试提取完整的JSON对象 - const jsonMatches = window.classifyBuffer.match(/\{[^}]+\}/g) - if (jsonMatches) { - for (const jsonStr of jsonMatches) { + // 尝试查找并提取完整的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 || '' - record.subClassify = result.subClassify || '' + // 如果AI返回了type字段,也更新type + if (result.type !== undefined && result.type !== null) { + record.type = result.type + } hasChanges.value = true } - // 移除已处理的JSON - window.classifyBuffer = window.classifyBuffer.replace(jsonStr, '') } } catch (e) { - // 不是完整的JSON,继续累积 + 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') { - window.classifyBuffer = '' + classifyBuffer.value = '' showToast('分类完成') } else if (eventType === 'error') { - window.classifyBuffer = '' + classifyBuffer.value = '' showToast(data) } } @@ -227,7 +276,7 @@ const saveClassifications = async () => { .map(r => ({ id: r.id, classify: r.classify, - subClassify: r.subClassify + type: r.type })) if (itemsToUpdate.length === 0) { diff --git a/Web/src/views/EmailRecord.vue b/Web/src/views/EmailRecord.vue index 2fa270b..36a6b53 100644 --- a/Web/src/views/EmailRecord.vue +++ b/Web/src/views/EmailRecord.vue @@ -415,8 +415,6 @@ onMounted(() => {