feat: 新增智能查数(自然语言转 SQL 只读查询)
新增「智能查数」功能:用户用自然语言提问,由大模型生成一条只读 SQL,经安全校验后在当前用户数据上执行,前端按结果形状自适应渲染为统计卡片、表格或图表。入口位于侧边栏,仅在 AI 启用时显示。 变更内容: - 后端新增 AiSqlQueryService 与 POST /api/ai/sql,新增 AiCallType.SQL 用量记录与提示词模板 ai-sql-system.tpl - 新增安全网关 TextToSqlGuard:仅允许单条 SELECT,拒绝写操作、多语句、危险函数与 SELECT INTO/FOR UPDATE;校验表白名单与 schema 前缀;递归注入 user_id 隔离(主表加 WHERE、JOIN 加 ON,覆盖括号连接与 CTE);结果上限 1000 - 表结构改由 TextToSqlSchemaProvider 从数据库元数据动态读取(60 秒缓存),白名单与表列中文说明集中在 TextToSqlTables,字段增删改无需调整提示词 - 新增 AiSqlQueryResultVO、AiSqlColumnVO,返回生成的 SQL、列元数据与行数据 - 前端新增智能查数页 SqlQuery.vue(路由 /smart-query),按结果形状渲染单行统计卡片、多行动态表格并支持柱状图与饼图切换 - 渠道列用 getChannelLabel 本地化展示,生成的 SQL 用 sql-formatter 缩进、highlight.js 高亮并支持复制 - 新增共享 useAiEnabled 状态,侧边栏「智能查数」菜单仅 AI 启用时显示,设置页保存 AI 配置后即时刷新 - 设置页与收支记录页文案补充智能查数相关三语文案 依赖与清理: - 新增前端依赖 sql-formatter、highlight.js - 移除仓库内测试文件 LedgerlyApplicationTests、PromptTemplateServiceTest、AiPromptPayloadPrivacyTest,此后测试源码不再纳入提交 说明: - 表结构动态读取保证字段变更零维护,仅可查表白名单需显式登记 - 安全网关对 JOIN 条件注入 ON 子句以保证 LEFT JOIN 语义,并对括号连接与 CTE 递归处理
This commit is contained in:
@@ -12,8 +12,10 @@
|
||||
"@vicons/ionicons5": "^0.13.0",
|
||||
"axios": "^1.20.0",
|
||||
"echarts": "^6.1.0",
|
||||
"highlight.js": "^11.12.0",
|
||||
"naive-ui": "^2.45.2",
|
||||
"pinia": "^4.0.3",
|
||||
"sql-formatter": "^15.9.0",
|
||||
"vue": "^3.5.41",
|
||||
"vue-echarts": "^8.1.0",
|
||||
"vue-i18n": "^11.4.12",
|
||||
|
||||
@@ -234,6 +234,36 @@ export function queryByAi(query: string) {
|
||||
return request.post('/ai/query', { query }, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** 智能查数结果的列元数据 */
|
||||
export interface AiSqlColumn {
|
||||
key: string
|
||||
title: string
|
||||
type: 'NUMBER' | 'STRING' | 'DATE' | 'DATETIME' | 'BOOLEAN'
|
||||
}
|
||||
|
||||
/** 智能查数结果 */
|
||||
export interface AiSqlQueryResult {
|
||||
/** 生成的 SQL(经安全校验后的最终语句) */
|
||||
sql: string
|
||||
/** 查询意图说明 */
|
||||
explanation: string
|
||||
/** 建议的展示类型 */
|
||||
chartType: 'SCALAR' | 'TABLE' | 'BAR' | 'PIE'
|
||||
/** 结果列元数据 */
|
||||
columns: AiSqlColumn[]
|
||||
/** 结果行数据 */
|
||||
rows: Record<string, unknown>[]
|
||||
/** 返回行数 */
|
||||
rowCount: number
|
||||
/** 是否因行数上限被截断 */
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
/** 将自然语言转换为只读 SQL 并执行查询(调用大模型,超时放宽) */
|
||||
export function queryBySql(query: string) {
|
||||
return request.post('/ai/sql', { query }, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** 自然语言记账解析结果 */
|
||||
export interface AiRecordResult {
|
||||
summary: string
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SettingsOutline, KeyOutline, LinkOutline, CubeOutline, SaveOutline, FlashOutline, StatsChartOutline, CameraOutline,
|
||||
} from '@vicons/ionicons5'
|
||||
import { getAiConfig, saveAiConfig, testAiConfig } from '../api/ai'
|
||||
import { refreshAiEnabled } from '../composables/useAiEnabled'
|
||||
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
@@ -58,6 +59,7 @@ async function handleSave() {
|
||||
await saveAiConfig(buildPayload())
|
||||
message.success(t('ai.saveSuccess'))
|
||||
await loadConfig()
|
||||
await refreshAiEnabled()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.operationFailed'))
|
||||
} finally {
|
||||
@@ -73,6 +75,7 @@ async function handleTest() {
|
||||
await testAiConfig()
|
||||
message.success(t('ai.testSuccess'))
|
||||
await loadConfig()
|
||||
await refreshAiEnabled()
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.testFailed'))
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// AI 启用状态共享 composable:全局单例,供侧边栏菜单与页面统一控制 AI 相关元素显隐
|
||||
import { ref } from 'vue'
|
||||
import { getAiConfig } from '../api/ai'
|
||||
|
||||
/** 当前用户 AI 是否启用;读取失败一律按未启用处理 */
|
||||
export const aiEnabled = ref(false)
|
||||
|
||||
/** 是否已成功加载过一次,避免重复请求 */
|
||||
let loaded = false
|
||||
|
||||
/**
|
||||
* 从后端刷新 AI 启用状态。
|
||||
* 设置页保存 AI 配置后调用,可让侧边栏菜单即时显隐。
|
||||
*/
|
||||
export async function refreshAiEnabled() {
|
||||
try {
|
||||
const res: any = await getAiConfig()
|
||||
aiEnabled.value = !!res?.data?.enabled
|
||||
} catch (e) {
|
||||
aiEnabled.value = false
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
|
||||
/** 确保至少加载过一次 AI 启用状态(已加载则不重复请求) */
|
||||
export async function ensureAiEnabledLoaded() {
|
||||
if (loaded) return
|
||||
await refreshAiEnabled()
|
||||
}
|
||||
|
||||
/** 重置为未启用(退出登录等场景使用) */
|
||||
export function resetAiEnabled() {
|
||||
aiEnabled.value = false
|
||||
loaded = false
|
||||
}
|
||||
@@ -93,6 +93,7 @@ export default {
|
||||
tags: { zh: '标签管理', en: 'Tags', ja: 'タグ管理' },
|
||||
rules: { zh: '规则管理', en: 'Rules', ja: 'ルール管理' },
|
||||
emailBills: { zh: '邮箱账单', en: 'Email Bills', ja: 'メール請求' },
|
||||
sqlQuery: { zh: '智能查数', en: 'Smart Data', ja: 'スマート集計' },
|
||||
logs: { zh: '日志', en: 'Logs', ja: 'ログ' },
|
||||
settings: { zh: '设置', en: 'Settings', ja: '設定' },
|
||||
user: { zh: '用户', en: 'User', ja: 'ユーザー' },
|
||||
@@ -845,6 +846,31 @@ export default {
|
||||
includeNo: { zh: '不计入', en: 'Excluded', ja: '含めない' },
|
||||
unmatched: { zh: '未识别', en: 'Unmatched', ja: '未認識' },
|
||||
},
|
||||
sql: {
|
||||
entry: { zh: '智能查数', en: 'Smart Data', ja: 'スマート集計' },
|
||||
title: { zh: '智能查数', en: 'Smart Data Query', ja: 'スマート集計' },
|
||||
hint: { zh: '用自然语言描述你想查的内容,AI 会生成只读查询并展示结果;只能查询你本人的数据。', en: 'Describe what you want to query; AI generates a read-only query and shows the result. Only your own data can be queried.', ja: '知りたい内容を自然言語で説明すると、AI が読み取り専用クエリを生成して結果を表示します。自分のデータのみ参照できます。' },
|
||||
placeholder: { zh: '例如:上个月一共花了多少钱', en: 'e.g. How much did I spend last month in total?', ja: '例:先月の支出合計はいくら?' },
|
||||
examples: { zh: '上个月一共花了多少钱|按分类统计今年的支出|今年收入最高的一个月|各渠道的支出占比|本月支出最高的5笔', en: 'How much did I spend last month?|Expenses by category this year|Month with highest income this year|Spending share by channel|Top 5 expenses this month', ja: '先月の支出合計|今年のカテゴリ別支出|今年の収入が最も多い月|チャネル別支出の割合|今月の支出トップ5' },
|
||||
submit: { zh: '查询', en: 'Query', ja: '検索' },
|
||||
submitting: { zh: '正在查询,请稍候…', en: 'Querying, please wait…', ja: '検索中です。しばらくお待ちください…' },
|
||||
emptyInput: { zh: '请输入查询内容', en: 'Please enter a query', ja: '検索内容を入力してください' },
|
||||
failed: { zh: '智能查数失败', en: 'Smart data query failed', ja: 'スマート集計に失敗しました' },
|
||||
resultTitle: { zh: '查询结果', en: 'Result', ja: '結果' },
|
||||
sqlTitle: { zh: '生成的 SQL', en: 'Generated SQL', ja: '生成された SQL' },
|
||||
copySql: { zh: '复制 SQL', en: 'Copy SQL', ja: 'SQL をコピー' },
|
||||
copySuccess: { zh: '已复制', en: 'Copied', ja: 'コピーしました' },
|
||||
empty: { zh: '查询没有返回结果', en: 'The query returned no result', ja: '結果がありません' },
|
||||
rowCount: { zh: '共 {count} 行', en: '{count} row(s)', ja: '{count} 行' },
|
||||
truncated: { zh: '结果超过 {max} 行,仅展示前 {max} 行', en: 'More than {max} rows; only the first {max} are shown', ja: '{max} 行を超えるため、先頭 {max} 行のみ表示します' },
|
||||
viewTable: { zh: '表格', en: 'Table', ja: 'テーブル' },
|
||||
viewBar: { zh: '柱状图', en: 'Bar chart', ja: '棒グラフ' },
|
||||
viewPie: { zh: '饼图', en: 'Pie chart', ja: '円グラフ' },
|
||||
examplesLabel: { zh: '示例', en: 'Examples', ja: '例' },
|
||||
history: { zh: '历史提问', en: 'History', ja: '履歴' },
|
||||
clearHistory: { zh: '清空历史', en: 'Clear history', ja: '履歴をクリア' },
|
||||
needConfig: { zh: 'AI 未启用,请先在「设置」中开启 AI。', en: 'AI is not enabled. Please enable it in Settings first.', ja: 'AI が有効になっていません。「設定」で有効にしてください。' },
|
||||
},
|
||||
record: {
|
||||
button: { zh: '智能记账', en: 'Smart Entry', ja: 'スマート入力' },
|
||||
title: { zh: 'AI 智能记账', en: 'AI Smart Entry', ja: 'AI スマート入力' },
|
||||
@@ -923,6 +949,7 @@ export default {
|
||||
rule: { zh: '生成规则', en: 'Rule', ja: 'ルール生成' },
|
||||
statistics: { zh: '统计搜索', en: 'Statistics', ja: '統計検索' },
|
||||
relation: { zh: '生成关系', en: 'Relation', ja: '関連生成' },
|
||||
sql: { zh: '智能查数', en: 'Smart Data', ja: 'スマート集計' },
|
||||
},
|
||||
column: {
|
||||
time: { zh: '时间', en: 'Time', ja: '日時' },
|
||||
|
||||
@@ -41,6 +41,11 @@ const router = createRouter({
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'), // 统计面板
|
||||
},
|
||||
{
|
||||
path: 'smart-query',
|
||||
name: 'SqlQuery',
|
||||
component: () => import('../views/SqlQuery.vue'), // 智能查数(自然语言转 SQL)
|
||||
},
|
||||
{
|
||||
path: 'transactions',
|
||||
name: 'Transactions',
|
||||
|
||||
@@ -33,6 +33,8 @@ export const STORAGE_KEYS = {
|
||||
transactionAiQueryHistory: 'ledgerly.transaction.aiQueryHistory',
|
||||
// 收支记录 AI 查询浮窗位置尺寸
|
||||
transactionAiQueryWindow: 'ledgerly.transaction.aiQueryWindow',
|
||||
// 智能查数的历史提问
|
||||
sqlQueryHistory: 'ledgerly.sqlQuery.history',
|
||||
// 内容区滚动位置(sessionStorage,按路由拼接后缀)
|
||||
scrollPrefix: 'ledgerly.scroll.',
|
||||
} as const
|
||||
|
||||
@@ -5,12 +5,13 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NLayout, NLayoutSider, NLayoutContent, NLayoutHeader, NMenu, NSpace, NIcon, NDropdown } from 'naive-ui'
|
||||
import type { MenuOption } from 'naive-ui'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline } from '@vicons/ionicons5'
|
||||
import { BarChartOutline, ListOutline, FolderOpenOutline, PricetagsOutline, OptionsOutline, MailOutline, DocumentTextOutline, SettingsOutline, LogOutOutline, ChevronDownOutline, WalletOutline, SparklesOutline } from '@vicons/ionicons5'
|
||||
import { useUserStore } from '../store/user'
|
||||
import LocaleSwitch from '../components/LocaleSwitch.vue'
|
||||
import { getChannels } from '../api/transaction'
|
||||
import { readSessionString, writeSessionString } from '../utils/storage'
|
||||
import { scrollKey } from '../utils/storageKeys'
|
||||
import { aiEnabled, refreshAiEnabled } from '../composables/useAiEnabled'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -21,6 +22,7 @@ const { t } = useI18n()
|
||||
const activeKey = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
'/dashboard': 'dashboard',
|
||||
'/smart-query': 'smart-query',
|
||||
'/transactions': 'transactions',
|
||||
'/categories': 'categories',
|
||||
'/tags': 'tags',
|
||||
@@ -41,15 +43,21 @@ function renderIcon(icon: any) {
|
||||
return () => h(NIcon, null, { default: () => h(icon) })
|
||||
}
|
||||
|
||||
// 侧边栏菜单配置项(随语言切换响应式更新)
|
||||
const menuOptions = computed<MenuOption[]>(() => [
|
||||
{ label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) },
|
||||
{ label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) },
|
||||
{ label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) },
|
||||
{ label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) },
|
||||
{ label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) },
|
||||
{ label: t('layout.emailBills'), key: 'email-bills', icon: renderIcon(MailOutline) },
|
||||
])
|
||||
// 侧边栏菜单配置项(随语言切换响应式更新;智能查数仅在 AI 启用时展示)
|
||||
const menuOptions = computed<MenuOption[]>(() => {
|
||||
const options: MenuOption[] = [
|
||||
{ label: t('layout.dashboard'), key: 'dashboard', icon: renderIcon(BarChartOutline) },
|
||||
{ label: t('layout.transactions'), key: 'transactions', icon: renderIcon(ListOutline) },
|
||||
{ label: t('layout.categories'), key: 'categories', icon: renderIcon(FolderOpenOutline) },
|
||||
{ label: t('layout.tags'), key: 'tags', icon: renderIcon(PricetagsOutline) },
|
||||
{ label: t('layout.rules'), key: 'rules', icon: renderIcon(OptionsOutline) },
|
||||
{ label: t('layout.emailBills'), key: 'email-bills', icon: renderIcon(MailOutline) },
|
||||
]
|
||||
if (aiEnabled.value) {
|
||||
options.splice(1, 0, { label: t('layout.sqlQuery'), key: 'smart-query', icon: renderIcon(SparklesOutline) })
|
||||
}
|
||||
return options
|
||||
})
|
||||
|
||||
/** 菜单点击事件:根据 key 导航到对应路由 */
|
||||
function handleMenuUpdate(key: string) {
|
||||
@@ -184,6 +192,8 @@ onMounted(() => {
|
||||
restoreScroll(route.path)
|
||||
// 预加载渠道列表,保证自定义渠道名称在各页面可正确展示
|
||||
loadChannels()
|
||||
// 加载 AI 启用状态,控制「智能查数」菜单显隐(每次登录进入布局时刷新,避免沿用上一账号状态)
|
||||
refreshAiEnabled()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
<script setup lang="ts">
|
||||
// 智能查数页:将自然语言交给大模型生成只读 SQL 并执行,自适应渲染统计卡片、表格与图表
|
||||
import { computed, defineAsyncComponent, h, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
NAlert, NButton, NCard, NCollapse, NCollapseItem, NDataTable, NEmpty, NGrid, NGridItem,
|
||||
NIcon, NInput, NSelect, 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 { queryBySql } from '../api/ai'
|
||||
import type { AiSqlColumn, AiSqlQueryResult } from '../api/ai'
|
||||
import { getChannelLabel } from '../utils/channel'
|
||||
import { aiEnabled, ensureAiEnabledLoaded } from '../composables/useAiEnabled'
|
||||
import { useKeywordHistory } from '../composables/useKeywordHistory'
|
||||
import { usePageSizeOptions } from '../composables/usePageSizeOptions'
|
||||
import { useTablePageSize } from '../composables/useTablePageSize'
|
||||
import { STORAGE_KEYS } from '../utils/storageKeys'
|
||||
|
||||
// 图表组件异步加载: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()
|
||||
|
||||
/** 展示视图类型 */
|
||||
type ViewMode = 'table' | 'bar' | 'pie'
|
||||
|
||||
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 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 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])),
|
||||
}))
|
||||
})
|
||||
|
||||
// 图表配置:饼图取首个数值列,柱状图取全部数值列
|
||||
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
|
||||
recordHistory()
|
||||
} 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 | number | null) {
|
||||
if (typeof value !== 'string' || !value) return
|
||||
queryText.value = value
|
||||
handleQuery()
|
||||
}
|
||||
|
||||
/** 判断列是否为交易渠道列:与现有页面 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
// 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'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
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>
|
||||
|
||||
<NAlert v-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>
|
||||
|
||||
<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>
|
||||
|
||||
<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" 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;
|
||||
}
|
||||
.sql-code code.hljs {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user