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>