为智能查数补充提问历史的后端持久化(含创建时间、可清空),并完善前端历史展示、会话缓存与若干体验问题。 变更内容: - 新增 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
594 lines
19 KiB
Vue
594 lines
19 KiB
Vue
<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, '&')
|
||
.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'))
|
||
}
|
||
}
|
||
|
||
/** 恢复上一次查询会话(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>
|