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:
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fontsource/jetbrains-mono": "^5.3.0",
|
||||||
"@vicons/ionicons5": "^0.13.0",
|
"@vicons/ionicons5": "^0.13.0",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"echarts": "^6.1.0",
|
"echarts": "^6.1.0",
|
||||||
|
|||||||
@@ -264,6 +264,23 @@ export function queryBySql(query: string) {
|
|||||||
return request.post('/ai/sql', { query }, { timeout: 120000 })
|
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 {
|
export interface AiRecordResult {
|
||||||
summary: string
|
summary: string
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { getAiConfig } from '../api/ai'
|
|||||||
/** 当前用户 AI 是否启用;读取失败一律按未启用处理 */
|
/** 当前用户 AI 是否启用;读取失败一律按未启用处理 */
|
||||||
export const aiEnabled = ref(false)
|
export const aiEnabled = ref(false)
|
||||||
|
|
||||||
/** 是否已成功加载过一次,避免重复请求 */
|
/** AI 启用状态是否已确定(首次读取完成前为 false,用于避免误显示「未启用」) */
|
||||||
let loaded = false
|
export const aiEnabledLoaded = ref(false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从后端刷新 AI 启用状态。
|
* 从后端刷新 AI 启用状态。
|
||||||
@@ -19,17 +19,17 @@ export async function refreshAiEnabled() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
aiEnabled.value = false
|
aiEnabled.value = false
|
||||||
}
|
}
|
||||||
loaded = true
|
aiEnabledLoaded.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 确保至少加载过一次 AI 启用状态(已加载则不重复请求) */
|
/** 确保至少加载过一次 AI 启用状态(已加载则不重复请求) */
|
||||||
export async function ensureAiEnabledLoaded() {
|
export async function ensureAiEnabledLoaded() {
|
||||||
if (loaded) return
|
if (aiEnabledLoaded.value) return
|
||||||
await refreshAiEnabled()
|
await refreshAiEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置为未启用(退出登录等场景使用) */
|
/** 重置为未启用(退出登录等场景使用) */
|
||||||
export function resetAiEnabled() {
|
export function resetAiEnabled() {
|
||||||
aiEnabled.value = false
|
aiEnabled.value = false
|
||||||
loaded = false
|
aiEnabledLoaded.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -883,6 +883,9 @@ export default {
|
|||||||
examplesLabel: { zh: '示例', en: 'Examples', ja: '例' },
|
examplesLabel: { zh: '示例', en: 'Examples', ja: '例' },
|
||||||
history: { zh: '历史提问', en: 'History', ja: '履歴' },
|
history: { zh: '历史提问', en: 'History', ja: '履歴' },
|
||||||
clearHistory: { zh: '清空历史', en: 'Clear 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 が有効になっていません。「設定」で有効にしてください。' },
|
needConfig: { zh: 'AI 未启用,请先在「设置」中开启 AI。', en: 'AI is not enabled. Please enable it in Settings first.', ja: 'AI が有効になっていません。「設定」で有効にしてください。' },
|
||||||
},
|
},
|
||||||
record: {
|
record: {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { getUserInfo } from '../api/user'
|
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. 前缀) */
|
/** 登录令牌存储键(认证凭据,不属于界面偏好,不纳入 ledgerly. 前缀) */
|
||||||
const TOKEN_KEY = 'token'
|
const TOKEN_KEY = 'token'
|
||||||
@@ -41,6 +42,8 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
nickname.value = ''
|
nickname.value = ''
|
||||||
userId.value = null
|
userId.value = null
|
||||||
removeKey(TOKEN_KEY)
|
removeKey(TOKEN_KEY)
|
||||||
|
// 清除智能查数上一次查询会话,避免同标签切换账号后看到上一用户的数据
|
||||||
|
removeSessionKey(STORAGE_KEYS.sqlQuerySession)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, nickname, userId, setToken, setUserInfo, fetchUserInfo, logout }
|
return { token, nickname, userId, setToken, setUserInfo, fetchUserInfo, logout }
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
// 忽略删除失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 迁移历史存储键:新键不存在且旧键存在时,将旧值搬运到新键并删除旧键。
|
* 迁移历史存储键:新键不存在且旧键存在时,将旧值搬运到新键并删除旧键。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export const STORAGE_KEYS = {
|
|||||||
transactionAiQueryHistory: 'ledgerly.transaction.aiQueryHistory',
|
transactionAiQueryHistory: 'ledgerly.transaction.aiQueryHistory',
|
||||||
// 收支记录 AI 查询浮窗位置尺寸
|
// 收支记录 AI 查询浮窗位置尺寸
|
||||||
transactionAiQueryWindow: 'ledgerly.transaction.aiQueryWindow',
|
transactionAiQueryWindow: 'ledgerly.transaction.aiQueryWindow',
|
||||||
// 智能查数的历史提问
|
// 智能查数上一次查询会话(sessionStorage,含输入与结果缓存)
|
||||||
sqlQueryHistory: 'ledgerly.sqlQuery.history',
|
sqlQuerySession: 'ledgerly.sqlQuery.session',
|
||||||
// 内容区滚动位置(sessionStorage,按路由拼接后缀)
|
// 内容区滚动位置(sessionStorage,按路由拼接后缀)
|
||||||
scrollPrefix: 'ledgerly.scroll.',
|
scrollPrefix: 'ledgerly.scroll.',
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
+205
-27
@@ -1,10 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 智能查数页:将自然语言交给大模型生成只读 SQL 并执行,自适应渲染统计卡片、表格与图表
|
// 智能查数页:将自然语言交给大模型生成只读 SQL 并执行,自适应渲染统计卡片、表格与图表
|
||||||
import { computed, defineAsyncComponent, h, onMounted, ref } from 'vue'
|
import { computed, defineAsyncComponent, h, onMounted, ref, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
NAlert, NButton, NCard, NCollapse, NCollapseItem, NDataTable, NEmpty, NGrid, NGridItem,
|
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'
|
} from 'naive-ui'
|
||||||
import type { DataTableColumns } from 'naive-ui'
|
import type { DataTableColumns } from 'naive-ui'
|
||||||
import { CopyOutline, SearchOutline, SparklesOutline } from '@vicons/ionicons5'
|
import { CopyOutline, SearchOutline, SparklesOutline } from '@vicons/ionicons5'
|
||||||
@@ -12,14 +12,17 @@ import { format } from 'sql-formatter'
|
|||||||
import hljs from 'highlight.js/lib/core'
|
import hljs from 'highlight.js/lib/core'
|
||||||
import sql from 'highlight.js/lib/languages/sql'
|
import sql from 'highlight.js/lib/languages/sql'
|
||||||
import 'highlight.js/styles/github.css'
|
import 'highlight.js/styles/github.css'
|
||||||
import { queryBySql } from '../api/ai'
|
import '@fontsource/jetbrains-mono/400.css'
|
||||||
import type { AiSqlColumn, AiSqlQueryResult } from '../api/ai'
|
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 { getChannelLabel } from '../utils/channel'
|
||||||
import { aiEnabled, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
|
import { aiEnabled, aiEnabledLoaded, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
|
||||||
import { useKeywordHistory } from '../composables/useKeywordHistory'
|
|
||||||
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
|
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
|
||||||
import { useTablePageSize } from '../composables/useTablePageSize'
|
import { useTablePageSize } from '../composables/useTablePageSize'
|
||||||
|
import { readSessionJson, writeSessionJson, removeSessionKey } from '../utils/storage'
|
||||||
import { STORAGE_KEYS } from '../utils/storageKeys'
|
import { STORAGE_KEYS } from '../utils/storageKeys'
|
||||||
|
import { useUserStore } from '../store/user'
|
||||||
|
|
||||||
// 图表组件异步加载:echarts 体积较大,按需加载,避免阻塞首屏
|
// 图表组件异步加载:echarts 体积较大,按需加载,避免阻塞首屏
|
||||||
const VChart = defineAsyncComponent(async () => {
|
const VChart = defineAsyncComponent(async () => {
|
||||||
@@ -47,28 +50,61 @@ hljs.registerLanguage('sql', sql)
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
/** 展示视图类型 */
|
/** 展示视图类型 */
|
||||||
type ViewMode = 'table' | 'bar' | 'pie'
|
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 queryText = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const result = ref<AiSqlQueryResult | null>(null)
|
const result = ref<AiSqlQueryResult | null>(null)
|
||||||
const resultVersion = ref(0)
|
const resultVersion = ref(0)
|
||||||
const viewMode = ref<ViewMode>('table')
|
const viewMode = ref<ViewMode>('table')
|
||||||
|
|
||||||
// 提问历史(持久化、去重、清空)
|
// 提问历史(后端持久化,含创建时间)
|
||||||
const { history, load: loadHistory, record: recordHistory, clear: clearHistory } = useKeywordHistory({
|
const history = ref<AiSqlHistoryItem[]>([])
|
||||||
storageKey: STORAGE_KEYS.sqlQueryHistory,
|
|
||||||
text: queryText,
|
/** 从后端加载提问历史 */
|
||||||
clearLabel: () => t('ai.sql.clearHistory'),
|
async function loadHistory() {
|
||||||
max: 50,
|
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 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 ?? [])
|
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(() => {
|
const chartOption = computed(() => {
|
||||||
if (!result.value || !canChart.value) return null
|
if (!result.value || !canChart.value) return null
|
||||||
@@ -170,7 +209,7 @@ async function handleQuery() {
|
|||||||
result.value = res.data as AiSqlQueryResult
|
result.value = res.data as AiSqlQueryResult
|
||||||
viewMode.value = defaultViewMode(result.value)
|
viewMode.value = defaultViewMode(result.value)
|
||||||
resultVersion.value += 1
|
resultVersion.value += 1
|
||||||
recordHistory()
|
loadHistory()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
message.error(e.message || t('ai.sql.failed'))
|
message.error(e.message || t('ai.sql.failed'))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -202,12 +241,28 @@ function applyExample(value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 选择历史:填入并立即查询 */
|
/** 选择历史:填入并立即查询 */
|
||||||
function applyHistory(value: string | number | null) {
|
function applyHistory(value: string) {
|
||||||
if (typeof value !== 'string' || !value) return
|
if (!value) return
|
||||||
queryText.value = value
|
queryText.value = value
|
||||||
handleQuery()
|
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 的约定一致 */
|
/** 判断列是否为交易渠道列:与现有页面 row.channel → getChannelLabel 的约定一致 */
|
||||||
function isChannelColumn(col: AiSqlColumn): boolean {
|
function isChannelColumn(col: AiSqlColumn): boolean {
|
||||||
const key = col.key.toLowerCase()
|
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 () => {
|
onMounted(async () => {
|
||||||
|
restoreSession()
|
||||||
await ensureAiEnabledLoaded()
|
await ensureAiEnabledLoaded()
|
||||||
loadHistory()
|
loadHistory()
|
||||||
})
|
})
|
||||||
@@ -282,7 +379,9 @@ onMounted(async () => {
|
|||||||
</NSpace>
|
</NSpace>
|
||||||
</template>
|
</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') }}
|
{{ t('ai.sql.needConfig') }}
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
@@ -312,18 +411,51 @@ onMounted(async () => {
|
|||||||
</NButton>
|
</NButton>
|
||||||
</NSpace>
|
</NSpace>
|
||||||
|
|
||||||
<NSpace v-if="history.length" align="center" :size="8" style="margin-top: 8px;">
|
<NCollapse v-if="history.length" style="margin-top: 12px;">
|
||||||
<NText depth="3" style="font-size: 12px;">{{ t('ai.sql.history') }}</NText>
|
<NCollapseItem :title="`${t('ai.sql.history')} (${history.length})`" name="history">
|
||||||
<NSelect
|
<NSpace align="center" justify="space-between" :size="8" style="margin-bottom: 12px;">
|
||||||
:options="historyOptions"
|
<NInput
|
||||||
|
v-model:value="historyKeyword"
|
||||||
size="small"
|
size="small"
|
||||||
style="width: 240px;"
|
clearable
|
||||||
:placeholder="t('ai.sql.history')"
|
:placeholder="t('ai.sql.historySearch')"
|
||||||
@update:value="applyHistory"
|
style="max-width: 260px;"
|
||||||
/>
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<NIcon :component="SearchOutline" />
|
||||||
|
</template>
|
||||||
|
</NInput>
|
||||||
<NButton size="tiny" quaternary @click="clearHistory">{{ t('ai.sql.clearHistory') }}</NButton>
|
<NButton size="tiny" quaternary @click="clearHistory">{{ t('ai.sql.clearHistory') }}</NButton>
|
||||||
</NSpace>
|
</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;">
|
<div v-if="result" style="margin-top: 20px;">
|
||||||
<NCollapse style="margin-bottom: 16px;">
|
<NCollapse style="margin-bottom: 16px;">
|
||||||
<NCollapseItem :title="t('ai.sql.sqlTitle')" name="sql">
|
<NCollapseItem :title="t('ai.sql.sqlTitle')" name="sql">
|
||||||
@@ -388,7 +520,7 @@ onMounted(async () => {
|
|||||||
size="small"
|
size="small"
|
||||||
:scroll-x="960"
|
: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>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -407,9 +539,55 @@ onMounted(async () => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
font-family: 'JetBrains Mono', Consolas, Monaco, 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
.sql-code code.hljs {
|
.sql-code code.hljs {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: transparent;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation;
|
|||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -47,6 +48,7 @@ import cn.rosercode.ledgerly.service.AiQueryService;
|
|||||||
import cn.rosercode.ledgerly.service.AiRecordService;
|
import cn.rosercode.ledgerly.service.AiRecordService;
|
||||||
import cn.rosercode.ledgerly.service.AiRelationService;
|
import cn.rosercode.ledgerly.service.AiRelationService;
|
||||||
import cn.rosercode.ledgerly.service.AiRuleService;
|
import cn.rosercode.ledgerly.service.AiRuleService;
|
||||||
|
import cn.rosercode.ledgerly.service.AiSqlHistoryService;
|
||||||
import cn.rosercode.ledgerly.service.AiSqlQueryService;
|
import cn.rosercode.ledgerly.service.AiSqlQueryService;
|
||||||
import cn.rosercode.ledgerly.service.AiStatisticsService;
|
import cn.rosercode.ledgerly.service.AiStatisticsService;
|
||||||
import cn.rosercode.ledgerly.service.AiSuggestionService;
|
import cn.rosercode.ledgerly.service.AiSuggestionService;
|
||||||
@@ -62,6 +64,7 @@ import cn.rosercode.ledgerly.vo.AiRecordImageResultVO;
|
|||||||
import cn.rosercode.ledgerly.vo.AiRecordResultVO;
|
import cn.rosercode.ledgerly.vo.AiRecordResultVO;
|
||||||
import cn.rosercode.ledgerly.vo.AiRelationResultVO;
|
import cn.rosercode.ledgerly.vo.AiRelationResultVO;
|
||||||
import cn.rosercode.ledgerly.vo.AiRuleSuggestResultVO;
|
import cn.rosercode.ledgerly.vo.AiRuleSuggestResultVO;
|
||||||
|
import cn.rosercode.ledgerly.vo.AiSqlHistoryVO;
|
||||||
import cn.rosercode.ledgerly.vo.AiSqlQueryResultVO;
|
import cn.rosercode.ledgerly.vo.AiSqlQueryResultVO;
|
||||||
import cn.rosercode.ledgerly.vo.AiStatisticsResultVO;
|
import cn.rosercode.ledgerly.vo.AiStatisticsResultVO;
|
||||||
import cn.rosercode.ledgerly.vo.AiSuggestionVO;
|
import cn.rosercode.ledgerly.vo.AiSuggestionVO;
|
||||||
@@ -94,6 +97,7 @@ public class AiController {
|
|||||||
private final AiTagService aiTagService;
|
private final AiTagService aiTagService;
|
||||||
private final AiRuleService aiRuleService;
|
private final AiRuleService aiRuleService;
|
||||||
private final AiSqlQueryService aiSqlQueryService;
|
private final AiSqlQueryService aiSqlQueryService;
|
||||||
|
private final AiSqlHistoryService aiSqlHistoryService;
|
||||||
private final AiStatisticsService aiStatisticsService;
|
private final AiStatisticsService aiStatisticsService;
|
||||||
private final AiImageRecordService aiImageRecordService;
|
private final AiImageRecordService aiImageRecordService;
|
||||||
private final AiImageRecordStreamService aiImageRecordStreamService;
|
private final AiImageRecordStreamService aiImageRecordStreamService;
|
||||||
@@ -107,6 +111,7 @@ public class AiController {
|
|||||||
AiCategoryService aiCategoryService, AiChannelService aiChannelService,
|
AiCategoryService aiCategoryService, AiChannelService aiChannelService,
|
||||||
AiTagService aiTagService,
|
AiTagService aiTagService,
|
||||||
AiRuleService aiRuleService, AiSqlQueryService aiSqlQueryService,
|
AiRuleService aiRuleService, AiSqlQueryService aiSqlQueryService,
|
||||||
|
AiSqlHistoryService aiSqlHistoryService,
|
||||||
AiStatisticsService aiStatisticsService,
|
AiStatisticsService aiStatisticsService,
|
||||||
AiImageRecordService aiImageRecordService,
|
AiImageRecordService aiImageRecordService,
|
||||||
AiImageRecordStreamService aiImageRecordStreamService,
|
AiImageRecordStreamService aiImageRecordStreamService,
|
||||||
@@ -123,6 +128,7 @@ public class AiController {
|
|||||||
this.aiTagService = aiTagService;
|
this.aiTagService = aiTagService;
|
||||||
this.aiRuleService = aiRuleService;
|
this.aiRuleService = aiRuleService;
|
||||||
this.aiSqlQueryService = aiSqlQueryService;
|
this.aiSqlQueryService = aiSqlQueryService;
|
||||||
|
this.aiSqlHistoryService = aiSqlHistoryService;
|
||||||
this.aiStatisticsService = aiStatisticsService;
|
this.aiStatisticsService = aiStatisticsService;
|
||||||
this.aiImageRecordService = aiImageRecordService;
|
this.aiImageRecordService = aiImageRecordService;
|
||||||
this.aiImageRecordStreamService = aiImageRecordStreamService;
|
this.aiImageRecordStreamService = aiImageRecordStreamService;
|
||||||
@@ -248,6 +254,31 @@ public class AiController {
|
|||||||
return Result.success(aiSqlQueryService.query(userId, queryRequest.getQuery()));
|
return Result.success(aiSqlQueryService.query(userId, queryRequest.getQuery()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取智能查数历史
|
||||||
|
*
|
||||||
|
* @param limit 返回条数,默认 100
|
||||||
|
* @return 提问历史列表(按创建时间倒序)
|
||||||
|
*/
|
||||||
|
@GetMapping("/sql/history")
|
||||||
|
@Operation(summary = "获取智能查数历史", description = "按创建时间倒序返回当前用户的自然语言提问历史")
|
||||||
|
public Result<List<AiSqlHistoryVO>> sqlHistory(@RequestParam(defaultValue = "100") int limit) {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
return Result.success(aiSqlHistoryService.list(userId, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空智能查数历史
|
||||||
|
*
|
||||||
|
* @return 删除的历史条数
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/sql/history")
|
||||||
|
@Operation(summary = "清空智能查数历史", description = "删除当前用户的全部智能查数提问历史")
|
||||||
|
public Result<Integer> clearSqlHistory() {
|
||||||
|
Long userId = UserContext.getUserId();
|
||||||
|
return Result.success(aiSqlHistoryService.clear(userId));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析自然语言记账内容为交易记录
|
* 解析自然语言记账内容为交易记录
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package cn.rosercode.ledgerly.entity;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 智能查数历史实体类,对应数据库表 t_ai_sql_history,记录用户的自然语言提问。
|
||||||
|
*
|
||||||
|
* @date 2026-09-24 12:18:26
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("t_ai_sql_history")
|
||||||
|
public class AiSqlHistory {
|
||||||
|
|
||||||
|
/** 记录ID,主键自增 */
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 创建时间 */
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
/** 所属用户ID */
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/** 用户的自然语言提问 */
|
||||||
|
private String queryText;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package cn.rosercode.ledgerly.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
import cn.rosercode.ledgerly.entity.AiSqlHistory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 智能查数历史数据访问接口。
|
||||||
|
*
|
||||||
|
* @date 2026-09-24 12:18:26
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface AiSqlHistoryMapper extends BaseMapper<AiSqlHistory> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package cn.rosercode.ledgerly.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import cn.rosercode.ledgerly.vo.AiSqlHistoryVO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 智能查数历史服务,提供提问历史的记录、查询与清空。
|
||||||
|
*
|
||||||
|
* @date 2026-09-24 12:18:26
|
||||||
|
*/
|
||||||
|
public interface AiSqlHistoryService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录一条提问历史。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param query 用户的自然语言提问
|
||||||
|
*/
|
||||||
|
void save(Long userId, String query);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询当前用户最近的提问历史(按创建时间倒序)。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param limit 返回条数
|
||||||
|
* @return 历史列表
|
||||||
|
*/
|
||||||
|
List<AiSqlHistoryVO> list(Long userId, int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空当前用户的全部提问历史。
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 删除条数
|
||||||
|
*/
|
||||||
|
int clear(Long userId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package cn.rosercode.ledgerly.service.impl;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import cn.rosercode.ledgerly.entity.AiSqlHistory;
|
||||||
|
import cn.rosercode.ledgerly.mapper.AiSqlHistoryMapper;
|
||||||
|
import cn.rosercode.ledgerly.service.AiSqlHistoryService;
|
||||||
|
import cn.rosercode.ledgerly.vo.AiSqlHistoryVO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 智能查数历史服务实现类。
|
||||||
|
*
|
||||||
|
* @date 2026-09-24 12:18:26
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class AiSqlHistoryServiceImpl implements AiSqlHistoryService {
|
||||||
|
|
||||||
|
/** 查询条数上限 */
|
||||||
|
private static final int MAX_LIMIT = 200;
|
||||||
|
|
||||||
|
/** 提问内容最大长度,与数据库列保持一致 */
|
||||||
|
private static final int QUERY_TEXT_LIMIT = 500;
|
||||||
|
|
||||||
|
private final AiSqlHistoryMapper aiSqlHistoryMapper;
|
||||||
|
|
||||||
|
public AiSqlHistoryServiceImpl(AiSqlHistoryMapper aiSqlHistoryMapper) {
|
||||||
|
this.aiSqlHistoryMapper = aiSqlHistoryMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*
|
||||||
|
* 写入失败仅打印告警,不影响主流程。
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void save(Long userId, String query) {
|
||||||
|
if (query == null || query.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
AiSqlHistory history = new AiSqlHistory();
|
||||||
|
history.setUserId(userId);
|
||||||
|
history.setQueryText(truncate(query.trim()));
|
||||||
|
try {
|
||||||
|
aiSqlHistoryMapper.insert(history);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("保存智能查数历史失败: userId={}, 原因={}", userId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<AiSqlHistoryVO> list(Long userId, int limit) {
|
||||||
|
int size = Math.min(Math.max(limit, 1), MAX_LIMIT);
|
||||||
|
List<AiSqlHistory> rows = aiSqlHistoryMapper.selectList(new LambdaQueryWrapper<AiSqlHistory>()
|
||||||
|
.eq(AiSqlHistory::getUserId, userId)
|
||||||
|
.orderByDesc(AiSqlHistory::getCreatedAt)
|
||||||
|
.orderByDesc(AiSqlHistory::getId)
|
||||||
|
.last("LIMIT " + size));
|
||||||
|
List<AiSqlHistoryVO> result = new ArrayList<>();
|
||||||
|
for (AiSqlHistory row : rows) {
|
||||||
|
AiSqlHistoryVO vo = new AiSqlHistoryVO();
|
||||||
|
vo.setId(row.getId());
|
||||||
|
vo.setQuery(row.getQueryText());
|
||||||
|
vo.setCreatedAt(row.getCreatedAt());
|
||||||
|
result.add(vo);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@inheritDoc}
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int clear(Long userId) {
|
||||||
|
int deleted = aiSqlHistoryMapper.delete(new LambdaQueryWrapper<AiSqlHistory>()
|
||||||
|
.eq(AiSqlHistory::getUserId, userId));
|
||||||
|
log.info("清空智能查数历史: userId={}, 删除={}条", userId, deleted);
|
||||||
|
return deleted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断提问文本,避免超出数据库列长度。
|
||||||
|
*
|
||||||
|
* @param value 原始文本
|
||||||
|
* @return 截断后的文本
|
||||||
|
*/
|
||||||
|
private String truncate(String value) {
|
||||||
|
return value.length() > QUERY_TEXT_LIMIT ? value.substring(0, QUERY_TEXT_LIMIT) : value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import cn.rosercode.ledgerly.entity.AiConfig;
|
|||||||
import cn.rosercode.ledgerly.enums.AiCallType;
|
import cn.rosercode.ledgerly.enums.AiCallType;
|
||||||
import cn.rosercode.ledgerly.prompt.PromptTemplateService;
|
import cn.rosercode.ledgerly.prompt.PromptTemplateService;
|
||||||
import cn.rosercode.ledgerly.service.AiConfigService;
|
import cn.rosercode.ledgerly.service.AiConfigService;
|
||||||
|
import cn.rosercode.ledgerly.service.AiSqlHistoryService;
|
||||||
import cn.rosercode.ledgerly.service.AiSqlQueryService;
|
import cn.rosercode.ledgerly.service.AiSqlQueryService;
|
||||||
import cn.rosercode.ledgerly.service.AiUsageService;
|
import cn.rosercode.ledgerly.service.AiUsageService;
|
||||||
import cn.rosercode.ledgerly.util.sql.TextToSqlGuard;
|
import cn.rosercode.ledgerly.util.sql.TextToSqlGuard;
|
||||||
@@ -63,6 +64,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
|
|||||||
private final AiConfigService aiConfigService;
|
private final AiConfigService aiConfigService;
|
||||||
private final DeepSeekClient deepSeekClient;
|
private final DeepSeekClient deepSeekClient;
|
||||||
private final AiUsageService aiUsageService;
|
private final AiUsageService aiUsageService;
|
||||||
|
private final AiSqlHistoryService aiSqlHistoryService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final PromptTemplateService promptTemplateService;
|
private final PromptTemplateService promptTemplateService;
|
||||||
private final TextToSqlSchemaProvider schemaProvider;
|
private final TextToSqlSchemaProvider schemaProvider;
|
||||||
@@ -71,6 +73,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
|
|||||||
public AiSqlQueryServiceImpl(AiConfigService aiConfigService,
|
public AiSqlQueryServiceImpl(AiConfigService aiConfigService,
|
||||||
DeepSeekClient deepSeekClient,
|
DeepSeekClient deepSeekClient,
|
||||||
AiUsageService aiUsageService,
|
AiUsageService aiUsageService,
|
||||||
|
AiSqlHistoryService aiSqlHistoryService,
|
||||||
ObjectMapper objectMapper,
|
ObjectMapper objectMapper,
|
||||||
PromptTemplateService promptTemplateService,
|
PromptTemplateService promptTemplateService,
|
||||||
TextToSqlSchemaProvider schemaProvider,
|
TextToSqlSchemaProvider schemaProvider,
|
||||||
@@ -78,6 +81,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
|
|||||||
this.aiConfigService = aiConfigService;
|
this.aiConfigService = aiConfigService;
|
||||||
this.deepSeekClient = deepSeekClient;
|
this.deepSeekClient = deepSeekClient;
|
||||||
this.aiUsageService = aiUsageService;
|
this.aiUsageService = aiUsageService;
|
||||||
|
this.aiSqlHistoryService = aiSqlHistoryService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
this.promptTemplateService = promptTemplateService;
|
this.promptTemplateService = promptTemplateService;
|
||||||
this.schemaProvider = schemaProvider;
|
this.schemaProvider = schemaProvider;
|
||||||
@@ -132,6 +136,7 @@ public class AiSqlQueryServiceImpl implements AiSqlQueryService {
|
|||||||
result.setChartType(chartType);
|
result.setChartType(chartType);
|
||||||
log.info("AI SQL 查询完成: userId={}, 耗时={}ms, 行数={}, 截断={}",
|
log.info("AI SQL 查询完成: userId={}, 耗时={}ms, 行数={}, 截断={}",
|
||||||
userId, System.currentTimeMillis() - start, result.getRowCount(), result.isTruncated());
|
userId, System.currentTimeMillis() - start, result.getRowCount(), result.isTruncated());
|
||||||
|
aiSqlHistoryService.save(userId, query);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package cn.rosercode.ledgerly.util.sql;
|
package cn.rosercode.ledgerly.util.sql;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package cn.rosercode.ledgerly.vo;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 智能查数历史视图对象。
|
||||||
|
*
|
||||||
|
* @date 2026-09-24 12:18:26
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AiSqlHistoryVO {
|
||||||
|
|
||||||
|
/** 记录ID */
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** 提问内容 */
|
||||||
|
private String query;
|
||||||
|
|
||||||
|
/** 创建时间 */
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -122,6 +122,15 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
|
|||||||
INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
|
INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- AI 智能查数历史表:记录用户的自然语言提问,用于历史回看与复制
|
||||||
|
CREATE TABLE IF NOT EXISTS t_ai_sql_history (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID,主键自增',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||||
|
query_text VARCHAR(500) NOT NULL COMMENT '用户的自然语言提问',
|
||||||
|
INDEX idx_ai_sql_history_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
|
||||||
|
);
|
||||||
|
|
||||||
-- AI 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要
|
-- AI 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要
|
||||||
CREATE TABLE IF NOT EXISTS t_ai_image (
|
CREATE TABLE IF NOT EXISTS t_ai_image (
|
||||||
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '图片ID,主键自增',
|
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '图片ID,主键自增',
|
||||||
|
|||||||
@@ -122,6 +122,15 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
|
|||||||
);;
|
);;
|
||||||
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_created ON t_ai_usage (user_id, created_at);;
|
CREATE INDEX IF NOT EXISTS idx_ai_usage_user_created ON t_ai_usage (user_id, created_at);;
|
||||||
|
|
||||||
|
-- AI 智能查数历史表:记录用户的自然语言提问,用于历史回看与复制
|
||||||
|
CREATE TABLE IF NOT EXISTS t_ai_sql_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
query_text VARCHAR(500) NOT NULL
|
||||||
|
);;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_sql_history_user_created ON t_ai_sql_history (user_id, created_at);;
|
||||||
|
|
||||||
-- AI 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要
|
-- AI 记账图片表:保存用户上传的账单/支付截图及其解析结果摘要
|
||||||
CREATE TABLE IF NOT EXISTS t_ai_image (
|
CREATE TABLE IF NOT EXISTS t_ai_image (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|||||||
Reference in New Issue
Block a user