feat: AI 智能查询改为可拖拽缩放的非模态浮窗并支持多行输入
- 统计面板与收支记录的 AI 智能查询由模态弹窗改为非模态浮窗,窗口打开时可继续操作主界面,无遮罩且不自动聚焦 - 新增通用浮窗组件 FloatingPanel,支持标题栏拖拽与右下角缩放,位置尺寸按页面持久化到 localStorage - 新增 AiQueryWindow 组件,输入由单行改为多行文本域,Enter 换行、Ctrl/Cmd+Enter 提交 - 历史记录聚焦时以浮层展示并可按输入过滤,忽略大小写去重、最多保留 200 条 - 提交查询后不关闭窗口,移除取消按钮,统一由右上角关闭按钮关闭 - 修复示例下拉层级低于浮窗被遮挡的问题,并在打开窗口时重置加载状态,避免按钮一直显示加载中 - 补充 close 与 ai.query.submitHint 的中文、英文、日文三语文案
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: 'フィルターを適用' },
|
||||
|
||||
@@ -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<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 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 () => {
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
<!-- AI 统计搜索弹窗(交互与账单列表 AI 查询一致) -->
|
||||
<NModal
|
||||
<!-- AI 智能查询浮窗(非模态、可拖拽缩放、多行输入) -->
|
||||
<AiQueryWindow
|
||||
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>
|
||||
: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"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<AiSuggestion[]>([]) // AI 建议列表
|
||||
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
|
||||
const aiQueryText = ref('') // AI 自然语言查询输入
|
||||
const aiQuerying = ref(false) // AI 查询解析状态
|
||||
const aiQueryResult = ref<AiQueryResult | null>(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(() => {
|
||||
</NSpace>
|
||||
</NModal>
|
||||
|
||||
<!-- AI 智能查询弹窗 -->
|
||||
<NModal v-if="aiEnabled" v-model:show="showAiQueryModal" 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="aiQueryHistory.options.value"
|
||||
:get-show="aiQueryHistory.showMenu"
|
||||
:render-label="aiQueryHistory.renderLabel"
|
||||
:input-props="{ onKeydown: handleAiQueryKeydown }"
|
||||
clearable
|
||||
:placeholder="t('ai.query.placeholder')"
|
||||
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>
|
||||
<!-- AI 智能查询浮窗(非模态、可拖拽缩放、多行输入) -->
|
||||
<AiQueryWindow
|
||||
v-if="aiEnabled"
|
||||
v-model:show="showAiQueryModal"
|
||||
:title="t('ai.query.title')"
|
||||
:hint="t('ai.query.hint')"
|
||||
:placeholder="t('ai.query.placeholder')"
|
||||
:examples="aiQueryExamples"
|
||||
:loading="aiQuerying"
|
||||
history-key="finance.transaction.aiQueryHistory"
|
||||
storage-key="finance.transaction.aiQueryWindow"
|
||||
@submit="handleAiQuery"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user