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>
|
||||
Reference in New Issue
Block a user