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: {
|
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: 'フィルターを適用' },
|
||||||
|
|||||||
@@ -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')"
|
||||||
>
|
|
||||||
<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')"
|
:placeholder="t('dashboard.aiSearchPlaceholder')"
|
||||||
style="width: 100%;"
|
:examples="aiExamples"
|
||||||
@update:value="aiSearchHistory.handleInput"
|
:loading="aiQuerying"
|
||||||
@select="handleAiSearchSelect"
|
history-key="finance.dashboard.aiSearchHistory"
|
||||||
>
|
storage-key="finance.dashboard.aiQueryWindow"
|
||||||
<template #prefix>
|
@submit="handleAiStatistics"
|
||||||
<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>
|
||||||
|
|||||||
@@ -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"
|
|
||||||
:get-show="aiQueryHistory.showMenu"
|
|
||||||
:render-label="aiQueryHistory.renderLabel"
|
|
||||||
:input-props="{ onKeydown: handleAiQueryKeydown }"
|
|
||||||
clearable
|
|
||||||
:placeholder="t('ai.query.placeholder')"
|
:placeholder="t('ai.query.placeholder')"
|
||||||
style="width: 100%;"
|
:examples="aiQueryExamples"
|
||||||
@update:value="aiQueryHistory.handleInput"
|
:loading="aiQuerying"
|
||||||
@select="handleAiQuerySelect"
|
history-key="finance.transaction.aiQueryHistory"
|
||||||
>
|
storage-key="finance.transaction.aiQueryWindow"
|
||||||
<template #prefix>
|
@submit="handleAiQuery"
|
||||||
<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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user