feat: 智能查数支持提问历史持久化与体验优化

为智能查数补充提问历史的后端持久化(含创建时间、可清空),并完善前端历史展示、会话缓存与若干体验问题。

变更内容:
- 新增 t_ai_sql_history 表(MySQL/SQLite 双脚本)与 AiSqlHistory 实体、Mapper、Service、VO,记录提问内容与创建时间
- AiSqlQueryServiceImpl 在查询成功后写入提问历史,新增 GET/DELETE /api/ai/sql/history 接口,支持按创建时间倒序查询与清空
- 前端历史改为后端持久化:卡片式列表、展示创建时间、搜索过滤、一键复制提问、点击回填重查与清空
- 新增会话缓存(sessionStorage):切走再回或刷新后恢复上次输入、结果与视图模式,结果超过 500 行时不缓存结果,登出时清除

体验修复:
- 修复柱状图与饼图切换时旧 series/坐标轴残留(VChart 采用 notMerge)
- 修复进入 /smart-query 时短暂误显示「AI 未启用」(新增 aiEnabledLoaded 加载态)
- 引入 JetBrains Mono 字体并修复生成的 SQL 字体未生效(内部 code 元素继承父级字体)

依赖:
- 新增前端依赖 @fontsource/jetbrains-mono

说明:
- 历史表由 SchemaInitializer 启动时幂等创建,无需手工执行 SQL
This commit is contained in:
2026-09-24 12:30:00 +08:00
parent 49d16bec82
commit feb56c8a8d
18 changed files with 527 additions and 39 deletions
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/jetbrains-mono": "^5.3.0",
"@vicons/ionicons5": "^0.13.0",
"axios": "^1.20.0",
"echarts": "^6.1.0",
+17
View File
@@ -264,6 +264,23 @@ export function queryBySql(query: string) {
return request.post('/ai/sql', { query }, { timeout: 120000 })
}
/** 智能查数历史记录 */
export interface AiSqlHistoryItem {
id: number
query: string
createdAt: string
}
/** 获取智能查数历史(按创建时间倒序) */
export function getSqlHistory(limit = 100) {
return request.get('/ai/sql/history', { params: { limit } })
}
/** 清空智能查数历史 */
export function clearSqlHistory() {
return request.delete('/ai/sql/history')
}
/** 自然语言记账解析结果 */
export interface AiRecordResult {
summary: string
+5 -5
View File
@@ -5,8 +5,8 @@ import { getAiConfig } from '../api/ai'
/** 当前用户 AI 是否启用;读取失败一律按未启用处理 */
export const aiEnabled = ref(false)
/** 是否已成功加载过一次,避免重复请求 */
let loaded = false
/** AI 启用状态是否已确定(首次读取完成前为 false,用于避免误显示「未启用」) */
export const aiEnabledLoaded = ref(false)
/**
* 从后端刷新 AI 启用状态。
@@ -19,17 +19,17 @@ export async function refreshAiEnabled() {
} catch (e) {
aiEnabled.value = false
}
loaded = true
aiEnabledLoaded.value = true
}
/** 确保至少加载过一次 AI 启用状态(已加载则不重复请求) */
export async function ensureAiEnabledLoaded() {
if (loaded) return
if (aiEnabledLoaded.value) return
await refreshAiEnabled()
}
/** 重置为未启用(退出登录等场景使用) */
export function resetAiEnabled() {
aiEnabled.value = false
loaded = false
aiEnabledLoaded.value = false
}
+3
View File
@@ -883,6 +883,9 @@ export default {
examplesLabel: { zh: '示例', en: 'Examples', ja: '例' },
history: { zh: '历史提问', en: 'History', ja: '履歴' },
clearHistory: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' },
copyQuestion: { zh: '复制提问', en: 'Copy question', ja: '質問をコピー' },
historySearch: { zh: '搜索历史提问', en: 'Search history', ja: '履歴を検索' },
historyNoMatch: { zh: '无匹配结果', en: 'No matching history', ja: '一致する履歴がありません' },
needConfig: { zh: 'AI 未启用,请先在「设置」中开启 AI。', en: 'AI is not enabled. Please enable it in Settings first.', ja: 'AI が有効になっていません。「設定」で有効にしてください。' },
},
record: {
+4 -1
View File
@@ -2,7 +2,8 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getUserInfo } from '../api/user'
import { readString, removeKey, writeString } from '../utils/storage'
import { readString, removeKey, removeSessionKey, writeString } from '../utils/storage'
import { STORAGE_KEYS } from '../utils/storageKeys'
/** 登录令牌存储键(认证凭据,不属于界面偏好,不纳入 ledgerly. 前缀) */
const TOKEN_KEY = 'token'
@@ -41,6 +42,8 @@ export const useUserStore = defineStore('user', () => {
nickname.value = ''
userId.value = null
removeKey(TOKEN_KEY)
// 清除智能查数上一次查询会话,避免同标签切换账号后看到上一用户的数据
removeSessionKey(STORAGE_KEYS.sqlQuerySession)
}
return { token, nickname, userId, setToken, setUserInfo, fetchUserInfo, logout }
+28
View File
@@ -64,6 +64,34 @@ export function writeSessionString(key: string, value: string): void {
}
}
/** 读取 sessionStorage JSON,解析失败或读取异常时回退 fallback */
export function readSessionJson<T>(key: string, fallback: T): T {
try {
const raw = sessionStorage.getItem(key)
return raw ? (JSON.parse(raw) as T) : fallback
} catch (e) {
return fallback
}
}
/** 写入 sessionStorage JSON,忽略存储异常 */
export function writeSessionJson(key: string, value: unknown): void {
try {
sessionStorage.setItem(key, JSON.stringify(value))
} catch (e) {
// 忽略写入失败
}
}
/** 删除 sessionStorage 键,忽略存储异常 */
export function removeSessionKey(key: string): void {
try {
sessionStorage.removeItem(key)
} catch (e) {
// 忽略删除失败
}
}
/**
* 迁移历史存储键:新键不存在且旧键存在时,将旧值搬运到新键并删除旧键。
*
+2 -2
View File
@@ -33,8 +33,8 @@ export const STORAGE_KEYS = {
transactionAiQueryHistory: 'ledgerly.transaction.aiQueryHistory',
// 收支记录 AI 查询浮窗位置尺寸
transactionAiQueryWindow: 'ledgerly.transaction.aiQueryWindow',
// 智能查数的历史提问
sqlQueryHistory: 'ledgerly.sqlQuery.history',
// 智能查数上一次查询会话(sessionStorage,含输入与结果缓存)
sqlQuerySession: 'ledgerly.sqlQuery.session',
// 内容区滚动位置(sessionStorage,按路由拼接后缀)
scrollPrefix: 'ledgerly.scroll.',
} as const
+208 -30
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
// 智能查数页:将自然语言交给大模型生成只读 SQL 并执行,自适应渲染统计卡片、表格与图表
import { computed, defineAsyncComponent, h, onMounted, ref } from 'vue'
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import {
NAlert, NButton, NCard, NCollapse, NCollapseItem, NDataTable, NEmpty, NGrid, NGridItem,
NIcon, NInput, NSelect, NSpace, NStatistic, NText, useMessage,
NIcon, NInput, NSpin, NSpace, NStatistic, NText, useMessage,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { CopyOutline, SearchOutline, SparklesOutline } from '@vicons/ionicons5'
@@ -12,14 +12,17 @@ import { format } from 'sql-formatter'
import hljs from 'highlight.js/lib/core'
import sql from 'highlight.js/lib/languages/sql'
import 'highlight.js/styles/github.css'
import { queryBySql } from '../api/ai'
import type { AiSqlColumn, AiSqlQueryResult } from '../api/ai'
import '@fontsource/jetbrains-mono/400.css'
import '@fontsource/jetbrains-mono/700.css'
import { queryBySql, getSqlHistory, clearSqlHistory } from '../api/ai'
import type { AiSqlColumn, AiSqlHistoryItem, AiSqlQueryResult } from '../api/ai'
import { getChannelLabel } from '../utils/channel'
import { aiEnabled, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import { aiEnabled, aiEnabledLoaded, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
import { useTablePageSize } from '../composables/useTablePageSize'
import { readSessionJson, writeSessionJson, removeSessionKey } from '../utils/storage'
import { STORAGE_KEYS } from '../utils/storageKeys'
import { useUserStore } from '../store/user'
// 图表组件异步加载:echarts 体积较大,按需加载,避免阻塞首屏
const VChart = defineAsyncComponent(async () => {
@@ -47,28 +50,61 @@ hljs.registerLanguage('sql', sql)
const { t } = useI18n()
const message = useMessage()
const userStore = useUserStore()
/** 展示视图类型 */
type ViewMode = 'table' | 'bar' | 'pie'
/** 上一次查询的会话缓存结构(sessionStorage) */
interface SqlQuerySession {
userId: number | null
query: string
result: AiSqlQueryResult | null
viewMode: ViewMode
}
/** 可缓存的最大结果行数,超过时只缓存输入与视图 */
const MAX_CACHED_ROWS = 500
const queryText = ref('')
const loading = ref(false)
const result = ref<AiSqlQueryResult | null>(null)
const resultVersion = ref(0)
const viewMode = ref<ViewMode>('table')
// 提问历史(持久化、去重、清空)
const { history, load: loadHistory, record: recordHistory, clear: clearHistory } = useKeywordHistory({
storageKey: STORAGE_KEYS.sqlQueryHistory,
text: queryText,
clearLabel: () => t('ai.sql.clearHistory'),
max: 50,
// 提问历史(后端持久化,含创建时间)
const history = ref<AiSqlHistoryItem[]>([])
/** 从后端加载提问历史 */
async function loadHistory() {
try {
const res: any = await getSqlHistory(100)
history.value = res.data || []
} catch (e) {
// 静默处理,历史加载失败不影响查询
}
}
/** 清空后端的提问历史 */
async function clearHistory() {
try {
await clearSqlHistory()
history.value = []
} catch (e: any) {
message.error(e.message || t('ai.sql.failed'))
}
}
// 历史搜索关键字与过滤结果(按提问内容忽略大小写匹配)
const historyKeyword = ref('')
const filteredHistory = computed(() => {
const keyword = historyKeyword.value.trim().toLowerCase()
if (!keyword) return history.value
return history.value.filter((item) => item.query.toLowerCase().includes(keyword))
})
// 示例问题
const exampleList = computed(() => t('ai.sql.examples').split('|').filter((item) => item.trim()))
// 历史下拉选项
const historyOptions = computed(() => history.value.map((item) => ({ label: item, value: item })))
// 当前结果行
const resultRows = computed(() => result.value?.rows ?? [])
@@ -115,6 +151,9 @@ const tableColumns = computed<DataTableColumns<Record<string, unknown>>>(() => {
}))
})
// 图表更新选项:完全替换 option,避免柱状图与饼图切换时残留旧的 series/坐标轴
const chartUpdateOptions = { notMerge: true }
// 图表配置:饼图取首个数值列,柱状图取全部数值列
const chartOption = computed(() => {
if (!result.value || !canChart.value) return null
@@ -170,7 +209,7 @@ async function handleQuery() {
result.value = res.data as AiSqlQueryResult
viewMode.value = defaultViewMode(result.value)
resultVersion.value += 1
recordHistory()
loadHistory()
} catch (e: any) {
message.error(e.message || t('ai.sql.failed'))
} finally {
@@ -202,12 +241,28 @@ function applyExample(value: string) {
}
/** 选择历史:填入并立即查询 */
function applyHistory(value: string | number | null) {
if (typeof value !== 'string' || !value) return
function applyHistory(value: string) {
if (!value) return
queryText.value = value
handleQuery()
}
/** 复制历史提问到剪贴板 */
async function copyQuestion(text: string) {
try {
await navigator.clipboard.writeText(text)
message.success(t('ai.sql.copySuccess'))
} catch (e) {
message.error(t('ai.sql.failed'))
}
}
/** 格式化历史创建时间:后端返回本地时间字符串,直接展示到分钟 */
function formatHistoryTime(value: string): string {
if (!value) return ''
return value.replace('T', ' ').slice(0, 16)
}
/** 判断列是否为交易渠道列:与现有页面 row.channel → getChannelLabel 的约定一致 */
function isChannelColumn(col: AiSqlColumn): boolean {
const key = col.key.toLowerCase()
@@ -267,7 +322,49 @@ async function copySql() {
}
}
/** 恢复上一次查询会话(sessionStorage,切换路由/刷新后不丢) */
function restoreSession() {
const session = readSessionJson<SqlQuerySession | null>(STORAGE_KEYS.sqlQuerySession, null)
if (!session || typeof session !== 'object') return
// 用户不一致时忽略并清除(userStore.userId 未加载时按同用户处理)
if (session.userId != null && userStore.userId != null && session.userId !== userStore.userId) {
removeSessionKey(STORAGE_KEYS.sqlQuerySession)
return
}
if (typeof session.query === 'string') {
queryText.value = session.query
}
if (session.viewMode === 'table' || session.viewMode === 'bar' || session.viewMode === 'pie') {
viewMode.value = session.viewMode
}
const cached = session.result
if (cached && Array.isArray(cached.columns) && Array.isArray(cached.rows)) {
result.value = cached
resultVersion.value += 1
}
}
/** 持久化当前查询会话;结果行数过大时只缓存输入与视图 */
function persistSession() {
const payload: SqlQuerySession = {
userId: userStore.userId,
query: queryText.value,
viewMode: viewMode.value,
result: result.value && result.value.rowCount <= MAX_CACHED_ROWS ? result.value : null,
}
writeSessionJson(STORAGE_KEYS.sqlQuerySession, payload)
}
// 输入、结果与视图变化时持久化(输入做防抖,避免频繁写入)
let persistTimer = 0
watch(queryText, () => {
if (persistTimer) window.clearTimeout(persistTimer)
persistTimer = window.setTimeout(persistSession, 300)
})
watch([result, viewMode], persistSession)
onMounted(async () => {
restoreSession()
await ensureAiEnabledLoaded()
loadHistory()
})
@@ -282,7 +379,9 @@ onMounted(async () => {
</NSpace>
</template>
<NAlert v-if="!aiEnabled" type="warning" :show-icon="true">
<NSpin v-if="!aiEnabledLoaded" size="small" class="ai-loading" />
<NAlert v-else-if="!aiEnabled" type="warning" :show-icon="true">
{{ t('ai.sql.needConfig') }}
</NAlert>
@@ -312,17 +411,50 @@ onMounted(async () => {
</NButton>
</NSpace>
<NSpace v-if="history.length" align="center" :size="8" style="margin-top: 8px;">
<NText depth="3" style="font-size: 12px;">{{ t('ai.sql.history') }}</NText>
<NSelect
:options="historyOptions"
size="small"
style="width: 240px;"
:placeholder="t('ai.sql.history')"
@update:value="applyHistory"
/>
<NButton size="tiny" quaternary @click="clearHistory">{{ t('ai.sql.clearHistory') }}</NButton>
</NSpace>
<NCollapse v-if="history.length" style="margin-top: 12px;">
<NCollapseItem :title="`${t('ai.sql.history')} (${history.length})`" name="history">
<NSpace align="center" justify="space-between" :size="8" style="margin-bottom: 12px;">
<NInput
v-model:value="historyKeyword"
size="small"
clearable
:placeholder="t('ai.sql.historySearch')"
style="max-width: 260px;"
>
<template #prefix>
<NIcon :component="SearchOutline" />
</template>
</NInput>
<NButton size="tiny" quaternary @click="clearHistory">{{ t('ai.sql.clearHistory') }}</NButton>
</NSpace>
<NEmpty
v-if="!filteredHistory.length"
size="small"
:description="t('ai.sql.historyNoMatch')"
/>
<div v-else class="history-list">
<div
v-for="item in filteredHistory"
:key="item.id"
class="history-item"
@click="applyHistory(item.query)"
>
<div class="history-item__main">
<span class="history-item__query">{{ item.query }}</span>
<span class="history-item__time">{{ formatHistoryTime(item.createdAt) }}</span>
</div>
<NButton size="tiny" quaternary @click.stop="copyQuestion(item.query)">
<template #icon>
<NIcon :component="CopyOutline" />
</template>
{{ t('ai.sql.copyQuestion') }}
</NButton>
</div>
</div>
</NCollapseItem>
</NCollapse>
<div v-if="result" style="margin-top: 20px;">
<NCollapse style="margin-bottom: 16px;">
@@ -388,7 +520,7 @@ onMounted(async () => {
size="small"
:scroll-x="960"
/>
<VChart v-else :option="chartOption ?? undefined" autoresize style="height: 380px;" />
<VChart v-else :option="chartOption ?? undefined" :update-options="chartUpdateOptions" autoresize style="height: 380px;" />
</template>
</template>
</div>
@@ -407,9 +539,55 @@ onMounted(async () => {
font-size: 12px;
line-height: 1.6;
overflow: auto;
font-family: 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace;
}
.sql-code code.hljs {
padding: 0;
background: transparent;
font-family: inherit;
}
.ai-loading {
display: flex;
justify-content: center;
padding: 24px 0;
}
.history-list {
max-height: 280px;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fafafa;
cursor: pointer;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.history-item:hover {
border-color: #18a058;
background: #f0f9f3;
box-shadow: 0 1px 4px rgba(24, 160, 88, 0.12);
}
.history-item__main {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.history-item__query {
color: #333;
word-break: break-all;
}
.history-item__time {
margin-top: 2px;
font-size: 12px;
color: #9aa0a6;
}
</style>