Files
ledgerly/frontend/src/views/SqlQuery.vue
T
rosercode feb56c8a8d 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
2026-09-24 12:30:00 +08:00

594 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 智能查数页:将自然语言交给大模型生成只读 SQL 并执行,自适应渲染统计卡片、表格与图表
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, NSpin, NSpace, NStatistic, NText, useMessage,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { CopyOutline, SearchOutline, SparklesOutline } from '@vicons/ionicons5'
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 '@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, 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 () => {
const [vueEcharts, echartsCore, renderers, charts, components] = await Promise.all([
import('vue-echarts'),
import('echarts/core'),
import('echarts/renderers'),
import('echarts/charts'),
import('echarts/components'),
])
echartsCore.use([
renderers.CanvasRenderer,
charts.BarChart,
charts.PieChart,
components.TitleComponent,
components.TooltipComponent,
components.LegendComponent,
components.GridComponent,
])
return vueEcharts.default
})
// 注册 SQL 语言,供生成的 SQL 语法高亮
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 = 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 resultRows = computed(() => result.value?.rows ?? [])
// 表格分页偏好(记忆每页条数)
const pageSize = useTablePageSize('smart-query')
const { pageSizeOptions } = usePageSizeOptions()
const pagination = computed(() => ({
pageSize: pageSize.value,
showSizePicker: true,
pageSizes: pageSizeOptions.value,
onUpdatePageSize: (value: number) => {
pageSize.value = value
},
}))
// 单行结果视为统计值,用统计卡片展示
const isScalar = computed(() => !!result.value && result.value.rows.length === 1)
// 多行且含数值列时可切换图表
const canChart = computed(() => !!result.value
&& result.value.rows.length > 1
&& result.value.columns.some((col) => col.type === 'NUMBER'))
const hasRows = computed(() => resultRows.value.length > 0)
// 单行统计卡片项
const scalarItems = computed(() => {
if (!result.value || !isScalar.value) return []
const row = result.value.rows[0]
return result.value.columns.map((col) => ({
key: col.key,
label: col.title,
value: displayValue(col, row[col.key]),
}))
})
// 动态表格列:按后端列元数据生成
const tableColumns = computed<DataTableColumns<Record<string, unknown>>>(() => {
if (!result.value) return []
return result.value.columns.map((col) => ({
key: col.key,
title: col.title,
align: col.type === 'NUMBER' ? 'right' : 'left',
ellipsis: { tooltip: true },
render: (row: Record<string, unknown>) => h('span', displayValue(col, row[col.key])),
}))
})
// 图表更新选项:完全替换 option,避免柱状图与饼图切换时残留旧的 series/坐标轴
const chartUpdateOptions = { notMerge: true }
// 图表配置:饼图取首个数值列,柱状图取全部数值列
const chartOption = computed(() => {
if (!result.value || !canChart.value) return null
const cols = result.value.columns
const dimension = cols.find((col) => col.type !== 'NUMBER') || cols[0]
const measures = cols.filter((col) => col.type === 'NUMBER')
const rows = result.value.rows
if (viewMode.value === 'pie') {
const measure = measures[0]
if (!measure) return null
return {
tooltip: { trigger: 'item' },
legend: { type: 'scroll', bottom: 0 },
series: [{
type: 'pie',
radius: ['35%', '65%'],
data: rows.map((row) => ({
name: String(row[dimension.key] ?? ''),
value: Number(row[measure.key]),
})),
}],
}
}
if (!measures.length) return null
return {
tooltip: { trigger: 'axis' },
legend: { top: 0 },
grid: { left: 12, right: 20, top: 40, bottom: 40, containLabel: true },
xAxis: {
type: 'category',
data: rows.map((row) => String(row[dimension.key] ?? '')),
axisLabel: { interval: 0, rotate: rows.length > 6 ? 30 : 0 },
},
yAxis: { type: 'value' },
series: measures.map((measure) => ({
name: measure.title,
type: 'bar',
data: rows.map((row) => Number(row[measure.key])),
})),
}
})
/** 查询:调用后端生成 SQL 并执行 */
async function handleQuery() {
const text = queryText.value.trim()
if (!text) {
message.warning(t('ai.sql.emptyInput'))
return
}
loading.value = true
try {
const res: any = await queryBySql(text)
result.value = res.data as AiSqlQueryResult
viewMode.value = defaultViewMode(result.value)
resultVersion.value += 1
loadHistory()
} catch (e: any) {
message.error(e.message || t('ai.sql.failed'))
} finally {
loading.value = false
}
}
/** 根据结果形状与后端建议决定默认视图 */
function defaultViewMode(data: AiSqlQueryResult): ViewMode {
if (data.rows.length > 1) {
if (data.chartType === 'BAR') return 'bar'
if (data.chartType === 'PIE') return 'pie'
}
return 'table'
}
/** Ctrl/Cmd + Enter 快捷提交 */
function handleKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault()
handleQuery()
}
}
/** 点击示例:填入并立即查询 */
function applyExample(value: string) {
queryText.value = value
handleQuery()
}
/** 选择历史:填入并立即查询 */
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()
const title = col.title.toLowerCase()
return key === 'channel' || title.includes('channel') || col.title.includes('渠道')
}
/** 格式化展示值:渠道转名称、数值千分位、空值占位 */
function displayValue(col: AiSqlColumn, value: unknown): string {
if (value === null || value === undefined || value === '') return '-'
if (isChannelColumn(col)) return getChannelLabel(String(value))
if (col.type === 'NUMBER') {
const num = Number(value)
if (!Number.isNaN(num)) return num.toLocaleString(undefined, { maximumFractionDigits: 2 })
}
return String(value)
}
// 生成的 SQL 格式化:多行缩进、关键字大写,失败回退原文
const formattedSql = computed(() => {
const raw = result.value?.sql
if (!raw) return ''
try {
return format(raw, { language: 'sql', tabWidth: 2, keywordCase: 'upper' })
} catch (e) {
return raw
}
})
/** HTML 转义,用于高亮回退分支,避免以 v-html 渲染未转义文本 */
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
// SQL 语法高亮(highlight.js 输出已转义;回退分支手动转义后仍安全)
const highlightedSql = computed(() => {
const code = formattedSql.value
if (!code) return ''
try {
return hljs.highlight(code, { language: 'sql' }).value
} catch (e) {
return escapeHtml(code)
}
})
/** 复制生成的 SQL(复制格式化后的版本) */
async function copySql() {
if (!result.value) return
try {
await navigator.clipboard.writeText(formattedSql.value || result.value.sql)
message.success(t('ai.sql.copySuccess'))
} catch (e) {
message.error(t('ai.sql.failed'))
}
}
/** 恢复上一次查询会话(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()
})
</script>
<template>
<NCard :bordered="false">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="SparklesOutline" />
<span>{{ t('ai.sql.title') }}</span>
</NSpace>
</template>
<NSpin v-if="!aiEnabledLoaded" size="small" class="ai-loading" />
<NAlert v-else-if="!aiEnabled" type="warning" :show-icon="true">
{{ t('ai.sql.needConfig') }}
</NAlert>
<template v-else>
<NText depth="3" style="display: block; margin-bottom: 12px;">{{ t('ai.sql.hint') }}</NText>
<NInput
v-model:value="queryText"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
:placeholder="t('ai.sql.placeholder')"
@keydown="handleKeydown"
/>
<NSpace align="center" justify="space-between" style="margin-top: 12px;">
<NSpace align="center" :size="8">
<NText depth="3" style="font-size: 12px;">{{ t('ai.sql.examplesLabel') }}</NText>
<NButton v-for="ex in exampleList" :key="ex" size="tiny" quaternary @click="applyExample(ex)">
{{ ex }}
</NButton>
</NSpace>
<NButton type="primary" :loading="loading" @click="handleQuery">
<template #icon>
<NIcon :component="SearchOutline" />
</template>
{{ t('ai.sql.submit') }}
</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;">
<NCollapseItem :title="t('ai.sql.sqlTitle')" name="sql">
<div class="sql-block">
<NSpace justify="end" style="margin-bottom: 8px;">
<NButton size="tiny" secondary @click="copySql">
<template #icon>
<NIcon :component="CopyOutline" />
</template>
{{ t('ai.sql.copySql') }}
</NButton>
</NSpace>
<pre class="sql-code"><code class="hljs" v-html="highlightedSql"></code></pre>
</div>
</NCollapseItem>
</NCollapse>
<NText v-if="result?.explanation" depth="3" style="display: block; margin-bottom: 12px;">
{{ result.explanation }}
</NText>
<NAlert v-if="result?.truncated" type="warning" :show-icon="true" style="margin-bottom: 12px;">
{{ t('ai.sql.truncated', { max: 1000 }) }}
</NAlert>
<NEmpty v-if="!hasRows" :description="t('ai.sql.empty')" />
<template v-else>
<!-- 单行结果:统计卡片 -->
<NGrid v-if="isScalar" :cols="Math.min(scalarItems.length, 4)" :x-gap="16" :y-gap="16">
<NGridItem v-for="item in scalarItems" :key="item.key">
<NCard size="small" :bordered="true">
<NStatistic :label="item.label" :value="item.value" />
</NCard>
</NGridItem>
</NGrid>
<!-- 多行结果:视图切换 + 表格/图表 -->
<template v-else>
<NSpace align="center" justify="space-between" style="margin-bottom: 12px;">
<NText depth="3" style="font-size: 12px;">{{ t('ai.sql.rowCount', { count: result?.rowCount }) }}</NText>
<NSpace v-if="canChart" :size="8">
<NButton size="small" :type="viewMode === 'table' ? 'primary' : 'default'" @click="viewMode = 'table'">
{{ t('ai.sql.viewTable') }}
</NButton>
<NButton size="small" :type="viewMode === 'bar' ? 'primary' : 'default'" @click="viewMode = 'bar'">
{{ t('ai.sql.viewBar') }}
</NButton>
<NButton size="small" :type="viewMode === 'pie' ? 'primary' : 'default'" @click="viewMode = 'pie'">
{{ t('ai.sql.viewPie') }}
</NButton>
</NSpace>
</NSpace>
<NDataTable
v-if="viewMode === 'table' || !canChart"
:key="resultVersion"
:columns="tableColumns"
:data="resultRows"
:pagination="pagination"
:bordered="false"
size="small"
:scroll-x="960"
/>
<VChart v-else :option="chartOption ?? undefined" :update-options="chartUpdateOptions" autoresize style="height: 380px;" />
</template>
</template>
</div>
</template>
</NCard>
</template>
<style scoped>
.sql-block {
width: 100%;
}
.sql-code {
margin: 0;
padding: 12px;
border-radius: 6px;
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>