feat: AI 智能查询改为可拖拽缩放的非模态浮窗并支持多行输入

- 统计面板与收支记录的 AI 智能查询由模态弹窗改为非模态浮窗,窗口打开时可继续操作主界面,无遮罩且不自动聚焦
- 新增通用浮窗组件 FloatingPanel,支持标题栏拖拽与右下角缩放,位置尺寸按页面持久化到 localStorage
- 新增 AiQueryWindow 组件,输入由单行改为多行文本域,Enter 换行、Ctrl/Cmd+Enter 提交
- 历史记录聚焦时以浮层展示并可按输入过滤,忽略大小写去重、最多保留 200 条
- 提交查询后不关闭窗口,移除取消按钮,统一由右上角关闭按钮关闭
- 修复示例下拉层级低于浮窗被遮挡的问题,并在打开窗口时重置加载状态,避免按钮一直显示加载中
- 补充 close 与 ai.query.submitHint 的中文、英文、日文三语文案
This commit is contained in:
2026-09-21 23:03:53 +08:00
parent f390246528
commit be7e27805d
5 changed files with 589 additions and 163 deletions
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
// AI 智能查询浮窗:多行输入、历史记录下拉、示例选择,非模态可拖拽缩放
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NAlert, NButton, NDropdown, NIcon, NInput, NSpace, useMessage } from 'naive-ui'
import { BulbOutline, CloseCircleOutline } from '@vicons/ionicons5'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import FloatingPanel from './FloatingPanel.vue'
const props = withDefaults(defineProps<{
/** 是否显示 */
show: boolean
/** 标题 */
title: string
/** 顶部提示文案 */
hint: string
/** 输入框占位文案 */
placeholder: string
/** 示例列表 */
examples: string[]
/** 查询进行中 */
loading?: boolean
/** 历史记录持久化键 */
historyKey: string
/** 浮窗位置尺寸持久化键 */
storageKey: string
/** 默认宽度 */
defaultWidth?: number
/** 默认高度 */
defaultHeight?: number
}>(), {
loading: false,
defaultWidth: 560,
defaultHeight: 360,
})
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
(e: 'submit', text: string): void
}>()
const { t } = useI18n()
const message = useMessage()
// 当前输入内容
const text = ref('')
// 历史记录(持久化、去重、清空,最多保留 200 条)
const historyStore = useKeywordHistory({
storageKey: props.historyKey,
text,
clearLabel: () => t('ai.query.clearHistory'),
max: 200,
})
// 历史下拉是否展示
const showHistory = ref(false)
// 输入框容器引用(用于定位历史下拉)
const fieldRef = ref<HTMLElement | null>(null)
// 历史下拉的固定定位样式
const dropdownStyle = ref<Record<string, string>>({})
onMounted(() => historyStore.load())
/** 历史下拉选项:按输入过滤,末尾追加「清空历史」 */
const historyOptions = computed(() => {
const keyword = text.value.trim().toLowerCase()
const list = historyStore.history.value
.filter((item) => item.toLowerCase().includes(keyword))
.map((item) => ({ label: item, value: item }))
if (historyStore.history.value.length > 0) {
list.push({ label: historyStore.clearValue, value: historyStore.clearValue })
}
return list
})
// 有历史且获得焦点时才展示下拉
const showHistoryMenu = computed(() => showHistory.value && historyOptions.value.length > 0)
// 下拉展示时校正定位
watch(showHistoryMenu, (visible) => {
if (visible) updateDropdownPosition()
})
/** 示例下拉选项 */
const exampleOptions = computed(() => props.examples.map((example) => ({ label: example, key: example })))
/** 依据输入框位置更新历史下拉的固定定位 */
async function updateDropdownPosition() {
await nextTick()
const el = fieldRef.value
if (!el) return
const rect = el.getBoundingClientRect()
dropdownStyle.value = {
left: `${rect.left}px`,
top: `${rect.bottom + 4}px`,
width: `${rect.width}px`,
}
}
/** 聚焦时展开历史下拉 */
function openHistory() {
showHistory.value = true
updateDropdownPosition()
}
/** 失焦时收起历史下拉 */
function closeHistory() {
showHistory.value = false
}
/** 选择历史项:清空历史,或填入并立即提交 */
function handleHistorySelect(value: string) {
showHistory.value = false
if (value === historyStore.clearValue) {
historyStore.clear()
return
}
text.value = value
submit()
}
/** 选择示例后填入输入框 */
function handleExampleSelect(key: string) {
text.value = key
}
/** 提交查询:校验、记录历史并抛出事件 */
function submit() {
if (props.loading) return
const value = text.value.trim()
if (!value) {
message.warning(t('ai.query.emptyInput'))
return
}
historyStore.record()
showHistory.value = false
emit('submit', value)
}
/** 快捷键:Ctrl/Cmd + Enter 提交,单独 Enter 换行 */
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault()
submit()
}
}
</script>
<template>
<FloatingPanel
:show="show"
:title="title"
:storage-key="storageKey"
:default-width="defaultWidth"
:default-height="defaultHeight"
:min-width="380"
:min-height="260"
@update:show="emit('update:show', $event)"
>
<NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">{{ hint }}</NAlert>
<div ref="fieldRef" class="ai-query__field">
<NInput
v-model:value="text"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
:placeholder="placeholder"
:disabled="loading"
@focus="openHistory"
@blur="closeHistory"
@keydown="handleKeydown"
/>
<Teleport to="body">
<div v-if="showHistoryMenu" class="ai-query__history" :style="dropdownStyle" @mousedown.prevent>
<div
v-for="option in historyOptions"
:key="option.value"
class="ai-query__history-item"
:class="{ 'is-clear': option.value === historyStore.clearValue }"
@click="handleHistorySelect(option.value)"
>
<template v-if="option.value === historyStore.clearValue">
<NIcon :size="16" :component="CloseCircleOutline" />
<span>{{ t('ai.query.clearHistory') }}</span>
</template>
<template v-else>{{ option.label }}</template>
</div>
</div>
</Teleport>
</div>
<NSpace justify="space-between" align="center">
<NDropdown trigger="click" :options="exampleOptions" :z-index="3300" @select="handleExampleSelect">
<NButton text type="primary" size="small">
<template #icon>
<NIcon :component="BulbOutline" />
</template>
{{ t('transaction.exampleLabel') }}
</NButton>
</NDropdown>
<span class="ai-query__tip">{{ t('ai.query.submitHint') }}</span>
</NSpace>
</NSpace>
<template #footer>
<NSpace justify="end">
<NButton type="primary" :loading="loading" @click="submit">{{ t('ai.query.button') }}</NButton>
</NSpace>
</template>
</FloatingPanel>
</template>
<style scoped>
.ai-query__field {
position: relative;
}
.ai-query__history {
position: fixed;
z-index: 3100;
max-height: 220px;
overflow-y: auto;
padding: 4px;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.14);
}
.ai-query__history-item {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 10px;
border-radius: 4px;
font-size: 13px;
color: #333;
cursor: pointer;
word-break: break-all;
}
.ai-query__history-item:hover {
background: #f2f6fc;
}
.ai-query__history-item.is-clear {
color: #999;
}
.ai-query__tip {
font-size: 12px;
color: #999;
}
</style>
+300
View File
@@ -0,0 +1,300 @@
<script setup lang="ts">
// 通用浮窗组件:非模态、可拖拽、右下角可缩放,位置与尺寸可持久化记忆
import { ref, watch, onBeforeUnmount, nextTick } from 'vue'
import { NButton, NIcon } from 'naive-ui'
import { CloseOutline } from '@vicons/ionicons5'
/** 浮窗位置与尺寸 */
interface PanelRect {
x: number
y: number
width: number
height: number
}
const props = withDefaults(defineProps<{
/** 是否显示 */
show: boolean
/** 标题栏文案 */
title?: string
/** 位置尺寸持久化键,为空则不持久化 */
storageKey?: string
/** 默认宽度 */
defaultWidth?: number
/** 默认高度 */
defaultHeight?: number
/** 最小宽度 */
minWidth?: number
/** 最小高度 */
minHeight?: number
}>(), {
title: '',
storageKey: '',
defaultWidth: 560,
defaultHeight: 360,
minWidth: 360,
minHeight: 240,
})
const emit = defineEmits<{
(e: 'update:show', value: boolean): void
}>()
// 浮窗距视口边缘的最小间距
const MARGIN = 8
const rect = ref<PanelRect>({ x: 0, y: 0, width: props.defaultWidth, height: props.defaultHeight })
/** 计算居中显示的默认位置 */
function defaultRect(): PanelRect {
const width = props.defaultWidth
const height = props.defaultHeight
return {
x: Math.max(MARGIN, (window.innerWidth - width) / 2),
y: Math.max(MARGIN, (window.innerHeight - height) / 3),
width,
height,
}
}
/** 将浮窗限制在视口内 */
function clampRect() {
const maxX = Math.max(MARGIN, window.innerWidth - rect.value.width - MARGIN)
const maxY = Math.max(MARGIN, window.innerHeight - rect.value.height - MARGIN)
rect.value.x = Math.min(Math.max(MARGIN, rect.value.x), maxX)
rect.value.y = Math.min(Math.max(MARGIN, rect.value.y), maxY)
}
/** 读取持久化的位置与尺寸,非法值回退默认并居中 */
function loadRect() {
if (props.storageKey) {
try {
const raw = localStorage.getItem(props.storageKey)
const parsed = raw ? JSON.parse(raw) : null
const x = Number(parsed?.x)
const y = Number(parsed?.y)
const width = Number(parsed?.width)
const height = Number(parsed?.height)
if ([x, y, width, height].every((n) => Number.isFinite(n))) {
rect.value = {
x,
y,
width: Math.max(props.minWidth, width),
height: Math.max(props.minHeight, height),
}
clampRect()
return
}
} catch {
// 忽略读取失败
}
}
rect.value = defaultRect()
}
/** 持久化位置与尺寸,忽略存储异常 */
function saveRect() {
if (!props.storageKey) return
try {
localStorage.setItem(props.storageKey, JSON.stringify(rect.value))
} catch {
// 忽略写入失败
}
}
// 拖拽状态
let dragging = false
let dragStartX = 0
let dragStartY = 0
let dragOriginX = 0
let dragOriginY = 0
/** 标题栏按下:开始拖拽 */
function handleDragStart(e: PointerEvent) {
if (e.button !== 0) return
dragging = true
dragStartX = e.clientX
dragStartY = e.clientY
dragOriginX = rect.value.x
dragOriginY = rect.value.y
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
window.addEventListener('pointermove', handleDragMove)
window.addEventListener('pointerup', handleDragEnd)
}
/** 拖拽移动 */
function handleDragMove(e: PointerEvent) {
if (!dragging) return
rect.value.x = dragOriginX + (e.clientX - dragStartX)
rect.value.y = dragOriginY + (e.clientY - dragStartY)
clampRect()
}
/** 拖拽结束并持久化 */
function handleDragEnd() {
if (!dragging) return
dragging = false
window.removeEventListener('pointermove', handleDragMove)
window.removeEventListener('pointerup', handleDragEnd)
saveRect()
}
// 缩放状态
let resizing = false
let resizeStartX = 0
let resizeStartY = 0
let resizeOriginW = 0
let resizeOriginH = 0
/** 右下角手柄按下:开始缩放 */
function handleResizeStart(e: PointerEvent) {
if (e.button !== 0) return
resizing = true
resizeStartX = e.clientX
resizeStartY = e.clientY
resizeOriginW = rect.value.width
resizeOriginH = rect.value.height
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
window.addEventListener('pointermove', handleResizeMove)
window.addEventListener('pointerup', handleResizeEnd)
}
/** 缩放移动:限制最小尺寸与视口边界 */
function handleResizeMove(e: PointerEvent) {
if (!resizing) return
const maxW = Math.max(props.minWidth, window.innerWidth - rect.value.x - MARGIN)
const maxH = Math.max(props.minHeight, window.innerHeight - rect.value.y - MARGIN)
rect.value.width = Math.min(Math.max(props.minWidth, resizeOriginW + (e.clientX - resizeStartX)), maxW)
rect.value.height = Math.min(Math.max(props.minHeight, resizeOriginH + (e.clientY - resizeStartY)), maxH)
}
/** 缩放结束并持久化 */
function handleResizeEnd() {
if (!resizing) return
resizing = false
window.removeEventListener('pointermove', handleResizeMove)
window.removeEventListener('pointerup', handleResizeEnd)
saveRect()
}
/** 视口尺寸变化时重新钳制位置 */
function handleWindowResize() {
clampRect()
}
/** 关闭浮窗 */
function handleClose() {
emit('update:show', false)
}
watch(() => props.show, async (visible) => {
if (visible) {
loadRect()
await nextTick()
window.addEventListener('resize', handleWindowResize)
} else {
window.removeEventListener('resize', handleWindowResize)
}
}, { immediate: true })
onBeforeUnmount(() => {
window.removeEventListener('pointermove', handleDragMove)
window.removeEventListener('pointerup', handleDragEnd)
window.removeEventListener('pointermove', handleResizeMove)
window.removeEventListener('pointerup', handleResizeEnd)
window.removeEventListener('resize', handleWindowResize)
})
</script>
<template>
<Teleport to="body">
<div
v-if="show"
class="floating-panel"
:style="{ left: rect.x + 'px', top: rect.y + 'px', width: rect.width + 'px', height: rect.height + 'px' }"
>
<div class="floating-panel__header" @pointerdown="handleDragStart">
<span class="floating-panel__title">{{ title }}</span>
<slot name="header-extra" />
<NButton
quaternary
size="small"
class="floating-panel__close"
:title="$t('common.close')"
@pointerdown.stop
@click="handleClose"
>
<template #icon>
<NIcon :component="CloseOutline" />
</template>
</NButton>
</div>
<div class="floating-panel__body">
<slot />
</div>
<div v-if="$slots.footer" class="floating-panel__footer">
<slot name="footer" />
</div>
<div class="floating-panel__resize" @pointerdown="handleResizeStart"></div>
</div>
</Teleport>
</template>
<style scoped>
.floating-panel {
position: fixed;
z-index: 3000;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 8px;
box-shadow: 0 6px 32px rgba(0, 0, 0, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.04);
overflow: hidden;
}
.floating-panel__header {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
height: 44px;
padding: 0 8px 0 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
cursor: move;
user-select: none;
touch-action: none;
}
.floating-panel__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
font-weight: 600;
color: #333;
}
.floating-panel__close {
flex-shrink: 0;
}
.floating-panel__body {
flex: 1;
min-height: 0;
padding: 16px;
overflow: auto;
}
.floating-panel__footer {
flex-shrink: 0;
padding: 10px 16px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
background: #fafafc;
}
.floating-panel__resize {
position: absolute;
right: 0;
bottom: 0;
width: 16px;
height: 16px;
cursor: nwse-resize;
touch-action: none;
background: linear-gradient(135deg, transparent 50%, rgba(0, 0, 0, 0.18) 50%);
}
</style>
+2
View File
@@ -11,6 +11,7 @@ export default {
common: { common: {
confirm: { zh: '确定', en: 'Confirm', ja: '確認' }, confirm: { zh: '确定', en: 'Confirm', ja: '確認' },
cancel: { zh: '取消', en: 'Cancel', ja: 'キャンセル' }, cancel: { zh: '取消', en: 'Cancel', ja: 'キャンセル' },
close: { zh: '关闭', en: 'Close', ja: '閉じる' },
delete: { zh: '删除', en: 'Delete', ja: '削除' }, delete: { zh: '删除', en: 'Delete', ja: '削除' },
edit: { zh: '编辑', en: 'Edit', ja: '編集' }, edit: { zh: '编辑', en: 'Edit', ja: '編集' },
operation: { zh: '操作', en: 'Actions', 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 がフィルターに変換して検索します。' }, 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: '検索内容を入力してください' }, emptyInput: { zh: '请输入查询内容', en: 'Please enter a query', ja: '検索内容を入力してください' },
clearHistory: { zh: '清空历史', en: 'Clear history', 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 検索の解析に失敗しました' }, failed: { zh: 'AI 查询解析失败', en: 'Failed to parse the query', ja: 'AI 検索の解析に失敗しました' },
resultTitle: { zh: 'AI 解析结果', en: 'AI Query Result', ja: 'AI 解析結果' }, resultTitle: { zh: 'AI 解析结果', en: 'AI Query Result', ja: 'AI 解析結果' },
apply: { zh: '应用筛选', en: 'Apply filters', ja: 'フィルターを適用' }, apply: { zh: '应用筛选', en: 'Apply filters', ja: 'フィルターを適用' },
+21 -83
View File
@@ -3,17 +3,17 @@
import { ref, computed, onMounted, watch, nextTick, unref, defineAsyncComponent } from 'vue' import { ref, computed, onMounted, watch, nextTick, unref, defineAsyncComponent } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' 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 { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton, NIcon, useMessage } from 'naive-ui'
import { SparklesOutline, BulbOutline } from '@vicons/ionicons5' import { SparklesOutline } from '@vicons/ionicons5'
import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics' import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
import { getCategoryList } from '../api/category' import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag' import { getTagList } from '../api/tag'
import { getChannels } from '../api/transaction' import { getChannels } from '../api/transaction'
import { getAiConfig, parseStatisticsByAi } from '../api/ai' import { getAiConfig, parseStatisticsByAi } from '../api/ai'
import type { AiStatisticsResult } from '../api/ai' import type { AiStatisticsResult } from '../api/ai'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import { getChannelLabel } from '../utils/channel' import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render' import { renderCategoryLabel } from '../utils/render'
import AiQueryWindow from '../components/AiQueryWindow.vue'
// 图表组件异步加载:echarts 体积较大,登录后进入面板时先渲染页面骨架, // 图表组件异步加载:echarts 体积较大,登录后进入面板时先渲染页面骨架,
// echarts 在后台按需下载并注册,避免阻塞目标页首屏 // echarts 在后台按需下载并注册,避免阻塞目标页首屏
@@ -74,25 +74,17 @@ const filterRemark = ref('') // 备注关键字
// AI 统计搜索状态 // AI 统计搜索状态
const aiEnabled = ref(false) const aiEnabled = ref(false)
const aiQueryText = ref('')
const aiQuerying = ref(false) const aiQuerying = ref(false)
const aiSearchResult = ref<AiStatisticsResult | null>(null) const aiSearchResult = ref<AiStatisticsResult | null>(null)
const showAiSearchModal = ref(false) const showAiSearchModal = ref(false)
// AI 搜索示例(下拉选择后填入输入框) // AI 搜索示例(浮窗示例下拉)
const aiExampleOptions = computed(() => [ const aiExamples = computed(() => [
{ label: t('dashboard.aiExample1'), key: t('dashboard.aiExample1') }, t('dashboard.aiExample1'),
{ label: t('dashboard.aiExample2'), key: t('dashboard.aiExample2') }, t('dashboard.aiExample2'),
{ label: t('dashboard.aiExample3'), key: t('dashboard.aiExample3') }, t('dashboard.aiExample3'),
]) ])
// AI 搜索历史记录(与账单列表一致:持久化、聚焦展示、可清空)
const aiSearchHistory = useKeywordHistory({
storageKey: 'finance.dashboard.aiSearchHistory',
text: aiQueryText,
clearLabel: () => t('ai.query.clearHistory'),
})
// 统计口径选项:仅计入 / 全部 / 仅不计入 // 统计口径选项:仅计入 / 全部 / 仅不计入
const statsScopeOptions = computed(() => [ const statsScopeOptions = computed(() => [
{ label: t('dashboard.statsScope.included'), value: 'INCLUDED' }, { label: t('dashboard.statsScope.included'), value: 'INCLUDED' },
@@ -605,21 +597,21 @@ function handleAiSearchOpen() {
message.warning(t('ai.needConfig')) message.warning(t('ai.needConfig'))
return return
} }
// 打开时清除上一次可能残留的加载状态,避免按钮一直显示加载中
aiQuerying.value = false
showAiSearchModal.value = true showAiSearchModal.value = true
} }
/** 提交自然语言统计搜索 */ /** 提交自然语言统计搜索 */
async function handleAiStatistics() { async function handleAiStatistics(text: string) {
if (!aiEnabled.value) { if (!aiEnabled.value) {
message.warning(t('ai.needConfig')) message.warning(t('ai.needConfig'))
return return
} }
const text = aiQueryText.value.trim()
if (!text) { if (!text) {
message.warning(t('ai.query.emptyInput')) message.warning(t('ai.query.emptyInput'))
return return
} }
aiSearchHistory.record()
aiQuerying.value = true aiQuerying.value = true
try { try {
const res: any = await parseStatisticsByAi(text) const res: any = await parseStatisticsByAi(text)
@@ -630,7 +622,6 @@ async function handleAiStatistics() {
return return
} }
await applyAiStatistics(aiSearchResult.value as AiStatisticsResult) await applyAiStatistics(aiSearchResult.value as AiStatisticsResult)
showAiSearchModal.value = false
message.success(aiSearchResult.value?.summary || t('dashboard.aiSearchApplied')) message.success(aiSearchResult.value?.summary || t('dashboard.aiSearchApplied'))
} catch (e: any) { } catch (e: any) {
message.error(e.message || t('dashboard.aiSearchFailed')) 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() { function handleResetFilters() {
filterStatsScope.value = 'INCLUDED' filterStatsScope.value = 'INCLUDED'
@@ -701,7 +669,6 @@ onMounted(async () => {
loadChannels() loadChannels()
loadTags() loadTags()
loadAiConfig() loadAiConfig()
aiSearchHistory.load()
await nextTick() await nextTick()
suppressRangeWatch = false suppressRangeWatch = false
fetchData() fetchData()
@@ -902,47 +869,18 @@ onMounted(async () => {
</NGridItem> </NGridItem>
</NGrid> </NGrid>
<!-- AI 统计搜索弹窗(交互与账单列表 AI 查询一致) --> <!-- AI 智能查询浮窗(非模态、可拖拽缩放、多行输入) -->
<NModal <AiQueryWindow
v-if="aiEnabled" v-if="aiEnabled"
v-model:show="showAiSearchModal" v-model:show="showAiSearchModal"
preset="card"
:title="t('ai.query.title')" :title="t('ai.query.title')"
style="width: 520px; max-width: 92vw;" :hint="t('ai.query.hint')"
> :placeholder="t('dashboard.aiSearchPlaceholder')"
<NSpace vertical :size="12"> :examples="aiExamples"
<NAlert type="info" :show-icon="true">{{ t('ai.query.hint') }}</NAlert> :loading="aiQuerying"
<NAutoComplete history-key="finance.dashboard.aiSearchHistory"
:value="aiQueryText" storage-key="finance.dashboard.aiQueryWindow"
:options="aiSearchHistory.options.value" @submit="handleAiStatistics"
: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> </div>
</template> </template>
+21 -80
View File
@@ -3,8 +3,8 @@
import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue' import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router' 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 { 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, BulbOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5' import { EyeOutline, EyeOffOutline, SparklesOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui' import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions, batchCreateTransactions } from '../api/transaction' 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' 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 TransactionDetailModal from '../components/TransactionDetailModal.vue'
import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue' import TransactionRelationEditorModal from '../components/TransactionRelationEditorModal.vue'
import TransactionRelationModal from '../components/TransactionRelationModal.vue' import TransactionRelationModal from '../components/TransactionRelationModal.vue'
import AiQueryWindow from '../components/AiQueryWindow.vue'
import type { RelationMemberDraft } from '../api/transactionGroup' import type { RelationMemberDraft } from '../api/transactionGroup'
const message = useMessage() // 消息提示 const message = useMessage() // 消息提示
@@ -187,18 +188,9 @@ const pendingTag = ref<{ name: string; color: string } | null>(null)
const aiApplying = ref(false) // AI 建议应用状态 const aiApplying = ref(false) // AI 建议应用状态
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表 const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
const aiQueryText = ref('') // AI 自然语言查询输入
const aiQuerying = ref(false) // AI 查询解析状态 const aiQuerying = ref(false) // AI 查询解析状态
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果 const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
const showAiQueryModal = ref(false) // 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 showAiRecordModal = ref(false) // AI 记账弹窗显示状态
const aiRecordText = ref('') // AI 记账输入 const aiRecordText = ref('') // AI 记账输入
const aiRecording = ref(false) // AI 记账解析状态 const aiRecording = ref(false) // AI 记账解析状态
@@ -1625,21 +1617,21 @@ function handleAiQueryOpen() {
message.warning(t('ai.needConfig')) message.warning(t('ai.needConfig'))
return return
} }
// 打开时清除上一次可能残留的加载状态,避免按钮一直显示加载中
aiQuerying.value = false
showAiQueryModal.value = true showAiQueryModal.value = true
} }
/** 请求 AI 解析自然语言查询 */ /** 请求 AI 解析自然语言查询 */
async function handleAiQuery() { async function handleAiQuery(text: string) {
if (!aiEnabled.value) { if (!aiEnabled.value) {
message.warning(t('ai.needConfig')) message.warning(t('ai.needConfig'))
return return
} }
const text = aiQueryText.value.trim()
if (!text) { if (!text) {
message.warning(t('ai.query.emptyInput')) message.warning(t('ai.query.emptyInput'))
return return
} }
aiQueryHistory.record()
aiQuerying.value = true aiQuerying.value = true
try { try {
const res: any = await queryByAi(text) const res: any = await queryByAi(text)
@@ -1651,7 +1643,6 @@ async function handleAiQuery() {
} }
// 直接应用解析结果并搜索,无需二次确认 // 直接应用解析结果并搜索,无需二次确认
applyAiQuery() applyAiQuery()
showAiQueryModal.value = false
} catch (e: any) { } catch (e: any) {
message.error(e.message || t('ai.query.failed')) message.error(e.message || t('ai.query.failed'))
} finally { } finally {
@@ -1697,34 +1688,8 @@ const aiQueryEmpty = computed(() => {
&& r.includeInStats == null && r.includeInStats == null
}) })
/** AI 查询示例(从 i18n 拆分为数组) */ /** AI 查询示例(浮窗示例下拉) */
const queryExamples = computed(() => t('ai.query.examples').split('|')) const aiQueryExamples = 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()
}
}
/** 将时间戳格式化为 yyyy-MM-dd */ /** 将时间戳格式化为 yyyy-MM-dd */
function formatDateLabel(ts: number) { function formatDateLabel(ts: number) {
@@ -2029,7 +1994,6 @@ async function doExport() {
onMounted(() => { onMounted(() => {
loadFilterPrefs() loadFilterPrefs()
remarkHistory.load() remarkHistory.load()
aiQueryHistory.load()
loadAiConfig() loadAiConfig()
loadExcludedDisplayStyle() loadExcludedDisplayStyle()
fetchCategories() fetchCategories()
@@ -2531,42 +2495,19 @@ onBeforeUnmount(() => {
</NSpace> </NSpace>
</NModal> </NModal>
<!-- AI 智能查询弹窗 --> <!-- AI 智能查询浮窗(非模态、可拖拽缩放、多行输入) -->
<NModal v-if="aiEnabled" v-model:show="showAiQueryModal" preset="card" :title="t('ai.query.title')" style="width: 520px; max-width: 92vw;"> <AiQueryWindow
<NSpace vertical :size="12"> v-if="aiEnabled"
<NAlert type="info" :show-icon="true">{{ t('ai.query.hint') }}</NAlert> v-model:show="showAiQueryModal"
<NAutoComplete :title="t('ai.query.title')"
:value="aiQueryText" :hint="t('ai.query.hint')"
:options="aiQueryHistory.options.value" :placeholder="t('ai.query.placeholder')"
:get-show="aiQueryHistory.showMenu" :examples="aiQueryExamples"
:render-label="aiQueryHistory.renderLabel" :loading="aiQuerying"
:input-props="{ onKeydown: handleAiQueryKeydown }" history-key="finance.transaction.aiQueryHistory"
clearable storage-key="finance.transaction.aiQueryWindow"
:placeholder="t('ai.query.placeholder')" @submit="handleAiQuery"
style="width: 100%;" />
@update:value="aiQueryHistory.handleInput"
@select="handleAiQuerySelect"
>
<template #prefix>
<NIcon :component="SparklesOutline" />
</template>
</NAutoComplete>
<NSpace justify="space-between" align="center">
<NDropdown trigger="click" :options="exampleOptions" @select="handleExampleSelect">
<NButton text type="primary" size="small">
<template #icon>
<NIcon :component="BulbOutline" />
</template>
{{ t('transaction.exampleLabel') }}
</NButton>
</NDropdown>
<NSpace>
<NButton @click="showAiQueryModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiQuerying" @click="handleAiQuery">{{ t('ai.query.button') }}</NButton>
</NSpace>
</NSpace>
</NSpace>
</NModal>
</div> </div>
</template> </template>