feat: 交易渠道重命名、账单来源统计默认值、AI 规则生成与统计搜索,AI 改用 openai-java SDK

- 交易记录「支付方式」全链路重命名为「交易渠道」(channel):DB 列、实体/DTO/VO、接口 /api/transaction/channels、统计维度 CHANNEL、AI 提示词与 JSON 字段、前端与中英日文案;新增 SchemaMigrator 在启动/切换数据源时幂等迁移列名(MySQL 用 information_schema、SQLite 用 PRAGMA)
- 按账单来源设置「计入统计」默认值:AppConfig 持久化 + GET/PUT /api/app/bill-source-include-in-stats,导入解析按「解析器判定 && 来源默认」应用并覆盖上传与邮箱两条路径,设置页新增面板
- AI 生成规则:基于所选已打标签记录归纳分类/标签规则(POST /api/ai/rule/suggest、/api/ai/rule/apply),规则页两步弹窗(选择记录→预览创建),支持候选记录分页/搜索/筛选;按「类型+关键词+分类+标签」签名与已有规则去重,重复项标记「已存在」并禁止导入
- 规则列表新增分页与搜索:GET /api/rule/page(名称/关键词、启用状态、收支类型)
- 统计面板 AI 搜索:POST /api/ai/statistics 将自然语言解析为时间/类型/分类/标签/渠道/金额/统计口径/趋势粒度并回填;交互对齐账单列表 AI 查询(弹窗 + 历史 + 示例 + 空条件守卫);统计后端补充标签筛选,仪表盘新增标签筛选与 AI 搜索
- AI 客户端改用 OpenAI 官方 Java SDK(com.openai:openai-java),自定义 baseUrl 指向 DeepSeek,保留 chat 签名与 DeepSeekResponse
- AI 数据外发收敛:分类/标签建议与规则生成不再发送 rawData 与金额,仅发送类型/备注/渠道/分类/标签;新增 AiCallType.RULE / STATISTICS
- 收支记录页新增「刷新」按钮
This commit is contained in:
2026-09-20 17:13:24 +08:00
parent 0a8811534b
commit 2976b85967
58 changed files with 3347 additions and 345 deletions
+75 -4
View File
@@ -16,7 +16,7 @@ export interface AiNewTag {
color: string
}
/** 单条 AI 建议(按类型、备注、支付方式聚合后的一组记录) */
/** 单条 AI 建议(按类型、备注、交易渠道聚合后的一组记录) */
export interface AiSuggestion {
transactionId: number
transactionIds: number[]
@@ -78,7 +78,7 @@ export function parseTagByAi(text: string) {
}
/** 根据表单当前内容生成单条分类与标签建议(调用大模型,超时放宽) */
export function suggestOneByAi(data: { type: string; amount?: number | null; paymentMethod?: string; remark?: string }) {
export function suggestOneByAi(data: { type: string; amount?: number | null; channel?: string; remark?: string }) {
return request.post('/ai/suggest-one', data, { timeout: 60000 })
}
@@ -92,6 +92,77 @@ export function suggestColor(name: string) {
return request.post('/ai/color', { name }, { timeout: 60000 })
}
/** AI 生成的规则建议 */
export interface AiRuleSuggestion {
name: string
type: string | null
keyword: string | null
minAmount: number | null
maxAmount: number | null
categoryId: number | null
categoryName: string | null
newCategoryName: string | null
newCategoryIcon: string | null
tagIds: number[]
tagNames: string[]
newTags: AiNewTag[]
reason: string
/** 是否与已有规则重复:重复时默认不勾选且禁止导入 */
duplicate: boolean
}
/** AI 生成规则的结果 */
export interface AiRuleSuggestResult {
summary: string
rules: AiRuleSuggestion[]
}
/** AI 统计查询解析结果 */
export interface AiStatisticsResult {
summary: string
types: string[]
categoryIds: number[]
categoryNames: string[]
tagIds: number[]
tagNames: string[]
channels: string[]
startTime: string | null
endTime: string | null
minAmount: number | null
maxAmount: number | null
remark: string | null
statsScope: 'INCLUDED' | 'ALL' | 'EXCLUDED' | null
dimension: 'day' | 'week' | 'month' | 'quarter' | 'year' | null
unmatched: string[]
}
/** 将自然语言统计查询解析为统计面板参数(调用大模型,超时放宽) */
export function parseStatisticsByAi(query: string) {
return request.post('/ai/statistics', { query }, { timeout: 120000 })
}
/** 根据选中的已打标签记录生成规则建议(调用大模型,超时放宽) */
export function suggestRulesByAi(transactionIds: number[]) {
return request.post('/ai/rule/suggest', { transactionIds }, { timeout: 120000 })
}
/** 批量创建 AI 生成的规则,必要时新建分类与标签 */
export function applyAiRules(rules: {
name: string
enabled?: boolean
type?: string | null
keyword?: string | null
minAmount?: number | null
maxAmount?: number | null
categoryId?: number | null
newCategoryName?: string | null
newCategoryIcon?: string | null
tagIds?: number[]
newTags?: AiNewTag[]
}[]) {
return request.post('/ai/rule/apply', { rules })
}
/** 一键应用建议:覆盖分类并追加标签,标记为新建的分类/标签会先创建再关联 */
export function applyAiSuggestions(suggestions: {
transactionId: number
@@ -111,7 +182,7 @@ export interface AiQueryResult {
categoryIds: number[]
categoryNames: string[]
uncategorized: boolean
paymentMethods: string[]
channels: string[]
startTime: string | null
endTime: string | null
includeInStats: boolean | null
@@ -135,7 +206,7 @@ export interface AiRecordResult {
amount: number | null
categoryId: number | null
categoryName: string | null
paymentMethod: string | null
channel: string | null
transactionTime: string | null
remark: string | null
includeInStats: boolean | null
+12
View File
@@ -0,0 +1,12 @@
// 应用级配置接口:按账单来源的「计入统计」默认值
import request from './request'
/** 查询按账单来源的「计入统计」默认值(key 为账单来源编码) */
export function getBillSourceIncludeInStats() {
return request.get('/app/bill-source-include-in-stats')
}
/** 更新按账单来源的「计入统计」默认值 */
export function updateBillSourceIncludeInStats(defaults: Record<string, boolean>) {
return request.put('/app/bill-source-include-in-stats', { defaults })
}
+14
View File
@@ -39,11 +39,25 @@ export interface RulePayload {
tagIds?: number[]
}
/** 规则分页查询参数 */
export interface RulePageParams {
page?: number
size?: number
keyword?: string
enabled?: boolean
type?: string
}
/** 获取规则列表 */
export function getRuleList() {
return request.get('/rule/list')
}
/** 分页查询规则(按名称/关键词、启用状态、收支类型筛选) */
export function getRulePage(params: RulePageParams) {
return request.get('/rule/page', { params })
}
/** 创建规则 */
export function createRule(data: RulePayload) {
return request.post('/rule', data)
+3 -2
View File
@@ -23,8 +23,9 @@ export interface StatisticsFilterParams {
types?: string[]
categoryIds?: number[]
uncategorized?: boolean
tagIds?: number[]
untagged?: boolean
paymentMethods?: string[]
channels?: string[]
minAmount?: number
maxAmount?: number
remark?: string
@@ -37,7 +38,7 @@ export function getStatisticsSummary(params: StatisticsFilterParams) {
return request.get('/statistics/summary', { params })
}
/** 按维度获取分组统计数据(dimension:day/week/month/quarter/year/paymentMethod/category/type) */
/** 按维度获取分组统计数据(dimension:day/week/month/quarter/year/channel/category/type) */
export function getStatisticsGroup(params: StatisticsFilterParams & { dimension: string }) {
return request.get('/statistics/group', { params })
}
+9 -8
View File
@@ -1,19 +1,19 @@
// 收支记录相关 API 接口:交易记录的增删改查(分页)
import request from './request'
/** 获取系统支持的支付方式编码列表(含账单导入来源与人工支付方式) */
export function getPaymentMethods() {
return request.get('/transaction/payment-methods')
/** 获取系统支持的交易渠道编码列表(含账单导入来源与人工交易渠道) */
export function getChannels() {
return request.get('/transaction/channels')
}
/** 分页获取交易记录列表,支持按类型、分类、支付方式、时间范围、计入统计、金额区间多选筛选 */
/** 分页获取交易记录列表,支持按类型、分类、交易渠道、时间范围、计入统计、金额区间多选筛选 */
export function getTransactionPage(params: {
page?: number
size?: number
types?: string[]
categoryIds?: number[]
uncategorized?: boolean
paymentMethods?: string[]
channels?: string[]
startTime?: string
endTime?: string
includeInStats?: boolean
@@ -24,6 +24,7 @@ export function getTransactionPage(params: {
sortOrder?: string
tagIds?: number[]
untagged?: boolean
tagged?: boolean
}) {
return request.get('/transaction/page', { params })
}
@@ -33,7 +34,7 @@ export function createTransaction(data: {
categoryId: number
type: string
amount: number
paymentMethod?: string
channel?: string
remark?: string
transactionTime: string
includeInStats?: boolean
@@ -47,7 +48,7 @@ export function updateTransaction(id: number, data: {
categoryId: number | null
type: string
amount: number
paymentMethod?: string
channel?: string
remark?: string
transactionTime: string
includeInStats?: boolean
@@ -163,7 +164,7 @@ export function exportTransaction(params: {
types?: string[]
categoryIds?: number[]
uncategorized?: boolean
paymentMethods?: string[]
channels?: string[]
startTime?: string
endTime?: string
includeInStats?: boolean
@@ -0,0 +1,90 @@
<script setup lang="ts">
// 账单来源统计默认值面板:按账单来源配置导入时「是否计入统计」的默认值
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NCard, NSpace, NSwitch, NButton, NIcon, NText,
} from 'naive-ui'
import { OptionsOutline, SaveOutline } from '@vicons/ionicons5'
import { getBillSourceIncludeInStats, updateBillSourceIncludeInStats } from '../api/app'
import { getChannelLabel } from '../utils/channel'
const message = useMessage()
const { t } = useI18n()
const defaults = ref<Record<string, boolean>>({})
const loading = ref(false)
const saving = ref(false)
/** 加载各账单来源的默认值并回显 */
async function loadDefaults() {
loading.value = true
try {
const res: any = await getBillSourceIncludeInStats()
defaults.value = { ...(res.data || {}) }
} catch (e: any) {
message.error(e.message || t('billSourceStats.loadFailed'))
} finally {
loading.value = false
}
}
/** 保存各账单来源的默认值 */
async function handleSave() {
saving.value = true
try {
const res: any = await updateBillSourceIncludeInStats(defaults.value)
defaults.value = { ...(res.data || {}) }
message.success(t('billSourceStats.saveSuccess'))
} catch (e: any) {
message.error(e.message || t('billSourceStats.saveFailed'))
} finally {
saving.value = false
}
}
onMounted(loadDefaults)
</script>
<template>
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="OptionsOutline" />
<span>{{ t('settings.billSourceStatsSection') }}</span>
</NSpace>
</template>
<NText depth="3" style="font-size: 12px;">{{ t('billSourceStats.hint') }}</NText>
<div class="source-list">
<div v-for="(value, code) in defaults" :key="code" class="source-row">
<NSpace align="center" :size="12">
<NSwitch v-model:value="defaults[code]" :disabled="loading" />
<span>{{ getChannelLabel(String(code)) }}</span>
<NText depth="3" style="font-size: 12px;">
{{ value ? t('billSourceStats.included') : t('billSourceStats.excluded') }}
</NText>
</NSpace>
</div>
</div>
<NSpace justify="end" style="margin-top: 16px; width: 100%;">
<NButton type="primary" :loading="saving" @click="handleSave">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('billSourceStats.save') }}
</NButton>
</NSpace>
</NCard>
</template>
<style scoped>
.source-list {
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
</style>
+55 -7
View File
@@ -28,11 +28,12 @@ export default {
deleteSuccess: { zh: '删除成功', en: 'Deleted successfully', ja: '削除しました' },
deleteFailed: { zh: '删除失败', en: 'Failed to delete', ja: '削除に失敗しました' },
confirmDelete: { zh: '确认删除', en: 'Confirm Deletion', ja: '削除の確認' },
refresh: { zh: '刷新', en: 'Refresh', ja: '更新' },
types: {
expense: { zh: '支出', en: 'Expense', ja: '支出' },
income: { zh: '收入', en: 'Income', ja: '収入' },
},
payment: {
channel: {
wechat: { zh: '微信', en: 'WeChat', ja: 'WeChat' },
alipay: { zh: '支付宝', en: 'Alipay', ja: 'Alipay' },
cash: { zh: '现金', en: 'Cash', ja: '現金' },
@@ -135,9 +136,21 @@ export default {
aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' },
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
billSourceStatsSection: { zh: '账单来源统计默认值', en: 'Bill Source Stats Defaults', ja: '請求元の集計既定値' },
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
},
// 账单来源统计默认值
billSourceStats: {
hint: { zh: '设置各账单来源导入时是否默认计入统计;不计入的记录仍会导入,但不参与统计', en: 'Set whether bills from each source count toward statistics by default. Excluded records are still imported but not counted.', ja: '各請求元の取り込み時に集計へ含めるかを設定します。除外した記録も取り込みますが集計対象外になります。' },
included: { zh: '计入统计', en: 'Included', ja: '集計対象' },
excluded: { zh: '不计入统计', en: 'Excluded', ja: '集計対象外' },
save: { zh: '保存', en: 'Save', ja: '保存' },
loadFailed: { zh: '加载账单来源统计默认值失败', en: 'Failed to load bill source stats defaults', ja: '請求元の集計既定値の読み込みに失敗しました' },
saveSuccess: { zh: '账单来源统计默认值已保存', en: 'Bill source stats defaults saved', ja: '請求元の集計既定値を保存しました' },
saveFailed: { zh: '保存账单来源统计默认值失败', en: 'Failed to save bill source stats defaults', ja: '請求元の集計既定値の保存に失敗しました' },
},
// 注册设置
registration: {
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
@@ -295,7 +308,16 @@ export default {
},
filterType: { zh: '类型', en: 'Type', ja: '種類' },
filterCategory: { zh: '分类', en: 'Category', ja: 'カテゴリ' },
filterPaymentMethod: { zh: '支付方式', en: 'Payment Method', ja: '支払方法' },
filterChannel: { zh: '交易渠道', en: 'Channel', ja: '取引チャネル' },
filterTag: { zh: '标签', en: 'Tag', ja: 'タグ' },
aiSearchPlaceholder: { zh: '用一句话描述统计需求,如「上个月餐饮花了多少」', en: 'Describe what to analyze, e.g. "How much did I spend on dining last month?"', ja: '集計したい内容を文章で入力(例:先月の飲食費は?)' },
aiSearchButton: { zh: 'AI 搜索', en: 'AI Search', ja: 'AI 検索' },
aiSearchEmpty: { zh: '请输入统计需求', en: 'Please enter a query', ja: '検索内容を入力してください' },
aiSearchApplied: { zh: '已按 AI 解析结果更新统计', en: 'Statistics updated from AI parsing', ja: 'AI の解析結果で統計を更新しました' },
aiSearchFailed: { zh: 'AI 统计搜索失败', en: 'AI statistics search failed', ja: 'AI 統計検索に失敗しました' },
aiExample1: { zh: '上个月餐饮花了多少', en: 'How much on dining last month?', ja: '先月の飲食費は?' },
aiExample2: { zh: '近半年交通支出趋势,按周看', en: 'Transport spending trend over the last 6 months, weekly', ja: '直近半年の交通費の推移を週別で' },
aiExample3: { zh: '今年各类支出占比', en: 'Expense breakdown by category this year', ja: '今年のカテゴリ別支出割合' },
minAmount: { zh: '最小金额', en: 'Min Amount', ja: '最小金額' },
maxAmount: { zh: '最大金额', en: 'Max Amount', ja: '最大金額' },
remark: { zh: '备注搜索', en: 'Search Remark', ja: '備考検索' },
@@ -311,8 +333,8 @@ export default {
trend: { zh: '收支趋势', en: 'Income & Expense Trend', ja: '収支の推移' },
expenseCategoryPie: { zh: '支出分类占比', en: 'Expense by Category', ja: '支出カテゴリの割合' },
incomeCategoryPie: { zh: '收入分类占比', en: 'Income by Category', ja: '収入カテゴリの割合' },
expensePaymentPie: { zh: '支出支付方式占比', en: 'Expense by Payment Method', ja: '支出の支払方法の割合' },
incomePaymentPie: { zh: '收入支付方式占比', en: 'Income by Payment Method', ja: '収入の支払方法の割合' },
expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' },
incomeChannelPie: { zh: '收入交易渠道占比', en: 'Income by Channel', ja: '収入の取引チャネル割合' },
uncategorized: { zh: '未分类', en: 'Uncategorized', ja: '未分類' },
},
@@ -349,7 +371,7 @@ export default {
type: { zh: '类型', en: 'Type', ja: '種類' },
category: { zh: '分类', en: 'Category', ja: 'カテゴリ' },
amount: { zh: '金额', en: 'Amount', ja: '金額' },
paymentMethod: { zh: '支付方式', en: 'Payment', ja: '支払方法' },
channel: { zh: '交易渠道', en: 'Channel', ja: '取引チャネル' },
remark: { zh: '备注', en: 'Remark', ja: '備考' },
tags: { zh: '标签', en: 'Tags', ja: 'タグ' },
includeInStats: { zh: '计入统计', en: 'In Stats', ja: '集計対象' },
@@ -391,7 +413,7 @@ export default {
validAmount: { zh: '请输入有效金额', en: 'Please enter a valid amount', ja: '有効な金額を入力してください' },
selectCategoryPlaceholder: { zh: '请选择分类', en: 'Select a category', ja: 'カテゴリを選択' },
amountPlaceholder: { zh: '请输入金额', en: 'Enter amount', ja: '金額を入力してください' },
paymentPlaceholder: { zh: '选择支付方式', en: 'Select payment method', ja: '支払方法を選択' },
channelPlaceholder: { zh: '选择交易渠道', en: 'Select channel', ja: '取引チャネルを選択' },
remarkPlaceholder: { zh: '可选备注说明', en: 'Optional remark', ja: '備考(任意)' },
remarkHistoryClear: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' },
tagPlaceholder: { zh: '选择标签', en: 'Select tags', ja: 'タグを選択' },
@@ -480,6 +502,10 @@ export default {
deleteConfirm: { zh: '确定要删除这个规则吗?', en: 'Are you sure you want to delete this rule?', ja: 'このルールを削除してもよろしいですか?' },
loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories', ja: 'カテゴリの読み込みに失敗しました' },
loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags', ja: 'タグの読み込みに失敗しました' },
searchPlaceholder: { zh: '搜索规则名称或关键词', en: 'Search name or keyword', ja: 'ルール名またはキーワードを検索' },
status: { zh: '启用状态', en: 'Status', ja: '有効状態' },
statusOn: { zh: '已启用', en: 'Enabled', ja: '有効' },
statusOff: { zh: '已停用', en: 'Disabled', ja: '無効' },
},
// 账单导入
@@ -601,6 +627,28 @@ export default {
nameRequired: { zh: '请先输入名称', en: 'Please enter a name first', ja: '先に名前を入力してください' },
failed: { zh: 'AI 推荐失败', en: 'AI recommendation failed', ja: 'AI の提案に失敗しました' },
},
rule: {
entry: { zh: 'AI 生成规则', en: 'AI Generate Rules', ja: 'AI でルール生成' },
title: { zh: 'AI 生成规则建议', en: 'AI Rule Suggestions', ja: 'AI ルール提案' },
hint: { zh: 'AI 将依据所选记录的已有标签与分类,生成分类与标签规则建议,可编辑后勾选创建', en: 'AI derives rules from the tags and categories of the selected records; edit and select to create.', ja: 'AI が選択した記録の既存タグとカテゴリからルールを提案します。編集して選択後に作成できます。' },
selectHint: { zh: '选择要用于生成规则的已打标签记录(默认全选当前页,已选 {count} 条)', en: 'Select tagged records to derive rules (current page selected by default, {count} selected)', ja: 'ルール生成に使うタグ付き記録を選択(既定で現在ページを全選択、選択中 {count} 件)' },
searchPlaceholder: { zh: '搜索备注', en: 'Search remark', ja: '備考を検索' },
selectPageAll: { zh: '全选本页', en: 'Select page', ja: 'このページを全選択' },
clearSelection: { zh: '清空选择', en: 'Clear selection', ja: '選択をクリア' },
generate: { zh: '生成规则', en: 'Generate rules', ja: 'ルールを生成' },
back: { zh: '返回上一步', en: 'Back', ja: '戻る' },
noTagged: { zh: '暂无可用的已打标签记录', en: 'No tagged records available', ja: 'タグ付きの記録がありません' },
loadRecordsFailed: { zh: '加载已打标签记录失败', en: 'Failed to load tagged records', ja: 'タグ付き記録の読み込みに失敗しました' },
reason: { zh: '理由', en: 'Reason', ja: '理由' },
newPrefix: { zh: '新建:', en: 'New: ', ja: '新規:' },
apply: { zh: '创建勾选规则', en: 'Create selected', ja: '選択したルールを作成' },
selectRequired: { zh: '请至少勾选一条记录或规则', en: 'Please select at least one record or rule', ja: '少なくとも1件の記録またはルールを選択してください' },
applied: { zh: '已创建 {count} 条规则', en: 'Created {count} rule(s)', ja: '{count} 件のルールを作成しました' },
failed: { zh: 'AI 规则生成失败', en: 'Failed to generate AI rules', ja: 'AI ルールの生成に失敗しました' },
applyFailed: { zh: '创建规则失败', en: 'Failed to create rules', ja: 'ルールの作成に失敗しました' },
duplicate: { zh: '已存在', en: 'Exists', ja: '既存' },
allDuplicate: { zh: '生成的规则均已存在,无需创建', en: 'All generated rules already exist', ja: '生成されたルールはすべて既存です' },
},
category: {
label: { zh: 'AI 生成分类', en: 'AI Category', ja: 'AI 生成カテゴリ' },
placeholder: { zh: '描述用途,如:日常通勤打车地铁', en: 'Describe it, e.g. daily commute by taxi and metro', ja: '用途を説明してください(例:日常の通勤でタクシーと地下鉄)' },
@@ -643,7 +691,7 @@ export default {
type: { zh: '类型', en: 'Type', ja: '種類' },
category: { zh: '分类', en: 'Category', ja: 'カテゴリ' },
tag: { zh: '标签', en: 'Tag', ja: 'タグ' },
payment: { zh: '支付方式', en: 'Payment', ja: '支払方法' },
channel: { zh: '交易渠道', en: 'Channel', ja: '取引チャネル' },
dateRange: { zh: '时间范围', en: 'Date range', ja: '期間' },
amountRange: { zh: '金额范围', en: 'Amount range', ja: '金額範囲' },
remark: { zh: '备注', en: 'Remark', ja: '備考' },
@@ -1,14 +1,14 @@
// 支付方式相关工具:将后端返回的支付方式编码映射为当前语言标签
// 交易渠道相关工具:将后端返回的交易渠道编码映射为当前语言标签
import i18n from '../locales'
/** i18n 全局实例的翻译函数签名(避免动态 key 触发过深的类型推断) */
type Translate = (key: string) => string
type HasKey = (key: string) => boolean
/** 将支付方式编码转换为当前语言的标签,无对应文案时回退为原始编码 */
export function getPaymentLabel(val: string): string {
/** 将交易渠道编码转换为当前语言的标签,无对应文案时回退为原始编码 */
export function getChannelLabel(val: string): string {
if (!val) return '-'
const { t, te } = i18n.global as unknown as { t: Translate; te: HasKey }
const key = `common.payment.${val.toLowerCase()}`
const key = `common.channel.${val.toLowerCase()}`
return te(key) ? t(key) : val
}
+280 -50
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与交易渠道分布
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton, NAutoComplete, NModal, NAlert, NDropdown, NIcon, useMessage } from 'naive-ui'
import { SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
@@ -10,14 +11,19 @@ import { LineChart, PieChart, BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
import { getCategoryList } from '../api/category'
import { getPaymentMethods } from '../api/transaction'
import { getPaymentLabel } from '../utils/payment'
import { getTagList } from '../api/tag'
import { getChannels } from '../api/transaction'
import { getAiConfig, parseStatisticsByAi } from '../api/ai'
import type { AiStatisticsResult } from '../api/ai'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render'
// 注册 ECharts 所需组件
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
const { t, locale } = useI18n() // 国际化
const message = useMessage() // 消息提示
// 时间范围快捷选项
const rangeOptions = computed(() => [
@@ -43,30 +49,60 @@ const granularity = ref<'day' | 'week' | 'month' | 'quarter' | 'year'>('day') //
const filterStatsScope = ref<'INCLUDED' | 'ALL' | 'EXCLUDED'>('INCLUDED') // 统计口径
const filterType = ref<string[]>([]) // 类型(多选)
const filterCategory = ref<number[]>([]) // 分类(多选,0 表示未分类)
const filterPaymentMethod = ref<string[]>([]) // 支付方式(多选)
const filterChannel = ref<string[]>([]) // 交易渠道(多选)
const filterTag = ref<number[]>([]) // 标签(多选)
const filterMinAmount = ref<number | null>(null) // 最小金额
const filterMaxAmount = ref<number | null>(null) // 最大金额
const filterRemark = ref('') // 备注关键字
// AI 统计搜索状态
const aiEnabled = ref(false)
const aiQueryText = ref('')
const aiQuerying = ref(false)
const aiSearchResult = ref<AiStatisticsResult | null>(null)
const showAiSearchModal = ref(false)
// AI 搜索示例(下拉选择后填入输入框)
const aiExampleOptions = computed(() => [
{ label: t('dashboard.aiExample1'), key: t('dashboard.aiExample1') },
{ label: t('dashboard.aiExample2'), key: t('dashboard.aiExample2') },
{ label: t('dashboard.aiExample3'), key: t('dashboard.aiExample3') },
])
// AI 搜索历史记录(与账单列表一致:持久化、聚焦展示、可清空)
const aiSearchHistory = useKeywordHistory({
storageKey: 'finance.dashboard.aiSearchHistory',
text: aiQueryText,
clearLabel: () => t('ai.query.clearHistory'),
})
// 统计口径选项:仅计入 / 全部 / 仅不计入
const statsScopeOptions = computed(() => [
{ label: t('dashboard.statsScope.included'), value: 'INCLUDED' },
{ label: t('dashboard.statsScope.all'), value: 'ALL' },
{ label: t('dashboard.statsScope.excluded'), value: 'EXCLUDED' },
])
// 类型与支付方式选项
// 类型与交易渠道选项
const typeOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
// 支付方式编码列表(从后端动态获取,含账单导入来源与人工支付方式)
const paymentMethodCodes = ref<string[]>([])
// 支付方式选项:由后端返回的编码映射为当前语言标签,locale 变化时重新生成
const paymentOptions = computed(() => {
// 交易渠道编码列表(从后端动态获取,含账单导入来源与人工交易渠道)
const channelCodes = ref<string[]>([])
// 交易渠道选项:由后端返回的编码映射为当前语言标签,locale 变化时重新生成
const channelOptions = computed(() => {
void locale.value
return paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code }))
return channelCodes.value.map((code) => ({ label: getChannelLabel(code), value: code }))
})
/** 标签选项 */
interface TagOption {
label: string
value: number
}
const tags = ref<TagOption[]>([])
const tagOptions = computed(() => tags.value)
/** 分类选项数据结构 */
interface CategoryOption {
label: string
@@ -96,7 +132,8 @@ const filterParams = computed(() => ({
statsScope: filterStatsScope.value,
types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(),
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
minAmount: filterMinAmount.value ?? undefined,
maxAmount: filterMaxAmount.value ?? undefined,
remark: filterRemark.value.trim() || undefined,
@@ -110,12 +147,12 @@ const summary = ref<any>({
})
const trendData = ref<any[]>([]) // 趋势分组数据
const categoryData = ref<any[]>([]) // 分类分组数据
const paymentData = ref<any[]>([]) // 支付方式分组数据
const channelData = ref<any[]>([]) // 交易渠道分组数据
const pieOption = ref({}) // 支出分类饼图
const incomePieOption = ref({}) // 收入分类饼图
const paymentOption = ref({}) // 支出支付方式饼图
const incomePaymentOption = ref({}) // 收入支付方式饼图
const channelOption = ref({}) // 支出交易渠道饼图
const incomeChannelOption = ref({}) // 收入交易渠道饼图
const trendOption = ref({}) // 趋势柱状图
const pad = (n: number) => String(n).padStart(2, '0')
@@ -220,35 +257,35 @@ function updateIncomePieChart() {
}
}
/** 更新支出支付方式占比饼图:仅统计支出,标签按当前语言本地化 */
function updatePaymentChart() {
paymentOption.value = {
title: { text: t('dashboard.expensePaymentPie'), left: 'center' },
/** 更新支出交易渠道占比饼图:仅统计支出,标签按当前语言本地化 */
function updateChannelChart() {
channelOption.value = {
title: { text: t('dashboard.expenseChannelPie'), left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: paymentData.value
data: channelData.value
.filter((p: any) => Number(p.expense) > 0)
.map((p: any) => ({ name: getPaymentLabel(p.key), value: Number(p.expense) })),
.map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.expense) })),
},
],
}
}
/** 更新收入支付方式占比饼图:仅统计收入,标签按当前语言本地化 */
function updateIncomePaymentChart() {
incomePaymentOption.value = {
title: { text: t('dashboard.incomePaymentPie'), left: 'center' },
/** 更新收入交易渠道占比饼图:仅统计收入,标签按当前语言本地化 */
function updateIncomeChannelChart() {
incomeChannelOption.value = {
title: { text: t('dashboard.incomeChannelPie'), left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: paymentData.value
data: channelData.value
.filter((p: any) => Number(p.income) > 0)
.map((p: any) => ({ name: getPaymentLabel(p.key), value: Number(p.income) })),
.map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.income) })),
},
],
}
@@ -283,20 +320,20 @@ async function fetchData() {
const { startTime, endTime } = range.value
const filters = filterParams.value
try {
const [summaryRes, trendRes, categoryRes, paymentRes]: any[] = await Promise.all([
const [summaryRes, trendRes, categoryRes, channelRes]: any[] = await Promise.all([
getStatisticsSummary({ startTime, endTime, ...filters }),
getStatisticsGroup({ dimension: granularity.value, startTime, endTime, ...filters }),
getStatisticsGroup({ dimension: 'category', startTime, endTime, ...filters }),
getStatisticsGroup({ dimension: 'paymentMethod', startTime, endTime, ...filters }),
getStatisticsGroup({ dimension: 'channel', startTime, endTime, ...filters }),
])
summary.value = summaryRes.data
trendData.value = trendRes.data
categoryData.value = categoryRes.data
paymentData.value = paymentRes.data
channelData.value = channelRes.data
updatePieChart()
updateIncomePieChart()
updatePaymentChart()
updateIncomePaymentChart()
updateChannelChart()
updateIncomeChannelChart()
updateTrendChart()
} catch (e: any) {
console.error('加载统计数据失败', e)
@@ -319,7 +356,10 @@ function loadPrefs() {
if (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope
if (Array.isArray(p.filterType)) filterType.value = p.filterType
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod
// 兼容旧版本以 filterPaymentMethod 存储的偏好
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
@@ -339,7 +379,8 @@ function savePrefs() {
filterStatsScope: filterStatsScope.value,
filterType: filterType.value,
filterCategory: filterCategory.value,
filterPaymentMethod: filterPaymentMethod.value,
filterChannel: filterChannel.value,
filterTag: filterTag.value,
filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value,
filterRemark: filterRemark.value,
@@ -359,22 +400,149 @@ async function fetchCategories() {
}
}
/** 从后端加载支付方式列表,供筛选下拉使用 */
async function loadPaymentMethods() {
/** 从后端加载交易渠道列表,供筛选下拉使用 */
async function loadChannels() {
try {
const res: any = await getPaymentMethods()
paymentMethodCodes.value = res.data || []
const res: any = await getChannels()
channelCodes.value = res.data || []
} catch (e) {
// 静默处理,支付方式下拉保持为空
// 静默处理,交易渠道下拉保持为空
}
}
/** 加载标签选项,供筛选下拉与 AI 统计使用 */
async function loadTags() {
try {
const res: any = await getTagList()
tags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
} catch (e) {
// 静默处理
}
}
/** 读取 AI 启用状态,未启用时不渲染 AI 搜索框 */
async function loadAiConfig() {
try {
const res: any = await getAiConfig()
aiEnabled.value = !!res.data?.enabled
} catch (e) {
aiEnabled.value = false
}
}
/** 将 AI 解析结果应用到统计面板筛选条件与粒度 */
async function applyAiStatistics(result: AiStatisticsResult) {
if (result.startTime && result.endTime) {
suppressRangeWatch = true
rangeType.value = 'custom'
customRange.value = [
new Date(result.startTime.replace('T', ' ').replace(/-/g, '/')).getTime(),
new Date(result.endTime.replace('T', ' ').replace(/-/g, '/')).getTime(),
]
}
if (result.dimension) {
granularity.value = result.dimension
}
if (result.statsScope) {
filterStatsScope.value = result.statsScope
}
filterType.value = result.types || []
filterCategory.value = result.categoryIds || []
filterChannel.value = result.channels || []
filterTag.value = result.tagIds || []
filterMinAmount.value = result.minAmount
filterMaxAmount.value = result.maxAmount
filterRemark.value = result.remark || ''
await nextTick()
suppressRangeWatch = false
}
/** 解析结果是否为空(无任何可用条件) */
const aiSearchEmpty = computed(() => {
const r = aiSearchResult.value
if (!r) return true
return (r.types?.length ?? 0) === 0
&& (r.categoryIds?.length ?? 0) === 0
&& (r.tagIds?.length ?? 0) === 0
&& (r.channels?.length ?? 0) === 0
&& !r.startTime
&& r.minAmount == null
&& r.maxAmount == null
&& !r.remark
&& !r.statsScope
&& !r.dimension
})
/** 打开 AI 搜索弹窗 */
function handleAiSearchOpen() {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
showAiSearchModal.value = true
}
/** 提交自然语言统计搜索 */
async function handleAiStatistics() {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
const text = aiQueryText.value.trim()
if (!text) {
message.warning(t('ai.query.emptyInput'))
return
}
aiSearchHistory.record()
aiQuerying.value = true
try {
const res: any = await parseStatisticsByAi(text)
aiSearchResult.value = res.data
// 无任何可用条件时提示,避免误清空当前筛选与时间范围
if (aiSearchEmpty.value) {
message.warning(t('ai.query.noCondition'))
return
}
await applyAiStatistics(aiSearchResult.value as AiStatisticsResult)
showAiSearchModal.value = false
message.success(aiSearchResult.value?.summary || t('dashboard.aiSearchApplied'))
} catch (e: any) {
message.error(e.message || t('dashboard.aiSearchFailed'))
} finally {
aiQuerying.value = false
}
}
/** 选中历史项:清空历史,或填入并立即查询 */
function handleAiSearchSelect(value: string) {
if (value === aiSearchHistory.clearValue) {
aiSearchHistory.clear()
return
}
aiQueryText.value = value
handleAiStatistics()
}
/** 回车时发起查询(阻止 NAutoComplete 内部选中建议项) */
function handleAiSearchKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.stopPropagation()
handleAiStatistics()
}
}
/** 选择示例后填入输入框 */
function handleExampleSelect(key: string) {
aiQueryText.value = key
}
/** 重置所有过滤条件(保留时间范围与粒度) */
function handleResetFilters() {
filterStatsScope.value = 'INCLUDED'
filterType.value = []
filterCategory.value = []
filterPaymentMethod.value = []
filterChannel.value = []
filterTag.value = []
filterMinAmount.value = null
filterMaxAmount.value = null
filterRemark.value = ''
@@ -391,20 +559,23 @@ watch(rangeType, (v) => {
// 范围、粒度或过滤条件变化时重新加载
watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
// 任一偏好变化时自动保存
watch([rangeType, customRange, granularity, filterStatsScope, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
watch([rangeType, customRange, granularity, filterStatsScope, filterType, filterCategory, filterChannel, filterTag, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
// 语言切换时重绘图表,更新标题与图例文案
watch(locale, () => {
updatePieChart()
updateIncomePieChart()
updatePaymentChart()
updateIncomePaymentChart()
updateChannelChart()
updateIncomeChannelChart()
updateTrendChart()
})
onMounted(async () => {
loadPrefs()
fetchCategories()
loadPaymentMethods()
loadChannels()
loadTags()
loadAiConfig()
aiSearchHistory.load()
await nextTick()
suppressRangeWatch = false
fetchData()
@@ -426,6 +597,12 @@ onMounted(async () => {
style="width: 260px;"
/>
<span style="color: #999; font-size: 12px;">{{ range.startTime.substring(0, 10) }} ~ {{ range.endTime.substring(0, 10) }}</span>
<NButton v-if="aiEnabled" @click="handleAiSearchOpen">
<template #icon>
<NIcon :component="SparklesOutline" />
</template>
{{ t('ai.query.entry') }}
</NButton>
</NSpace>
</NSpace>
@@ -459,15 +636,25 @@ onMounted(async () => {
style="width: 200px;"
/>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
v-model:value="filterChannel"
:options="channelOptions"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('dashboard.filterPaymentMethod')"
:placeholder="t('dashboard.filterChannel')"
style="width: 200px;"
/>
<NSelect
v-model:value="filterTag"
:options="tagOptions"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('dashboard.filterTag')"
style="width: 180px;"
/>
<NInputNumber
v-model:value="filterMinAmount"
:min="0"
@@ -562,7 +749,7 @@ onMounted(async () => {
<VChart :option="trendOption" style="height: 350px;" autoresize />
</NCard>
<!-- 饼图区域:支出/收入分类占比 + 支出/收入支付方式占比 -->
<!-- 饼图区域:支出/收入分类占比 + 支出/收入交易渠道占比 -->
<NGrid :cols="2" :x-gap="16" :y-gap="16">
<NGridItem>
<NCard>
@@ -576,14 +763,57 @@ onMounted(async () => {
</NGridItem>
<NGridItem>
<NCard>
<VChart :option="paymentOption" style="height: 350px;" autoresize />
<VChart :option="channelOption" style="height: 350px;" autoresize />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<VChart :option="incomePaymentOption" style="height: 350px;" autoresize />
<VChart :option="incomeChannelOption" style="height: 350px;" autoresize />
</NCard>
</NGridItem>
</NGrid>
<!-- AI 统计搜索弹窗(交互与账单列表 AI 查询一致) -->
<NModal
v-if="aiEnabled"
v-model:show="showAiSearchModal"
preset="card"
:title="t('ai.query.title')"
style="width: 520px; max-width: 92vw;"
>
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">{{ t('ai.query.hint') }}</NAlert>
<NAutoComplete
:value="aiQueryText"
:options="aiSearchHistory.options.value"
:get-show="aiSearchHistory.showMenu"
:render-label="aiSearchHistory.renderLabel"
:input-props="{ onKeydown: handleAiSearchKeydown }"
clearable
:placeholder="t('dashboard.aiSearchPlaceholder')"
style="width: 100%;"
@update:value="aiSearchHistory.handleInput"
@select="handleAiSearchSelect"
>
<template #prefix>
<NIcon :component="SparklesOutline" />
</template>
</NAutoComplete>
<NSpace justify="space-between" align="center">
<NDropdown trigger="click" :options="aiExampleOptions" @select="handleExampleSelect">
<NButton text type="primary" size="small">
<template #icon>
<NIcon :component="BulbOutline" />
</template>
{{ t('transaction.exampleLabel') }}
</NButton>
</NDropdown>
<NSpace>
<NButton @click="showAiSearchModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiQuerying" @click="handleAiStatistics">{{ t('ai.query.button') }}</NButton>
</NSpace>
</NSpace>
</NSpace>
</NModal>
</div>
</template>
+553 -8
View File
@@ -4,13 +4,17 @@ import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem,
NInput, NInputNumber, NSelect, NSwitch, NTag,
NInput, NInputNumber, NSelect, NSwitch, NTag, NText, NCard,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getRuleList, createRule, updateRule, deleteRule, toggleRuleEnabled } from '../api/rule'
import { getRulePage, createRule, updateRule, deleteRule, toggleRuleEnabled } from '../api/rule'
import type { RuleItem } from '../api/rule'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
import { getTransactionPage } from '../api/transaction'
import { getAiConfig, suggestRulesByAi, applyAiRules } from '../api/ai'
import type { AiRuleSuggestion } from '../api/ai'
import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render'
const message = useMessage() // 消息提示
@@ -41,6 +45,46 @@ const submitting = ref(false)
const categories = ref<CategoryOption[]>([])
const tags = ref<TagOption[]>([])
// 规则列表分页与筛选
const page = ref(1)
const pageSize = ref(20)
const total = ref(0)
const searchKeyword = ref('')
const filterEnabled = ref<string | null>(null) // 'ON' | 'OFF' | null
const filterType = ref<string | null>(null)
/** AI 规则建议行(带本地 key 供表格选择使用) */
type AiRuleRow = AiRuleSuggestion & { key: number }
/** 已打标签的候选记录 */
interface CandidateRow {
id: number
type: string
remark: string
channel: string
categoryName: string | null
tags: { id: number; name: string; color?: string }[]
}
// AI 生成规则状态
const aiEnabled = ref(false)
const showAiRuleModal = ref(false)
const aiStep = ref<'select' | 'suggest'>('select')
const aiCandidateRows = ref<CandidateRow[]>([])
const aiCandidateLoading = ref(false)
const checkedCandidateKeys = ref<number[]>([])
const aiCandidatePage = ref(1)
const aiCandidatePageSize = ref(20)
const aiCandidateTotal = ref(0)
const aiCandidateSearch = ref('')
const aiCandidateType = ref<string | null>(null)
const aiCandidateTagIds = ref<number[]>([])
const aiRuleLoading = ref(false)
const aiRuleApplying = ref(false)
const aiRuleSummary = ref('')
const aiRuleRows = ref<AiRuleRow[]>([])
const checkedAiRuleKeys = ref<number[]>([])
// 表单数据
const form = ref<{
name: string
@@ -71,6 +115,16 @@ const typeOptions = computed(() => [
{ label: t('common.types.income'), value: 'INCOME' },
])
// 规则列表筛选:收支类型(可清空)与启用状态
const typeFilterOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
const enabledFilterOptions = computed(() => [
{ label: t('rule.statusOn'), value: 'ON' },
{ label: t('rule.statusOff'), value: 'OFF' },
])
// 分类选项:按当前收支类型过滤
const categoryOptions = computed(() => {
const list = form.value.type ? categories.value.filter((c) => c.type === form.value.type) : categories.value
@@ -174,12 +228,317 @@ const columns = computed<DataTableColumns<RuleItem>>(() => [
},
])
/** 加载规则列表数据 */
/** AI 规则建议表格行键 */
function aiRuleRowKey(row: AiRuleRow) {
return row.key
}
/** AI 规则建议表格列:名称与关键词可编辑,分类/标签展示 AI 建议 */
const aiRuleColumns = computed<DataTableColumns<AiRuleRow>>(() => [
// 与已有规则重复的条目禁止勾选(禁止导入)
{ type: 'selection', disabled: (row) => !!row.duplicate },
{
title: t('rule.name'),
key: 'name',
width: 240,
render: (row) => h(NSpace, { size: 4, align: 'center', wrap: false }, {
default: () => [
h(NInput, {
value: row.name,
size: 'small',
disabled: !!row.duplicate,
onUpdateValue: (v: string) => { row.name = v },
}),
row.duplicate ? h(NTag, { size: 'small', type: 'warning' }, {
default: () => t('ai.rule.duplicate'),
}) : null,
],
}),
},
{
title: t('rule.keyword'),
key: 'keyword',
width: 160,
render: (row) => h(NInput, {
value: row.keyword || '',
size: 'small',
placeholder: '—',
disabled: !!row.duplicate,
onUpdateValue: (v: string) => { row.keyword = v || null },
}),
},
{
title: t('common.type'),
key: 'type',
width: 80,
render: (row) => (row.type ? (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) : '—'),
},
{
title: t('rule.targetCategory'),
key: 'category',
width: 150,
render: (row) => {
if (row.categoryName) {
return row.categoryName
}
if (row.newCategoryName) {
return `${t('ai.rule.newPrefix')}${row.newCategoryName}`
}
return '—'
},
},
{
title: t('rule.targetTags'),
key: 'tags',
width: 170,
render: (row) => {
const names = [
...(row.tagNames || []),
...((row.newTags || []).map((tag) => `${t('ai.rule.newPrefix')}${tag.name}`)),
]
return names.length > 0 ? names.join('、') : '—'
},
},
{
title: t('ai.rule.reason'),
key: 'reason',
render: (row) => row.reason || '',
},
])
/** 候选记录表格行键 */
function aiCandidateRowKey(row: CandidateRow) {
return row.id
}
/** 候选记录分页配置 */
const aiCandidatePagination = computed(() => ({
page: aiCandidatePage.value,
pageSize: aiCandidatePageSize.value,
itemCount: aiCandidateTotal.value,
pageSizes: [10, 20, 50, 100],
showSizePicker: true,
}))
/** 候选记录类型筛选选项 */
const aiCandidateTypeOptions = computed(() => [
{ label: t('common.types.income'), value: 'INCOME' },
{ label: t('common.types.expense'), value: 'EXPENSE' },
])
/** 候选记录表格列:展示已打标签的记录供勾选 */
const aiCandidateColumns = computed<DataTableColumns<CandidateRow>>(() => [
{ type: 'selection' },
{
title: t('transaction.column.remark'),
key: 'remark',
ellipsis: { tooltip: true },
},
{
title: t('common.type'),
key: 'type',
width: 70,
render: (row) => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')),
},
{
title: t('dashboard.filterChannel'),
key: 'channel',
width: 100,
render: (row) => getChannelLabel(row.channel),
},
{
title: t('dashboard.filterTag'),
key: 'tags',
width: 220,
render: (row) => {
if (!row.tags || row.tags.length === 0) {
return '—'
}
return h(NSpace, { size: 4 }, {
default: () => row.tags.map((tag) => h(NTag, {
size: 'small',
color: tag.color ? { color: tag.color, textColor: '#fff' } : undefined,
}, { default: () => tag.name })),
})
},
},
])
/** 读取 AI 启用状态,未启用时不渲染 AI 入口 */
async function loadAiConfig() {
try {
const res: any = await getAiConfig()
aiEnabled.value = !!res.data?.enabled
} catch (e) {
aiEnabled.value = false
}
}
/** 加载候选记录(已打标签),autoSelect 为 true 时默认勾选当前页 */
async function loadCandidates(autoSelect: boolean) {
aiCandidateLoading.value = true
try {
const res: any = await getTransactionPage({
// 有标签筛选时由其隐含「已打标签」,否则显式筛选有标签记录
tagged: aiCandidateTagIds.value.length === 0 ? true : undefined,
tagIds: aiCandidateTagIds.value.length > 0 ? aiCandidateTagIds.value : undefined,
types: aiCandidateType.value ? [aiCandidateType.value] : undefined,
remark: aiCandidateSearch.value.trim() || undefined,
page: aiCandidatePage.value,
size: aiCandidatePageSize.value,
sortField: 'transactionTime',
sortOrder: 'desc',
})
const data = res.data || {}
aiCandidateRows.value = (data.records || []).map((r: any) => ({
id: r.id,
type: r.type,
remark: r.remark || '',
channel: r.channel,
categoryName: r.categoryName,
tags: r.tags || [],
}))
aiCandidateTotal.value = data.total || 0
if (autoSelect) {
checkedCandidateKeys.value = aiCandidateRows.value.map((row) => row.id)
}
} catch (e: any) {
message.error(e.message || t('ai.rule.loadRecordsFailed'))
} finally {
aiCandidateLoading.value = false
}
}
/** 打开 AI 生成规则弹窗,重置候选条件并加载已打标签记录(默认全选当前页) */
async function handleAiRuleOpen() {
showAiRuleModal.value = true
aiStep.value = 'select'
aiRuleRows.value = []
aiRuleSummary.value = ''
checkedAiRuleKeys.value = []
aiCandidateRows.value = []
checkedCandidateKeys.value = []
aiCandidatePage.value = 1
aiCandidateSearch.value = ''
aiCandidateType.value = null
aiCandidateTagIds.value = []
await loadCandidates(true)
if (aiCandidateTotal.value === 0) {
message.warning(t('ai.rule.noTagged'))
}
}
/** 候选记录搜索/筛选:回到第一页并默认勾选 */
function handleCandidateSearch() {
aiCandidatePage.value = 1
loadCandidates(true)
}
/** 清空候选记录搜索与筛选条件 */
function handleCandidateClear() {
aiCandidateSearch.value = ''
aiCandidateType.value = null
aiCandidateTagIds.value = []
aiCandidatePage.value = 1
loadCandidates(true)
}
/** 候选记录翻页(保留已有勾选,避免跨页选择丢失) */
function handleCandidatePageChange(page: number) {
aiCandidatePage.value = page
loadCandidates(false)
}
/** 候选记录切换每页条数 */
function handleCandidatePageSizeChange(size: number) {
aiCandidatePageSize.value = size
aiCandidatePage.value = 1
loadCandidates(false)
}
/** 勾选当前页全部候选记录(与已有勾选取并集) */
function handleCandidateSelectPage() {
const merged = new Set([...checkedCandidateKeys.value, ...aiCandidateRows.value.map((row) => row.id)])
checkedCandidateKeys.value = Array.from(merged)
}
/** 清空候选记录勾选 */
function handleCandidateClearSelection() {
checkedCandidateKeys.value = []
}
/** 将勾选的记录发送给 AI,生成规则建议并切换到建议步骤 */
async function handleAiRuleGenerate() {
if (checkedCandidateKeys.value.length === 0) {
message.warning(t('ai.rule.selectRequired'))
return
}
aiRuleLoading.value = true
try {
const res: any = await suggestRulesByAi(checkedCandidateKeys.value)
const data = res.data || {}
aiRuleSummary.value = data.summary || ''
aiRuleRows.value = (data.rules || []).map((rule: AiRuleSuggestion, index: number) => ({ ...rule, key: index }))
// 与已有规则重复的条目不勾选
checkedAiRuleKeys.value = aiRuleRows.value.filter((row) => !row.duplicate).map((row) => row.key)
if (aiRuleRows.value.length > 0 && checkedAiRuleKeys.value.length === 0) {
message.warning(t('ai.rule.allDuplicate'))
}
aiStep.value = 'suggest'
} catch (e: any) {
message.error(e.message || t('ai.rule.failed'))
} finally {
aiRuleLoading.value = false
}
}
/** 应用勾选的 AI 规则建议:批量创建并刷新列表(跳过与已有规则重复项) */
async function handleAiRuleApply() {
const selected = aiRuleRows.value.filter((row) => !row.duplicate && checkedAiRuleKeys.value.includes(row.key))
if (selected.length === 0) {
message.warning(t('ai.rule.selectRequired'))
return
}
aiRuleApplying.value = true
try {
const res: any = await applyAiRules(selected.map((row) => ({
name: row.name,
enabled: true,
type: row.type,
keyword: row.keyword,
minAmount: row.minAmount,
maxAmount: row.maxAmount,
categoryId: row.categoryId,
newCategoryName: row.newCategoryName,
newCategoryIcon: row.newCategoryIcon,
tagIds: row.tagIds,
newTags: row.newTags,
})))
message.success(t('ai.rule.applied', { count: res.data ?? selected.length }))
showAiRuleModal.value = false
await fetchData()
await loadCategories()
await loadTags()
} catch (e: any) {
message.error(e.message || t('ai.rule.applyFailed'))
} finally {
aiRuleApplying.value = false
}
}
/** 加载规则列表数据(分页 + 筛选) */
async function fetchData() {
loading.value = true
try {
const res: any = await getRuleList()
rules.value = res.data || []
const res: any = await getRulePage({
page: page.value,
size: pageSize.value,
keyword: searchKeyword.value.trim() || undefined,
enabled: filterEnabled.value === null ? undefined : filterEnabled.value === 'ON',
type: filterType.value || undefined,
})
rules.value = res.data?.records || []
total.value = res.data?.total || 0
} catch (e: any) {
message.error(e.message || t('common.loadFailed'))
} finally {
@@ -187,6 +546,43 @@ async function fetchData() {
}
}
/** 规则列表分页配置 */
const pagination = computed(() => ({
page: page.value,
pageSize: pageSize.value,
itemCount: total.value,
pageSizes: [10, 20, 50, 100],
showSizePicker: true,
}))
/** 执行规则搜索:回到第一页 */
function handleSearch() {
page.value = 1
fetchData()
}
/** 清空规则搜索与筛选 */
function handleClearSearch() {
searchKeyword.value = ''
filterEnabled.value = null
filterType.value = null
page.value = 1
fetchData()
}
/** 规则列表翻页 */
function handlePageChange(value: number) {
page.value = value
fetchData()
}
/** 规则列表切换每页条数 */
function handlePageSizeChange(value: number) {
pageSize.value = value
page.value = 1
fetchData()
}
/** 加载分类选项 */
async function loadCategories() {
try {
@@ -275,6 +671,8 @@ async function handleSubmit() {
} else {
await createRule(payload)
message.success(t('common.createSuccess'))
// 新增后回到第一页,便于看到新规则
page.value = 1
}
showModal.value = false
await fetchData()
@@ -325,11 +723,12 @@ watch(() => form.value.type, (type) => {
}
})
// 组件挂载时加载规则、分类与标签
// 组件挂载时加载规则、分类、标签与 AI 启用状态
onMounted(() => {
fetchData()
loadCategories()
loadTags()
loadAiConfig()
})
</script>
@@ -338,10 +737,51 @@ onMounted(() => {
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px;">
<h2 style="margin: 0;">{{ t('rule.title') }}</h2>
<NButton type="primary" @click="handleAdd">{{ t('rule.add') }}</NButton>
<NSpace>
<NButton v-if="aiEnabled" @click="handleAiRuleOpen">{{ t('ai.rule.entry') }}</NButton>
<NButton type="primary" @click="handleAdd">{{ t('rule.add') }}</NButton>
</NSpace>
</NSpace>
<NDataTable :columns="columns" :data="rules" :loading="loading" :bordered="false" />
<!-- 搜索与筛选 -->
<NCard :bordered="false" style="margin-bottom: 16px;">
<NSpace align="center" :wrap="true" :size="[8, 8]">
<NInput
v-model:value="searchKeyword"
clearable
:placeholder="t('rule.searchPlaceholder')"
style="width: 220px;"
@keyup.enter="handleSearch"
/>
<NSelect
v-model:value="filterEnabled"
:options="enabledFilterOptions"
clearable
:placeholder="t('rule.status')"
style="width: 120px;"
/>
<NSelect
v-model:value="filterType"
:options="typeFilterOptions"
clearable
:placeholder="t('dashboard.filterType')"
style="width: 120px;"
/>
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
<NButton @click="handleClearSearch">{{ t('transaction.clearAll') }}</NButton>
</NSpace>
</NCard>
<NDataTable
remote
:columns="columns"
:data="rules"
:loading="loading"
:bordered="false"
:pagination="pagination"
@update:page="handlePageChange"
@update:page-size="handlePageSizeChange"
/>
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('rule.editTitle') : t('rule.addTitle')" style="width: 520px;">
@@ -396,5 +836,110 @@ onMounted(() => {
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
<!-- AI 生成规则弹窗:先选择已打标签记录,再预览规则建议 -->
<NModal
v-if="aiEnabled"
v-model:show="showAiRuleModal"
preset="card"
:title="t('ai.rule.title')"
style="width: min(1200px, 94vw);"
>
<!-- 步骤一:选择记录 -->
<template v-if="aiStep === 'select'">
<NSpace align="center" :wrap="true" :size="[8, 8]" style="margin-bottom: 12px;">
<NInput
v-model:value="aiCandidateSearch"
clearable
:placeholder="t('ai.rule.searchPlaceholder')"
style="width: 220px;"
@keyup.enter="handleCandidateSearch"
/>
<NSelect
v-model:value="aiCandidateType"
:options="aiCandidateTypeOptions"
clearable
:placeholder="t('dashboard.filterType')"
style="width: 120px;"
/>
<NSelect
v-model:value="aiCandidateTagIds"
:options="tagOptions"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('dashboard.filterTag')"
style="width: 200px;"
/>
<NButton type="primary" @click="handleCandidateSearch">{{ t('transaction.search') }}</NButton>
<NButton @click="handleCandidateClear">{{ t('transaction.clearAll') }}</NButton>
</NSpace>
<NSpace align="center" justify="space-between" :wrap="true" style="margin-bottom: 8px;">
<NText depth="3" style="font-size: 12px;">
{{ t('ai.rule.selectHint', { count: checkedCandidateKeys.length }) }}
</NText>
<NSpace :size="8">
<NButton size="small" :disabled="aiCandidateRows.length === 0" @click="handleCandidateSelectPage">
{{ t('ai.rule.selectPageAll') }}
</NButton>
<NButton size="small" :disabled="checkedCandidateKeys.length === 0" @click="handleCandidateClearSelection">
{{ t('ai.rule.clearSelection') }}
</NButton>
</NSpace>
</NSpace>
<NDataTable
v-model:checked-row-keys="checkedCandidateKeys"
remote
:columns="aiCandidateColumns"
:data="aiCandidateRows"
:loading="aiCandidateLoading"
:row-key="aiCandidateRowKey"
:pagination="aiCandidatePagination"
:max-height="360"
:scroll-x="1100"
size="small"
@update:page="handleCandidatePageChange"
@update:page-size="handleCandidatePageSizeChange"
/>
<NSpace justify="end" style="margin-top: 16px;">
<NButton @click="showAiRuleModal = false">{{ t('common.cancel') }}</NButton>
<NButton
type="primary"
:loading="aiRuleLoading"
:disabled="aiCandidateLoading || checkedCandidateKeys.length === 0"
@click="handleAiRuleGenerate"
>
{{ t('ai.rule.generate') }}
</NButton>
</NSpace>
</template>
<!-- 步骤二:预览规则建议 -->
<template v-else>
<NText depth="3" style="font-size: 12px; display: block; margin-bottom: 12px;">
{{ aiRuleSummary || t('ai.rule.hint') }}
</NText>
<NDataTable
v-model:checked-row-keys="checkedAiRuleKeys"
:columns="aiRuleColumns"
:data="aiRuleRows"
:loading="aiRuleLoading"
:row-key="aiRuleRowKey"
:max-height="420"
:scroll-x="1100"
size="small"
/>
<NSpace justify="end" style="margin-top: 16px;">
<NButton @click="aiStep = 'select'">{{ t('ai.rule.back') }}</NButton>
<NButton @click="showAiRuleModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRuleApplying" :disabled="aiRuleLoading" @click="handleAiRuleApply">
{{ t('ai.rule.apply') }}
</NButton>
</NSpace>
</template>
</NModal>
</div>
</template>
+3 -1
View File
@@ -1,8 +1,9 @@
<script setup lang="ts">
// 系统设置页:上下分区展示数据库配置、注册设置、AI 模型配置与定时通知
// 系统设置页:上下分区展示数据库配置、注册设置、账单来源统计、AI 模型配置与定时通知
import { useI18n } from 'vue-i18n'
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
import BillSourceStatsPanel from '../components/BillSourceStatsPanel.vue'
import AiConfigPanel from '../components/AiConfigPanel.vue'
import NotificationConfigPanel from '../components/NotificationConfigPanel.vue'
@@ -14,6 +15,7 @@ const { t } = useI18n()
<h2 class="page-title">{{ t('settings.title') }}</h2>
<DatabaseConfigPanel />
<RegistrationConfigPanel />
<BillSourceStatsPanel />
<AiConfigPanel />
<NotificationConfigPanel />
</div>
+52 -44
View File
@@ -3,14 +3,14 @@
import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown } from 'naive-ui'
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getPaymentMethods, batchDeleteTransactions } from '../api/transaction'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions } from '../api/transaction'
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import { getCategoryList, createCategory } from '../api/category'
import { getTagList, createTag } from '../api/tag'
import { getPaymentLabel } from '../utils/payment'
import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import BillImportModal from './BillImportModal.vue'
@@ -27,7 +27,7 @@ interface TransactionItem {
categoryIcon: string
type: string // INCOME 或 EXPENSE
amount: number
paymentMethod: string
channel: string
remark: string
remarkHidden: boolean
transactionTime: string
@@ -185,7 +185,7 @@ const form = ref({
categoryId: null as number | null,
type: 'EXPENSE',
amount: null as number | null,
paymentMethod: '',
channel: '',
remark: '',
transactionTime: Date.now(),
includeInStats: true,
@@ -198,18 +198,18 @@ const typeOptions = computed(() => [
{ label: t('common.types.income'), value: 'INCOME' },
])
// 支付方式编码列表(从后端动态获取,含账单导入来源与人工支付方式)
const paymentMethodCodes = ref<string[]>([])
// 交易渠道编码列表(从后端动态获取,含账单导入来源与人工交易渠道)
const channelCodes = ref<string[]>([])
// 支付方式选项:由后端返回的编码映射为当前语言标签
const paymentOptions = computed(() =>
paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code })),
// 交易渠道选项:由后端返回的编码映射为当前语言标签
const channelOptions = computed(() =>
channelCodes.value.map((code) => ({ label: getChannelLabel(code), value: code })),
)
// 筛选条件状态
const filterType = ref<string[]>([]) // 类型筛选(多选)
const filterCategory = ref<number[]>([]) // 分类筛选(多选)
const filterPaymentMethod = ref<string[]>([]) // 支付方式筛选(多选)
const filterChannel = ref<string[]>([]) // 交易渠道筛选(多选)
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
const filterRemark = ref('') // 备注关键字搜索
@@ -293,7 +293,9 @@ function loadFilterPrefs() {
const p = JSON.parse(raw)
if (Array.isArray(p.filterType)) filterType.value = p.filterType
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod
// 兼容旧版本以 filterPaymentMethod 存储的偏好
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
if (Array.isArray(p.filterDateRange) && p.filterDateRange.length === 2) filterDateRange.value = p.filterDateRange
if (p.filterIncludeInStats === 'true' || p.filterIncludeInStats === 'false') filterIncludeInStats.value = p.filterIncludeInStats
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
@@ -313,7 +315,7 @@ function saveFilterPrefs() {
localStorage.setItem(FILTER_PREFS_KEY, JSON.stringify({
filterType: filterType.value,
filterCategory: filterCategory.value,
filterPaymentMethod: filterPaymentMethod.value,
filterChannel: filterChannel.value,
filterDateRange: filterDateRange.value,
filterIncludeInStats: filterIncludeInStats.value,
filterRemark: filterRemark.value,
@@ -330,7 +332,7 @@ function saveFilterPrefs() {
// 筛选条件变化时自动保存用户偏好
watch(
[filterType, filterCategory, filterPaymentMethod, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
[filterType, filterCategory, filterChannel, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
saveFilterPrefs,
{ deep: true },
)
@@ -461,8 +463,8 @@ function createColumns(): DataTableColumns<TransactionItem> {
},
},
{
title: t('transaction.column.paymentMethod'), key: 'paymentMethod', width: 100,
render(row) { return getPaymentLabel(row.paymentMethod) },
title: t('transaction.column.channel'), key: 'channel', width: 100,
render(row) { return getChannelLabel(row.channel) },
},
{
title: t('transaction.column.remark'), key: 'remark', minWidth: 220,
@@ -523,13 +525,13 @@ function createColumns(): DataTableColumns<TransactionItem> {
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
/** 从后端加载支付方式列表,供筛选与编辑表单使用 */
async function loadPaymentMethods() {
/** 从后端加载交易渠道列表,供筛选与编辑表单使用 */
async function loadChannels() {
try {
const res: any = await getPaymentMethods()
paymentMethodCodes.value = res.data || []
const res: any = await getChannels()
channelCodes.value = res.data || []
} catch (e) {
// 静默处理,支付方式下拉保持为空
// 静默处理,交易渠道下拉保持为空
}
}
@@ -562,7 +564,7 @@ async function fetchData() {
size: pageSize.value,
types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(),
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
@@ -607,7 +609,7 @@ function handleViewRaw(row: TransactionItem) {
/** 打开新增记录弹窗,重置表单 */
function handleAdd() {
editingId.value = null
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
form.value = { categoryId: null, type: 'EXPENSE', amount: null, channel: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
pendingCategory.value = null
pendingTag.value = null
showModal.value = true
@@ -620,7 +622,7 @@ function handleEdit(row: TransactionItem) {
categoryId: row.categoryId,
type: row.type,
amount: row.amount,
paymentMethod: row.paymentMethod || '',
channel: row.channel || '',
remark: row.remark || '',
transactionTime: new Date(row.transactionTime).getTime(),
includeInStats: row.includeInStats ?? true,
@@ -646,7 +648,7 @@ async function handleSubmit() {
categoryId: form.value.categoryId!,
type: form.value.type,
amount: form.value.amount!,
paymentMethod: form.value.paymentMethod || undefined,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
transactionTime: formatLocalDateTime(form.value.transactionTime),
includeInStats: form.value.includeInStats,
@@ -737,7 +739,7 @@ function handleSearch() {
function handleReset() {
filterType.value = []
filterCategory.value = []
filterPaymentMethod.value = []
filterChannel.value = []
filterDateRange.value = null
filterIncludeInStats.value = null
filterRemark.value = ''
@@ -797,14 +799,14 @@ async function handleAiSuggest() {
}
}
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、支付方式、备注) */
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、交易渠道、备注) */
async function handleAiSuggestOne() {
aiFormLoading.value = true
try {
const res: any = await suggestOneByAi({
type: form.value.type,
amount: form.value.amount,
paymentMethod: form.value.paymentMethod || undefined,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
})
const suggestion = res.data
@@ -850,7 +852,7 @@ function buildCategoryPrompt() {
const parts = [`这是一笔${typeLabel}记录`]
if (form.value.remark) parts.push(`备注:${form.value.remark}`)
if (form.value.amount) parts.push(`金额:${form.value.amount}`)
if (form.value.paymentMethod) parts.push(`支付方式:${getPaymentLabel(form.value.paymentMethod)}`)
if (form.value.channel) parts.push(`交易渠道:${getChannelLabel(form.value.channel)}`)
parts.push('请生成一个能涵盖这类记录的记账分类')
return parts.join(',')
}
@@ -894,7 +896,7 @@ async function handleAiTagSuggest() {
const res: any = await suggestOneByAi({
type: form.value.type,
amount: form.value.amount,
paymentMethod: form.value.paymentMethod || undefined,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
})
const suggestion = res.data
@@ -1055,7 +1057,7 @@ async function handleAiRecordParse() {
categoryId: r.categoryId ?? null,
type: r.type || 'EXPENSE',
amount: r.amount ?? null,
paymentMethod: r.paymentMethod || '',
channel: r.channel || '',
remark: r.remark || '',
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
includeInStats: r.includeInStats ?? true,
@@ -1121,7 +1123,7 @@ function applyAiQuery() {
const categoryIds = [...(r.categoryIds || [])]
if (r.uncategorized) categoryIds.push(0)
filterCategory.value = categoryIds
filterPaymentMethod.value = r.paymentMethods || []
filterChannel.value = r.channels || []
filterDateRange.value = r.startTime && r.endTime
? [new Date(r.startTime).getTime(), new Date(r.endTime).getTime()]
: null
@@ -1143,7 +1145,7 @@ const aiQueryEmpty = computed(() => {
&& (r.categoryIds?.length ?? 0) === 0
&& !r.uncategorized
&& (r.tagIds?.length ?? 0) === 0
&& (r.paymentMethods?.length ?? 0) === 0
&& (r.channels?.length ?? 0) === 0
&& !r.startTime
&& r.minAmount == null
&& r.maxAmount == null
@@ -1226,11 +1228,11 @@ const activeFilterTags = computed(() => {
})
}
filterPaymentMethod.value.forEach((v) => {
filterChannel.value.forEach((v) => {
tags.push({
key: `payment:${v}`,
label: getPaymentLabel(v),
clear: () => { filterPaymentMethod.value = filterPaymentMethod.value.filter((x) => x !== v) },
key: `channel:`,
label: getChannelLabel(v),
clear: () => { filterChannel.value = filterChannel.value.filter((x) => x !== v) },
})
})
@@ -1451,7 +1453,7 @@ async function doExport() {
const res = await exportTransaction({
types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(),
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
@@ -1487,7 +1489,7 @@ onMounted(() => {
loadAiConfig()
fetchCategories()
fetchTags()
loadPaymentMethods()
loadChannels()
fetchData()
})
</script>
@@ -1507,6 +1509,12 @@ onMounted(() => {
</NButton>
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton :loading="loading" @click="fetchData">
<template #icon>
<NIcon :component="RefreshOutline" />
</template>
{{ t('common.refresh') }}
</NButton>
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
<template #icon>
<NIcon :component="SparklesOutline" />
@@ -1562,12 +1570,12 @@ onMounted(() => {
@blur="handleRemarkBlur"
/>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
v-model:value="filterChannel"
:options="channelOptions"
multiple
clearable
:max-tag-count="2"
:placeholder="t('transaction.column.paymentMethod')"
:placeholder="t('transaction.column.channel')"
style="width: 180px;"
/>
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
@@ -1721,8 +1729,8 @@ onMounted(() => {
<NFormItem :label="t('transaction.column.amount')">
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
</NFormItem>
<NFormItem :label="t('transaction.column.paymentMethod')">
<NSelect v-model:value="form.paymentMethod" :options="paymentOptions" clearable :placeholder="t('transaction.paymentPlaceholder')" />
<NFormItem :label="t('transaction.column.channel')">
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" />
</NFormItem>
<NFormItem :label="t('transaction.column.transactionTime')">
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />