diff --git a/frontend/src/components/AiQueryWindow.vue b/frontend/src/components/AiQueryWindow.vue new file mode 100644 index 0000000..1e6cf24 --- /dev/null +++ b/frontend/src/components/AiQueryWindow.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/frontend/src/components/FloatingPanel.vue b/frontend/src/components/FloatingPanel.vue new file mode 100644 index 0000000..7fe0769 --- /dev/null +++ b/frontend/src/components/FloatingPanel.vue @@ -0,0 +1,300 @@ + + + + + diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index a3066d8..583386e 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -11,6 +11,7 @@ export default { common: { confirm: { zh: '确定', en: 'Confirm', ja: '確認' }, cancel: { zh: '取消', en: 'Cancel', ja: 'キャンセル' }, + close: { zh: '关闭', en: 'Close', ja: '閉じる' }, delete: { zh: '删除', en: 'Delete', ja: '削除' }, edit: { zh: '编辑', en: 'Edit', ja: '編集' }, operation: { zh: '操作', en: 'Actions', ja: '操作' }, @@ -794,6 +795,7 @@ export default { hint: { zh: '用自然语言描述查询条件,AI 会解析为筛选条件并搜索。', en: 'Describe the query in natural language; AI will turn it into filters and search.', ja: '自然言語で検索条件を説明すると、AI がフィルターに変換して検索します。' }, emptyInput: { zh: '请输入查询内容', en: 'Please enter a query', ja: '検索内容を入力してください' }, clearHistory: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' }, + submitHint: { zh: 'Ctrl + Enter 提交', en: 'Ctrl + Enter to submit', ja: 'Ctrl + Enter で送信' }, failed: { zh: 'AI 查询解析失败', en: 'Failed to parse the query', ja: 'AI 検索の解析に失敗しました' }, resultTitle: { zh: 'AI 解析结果', en: 'AI Query Result', ja: 'AI 解析結果' }, apply: { zh: '应用筛选', en: 'Apply filters', ja: 'フィルターを適用' }, diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index f3066b9..ff08c17 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -3,17 +3,17 @@ import { ref, computed, onMounted, watch, nextTick, unref, defineAsyncComponent } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' -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 { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton, NIcon, useMessage } from 'naive-ui' +import { SparklesOutline } from '@vicons/ionicons5' import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics' import { getCategoryList } from '../api/category' 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' +import AiQueryWindow from '../components/AiQueryWindow.vue' // 图表组件异步加载:echarts 体积较大,登录后进入面板时先渲染页面骨架, // echarts 在后台按需下载并注册,避免阻塞目标页首屏 @@ -74,25 +74,17 @@ const filterRemark = ref('') // 备注关键字 // AI 统计搜索状态 const aiEnabled = ref(false) -const aiQueryText = ref('') const aiQuerying = ref(false) const aiSearchResult = ref(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 aiExamples = computed(() => [ + t('dashboard.aiExample1'), + t('dashboard.aiExample2'), + 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' }, @@ -605,21 +597,21 @@ function handleAiSearchOpen() { message.warning(t('ai.needConfig')) return } + // 打开时清除上一次可能残留的加载状态,避免按钮一直显示加载中 + aiQuerying.value = false showAiSearchModal.value = true } /** 提交自然语言统计搜索 */ -async function handleAiStatistics() { +async function handleAiStatistics(text: string) { 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) @@ -630,7 +622,6 @@ async function handleAiStatistics() { 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')) @@ -639,29 +630,6 @@ async function handleAiStatistics() { } } -/** 选中历史项:清空历史,或填入并立即查询 */ -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' @@ -701,7 +669,6 @@ onMounted(async () => { loadChannels() loadTags() loadAiConfig() - aiSearchHistory.load() await nextTick() suppressRangeWatch = false fetchData() @@ -902,47 +869,18 @@ onMounted(async () => { - - + - - {{ t('ai.query.hint') }} - - - - - - - - {{ t('transaction.exampleLabel') }} - - - - {{ t('common.cancel') }} - {{ t('ai.query.button') }} - - - - + :hint="t('ai.query.hint')" + :placeholder="t('dashboard.aiSearchPlaceholder')" + :examples="aiExamples" + :loading="aiQuerying" + history-key="finance.dashboard.aiSearchHistory" + storage-key="finance.dashboard.aiQueryWindow" + @submit="handleAiStatistics" + /> diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index 3cde0bd..ad168a4 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -3,8 +3,8 @@ import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' -import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown, NUpload, NUploadDragger, NTabs, NTabPane } from 'naive-ui' -import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5' +import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NUpload, NUploadDragger, NTabs, NTabPane } from 'naive-ui' +import { EyeOutline, EyeOffOutline, SparklesOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5' import type { DataTableColumns, UploadFileInfo } from 'naive-ui' import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions, batchCreateTransactions } from '../api/transaction' import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi } from '../api/ai' @@ -21,6 +21,7 @@ import BillImportModal from './BillImportModal.vue' import TransactionDetailModal from '../components/TransactionDetailModal.vue' import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue' import TransactionRelationModal from '../components/TransactionRelationModal.vue' +import AiQueryWindow from '../components/AiQueryWindow.vue' import type { RelationMemberDraft } from '../api/transactionGroup' const message = useMessage() // 消息提示 @@ -187,18 +188,9 @@ const pendingTag = ref<{ name: string; color: string } | null>(null) const aiApplying = ref(false) // AI 建议应用状态 const aiSuggestions = ref([]) // AI 建议列表 const checkedAiRowKeys = ref([]) // AI 建议弹窗中已勾选的代表记录ID -const aiQueryText = ref('') // AI 自然语言查询输入 const aiQuerying = ref(false) // AI 查询解析状态 const aiQueryResult = ref(null) // AI 查询解析结果 const showAiQueryModal = ref(false) // AI 智能查询弹窗显示状态 -// AI 查询历史存储键 -const AI_QUERY_HISTORY_KEY = 'finance.transaction.aiQueryHistory' -// AI 查询历史(最近使用,最新在前) -const aiQueryHistory = useKeywordHistory({ - storageKey: AI_QUERY_HISTORY_KEY, - text: aiQueryText, - clearLabel: () => t('ai.query.clearHistory'), -}) const showAiRecordModal = ref(false) // AI 记账弹窗显示状态 const aiRecordText = ref('') // AI 记账输入 const aiRecording = ref(false) // AI 记账解析状态 @@ -1625,21 +1617,21 @@ function handleAiQueryOpen() { message.warning(t('ai.needConfig')) return } + // 打开时清除上一次可能残留的加载状态,避免按钮一直显示加载中 + aiQuerying.value = false showAiQueryModal.value = true } /** 请求 AI 解析自然语言查询 */ -async function handleAiQuery() { +async function handleAiQuery(text: string) { if (!aiEnabled.value) { message.warning(t('ai.needConfig')) return } - const text = aiQueryText.value.trim() if (!text) { message.warning(t('ai.query.emptyInput')) return } - aiQueryHistory.record() aiQuerying.value = true try { const res: any = await queryByAi(text) @@ -1651,7 +1643,6 @@ async function handleAiQuery() { } // 直接应用解析结果并搜索,无需二次确认 applyAiQuery() - showAiQueryModal.value = false } catch (e: any) { message.error(e.message || t('ai.query.failed')) } finally { @@ -1697,34 +1688,8 @@ const aiQueryEmpty = computed(() => { && r.includeInStats == null }) -/** AI 查询示例(从 i18n 拆分为数组) */ -const queryExamples = computed(() => t('ai.query.examples').split('|')) - -/** AI 查询示例下拉选项 */ -const exampleOptions = computed(() => queryExamples.value.map((example) => ({ label: example, key: example }))) - -/** 选择示例后填入输入框 */ -function handleExampleSelect(key: string) { - aiQueryText.value = key -} - -/** 选中历史项:填入并立即发起查询 */ -function handleAiQuerySelect(value: string) { - if (value === aiQueryHistory.clearValue) { - aiQueryHistory.clear() - return - } - aiQueryText.value = value - handleAiQuery() -} - -/** 回车时发起查询(阻止 NAutoComplete 内部选中建议项) */ -function handleAiQueryKeydown(e: KeyboardEvent) { - if (e.key === 'Enter') { - e.stopPropagation() - handleAiQuery() - } -} +/** AI 查询示例(浮窗示例下拉) */ +const aiQueryExamples = computed(() => t('ai.query.examples').split('|')) /** 将时间戳格式化为 yyyy-MM-dd */ function formatDateLabel(ts: number) { @@ -2029,7 +1994,6 @@ async function doExport() { onMounted(() => { loadFilterPrefs() remarkHistory.load() - aiQueryHistory.load() loadAiConfig() loadExcludedDisplayStyle() fetchCategories() @@ -2531,42 +2495,19 @@ onBeforeUnmount(() => { - - - - {{ t('ai.query.hint') }} - - - - - - - - {{ t('transaction.exampleLabel') }} - - - - {{ t('common.cancel') }} - {{ t('ai.query.button') }} - - - - + +