style(debug): unify lab workspace card surfaces

This commit is contained in:
digua committed 2026-07-12 01:44:52 +08:00
1 parent 12c7fc2105
commit 8f1f9a56ac
3 files changed
+277 -252

No files matched your search

+153 -151
View File
@@ -5,7 +5,7 @@ import type { TableSchema, SQLResult } from '@/components/analysis/SQLLab'
import { useDataService, useAIService } from '@/services'
import { getColumnLabel } from '@/components/analysis/SQLLab'
import type { LocaleType } from '@/i18n/types'
import { UITabs } from '@/components/UI'
import { ThemeCard, UITabs } from '@/components/UI'
const PRIORITY_TABLES = ['member', 'meta', 'message', 'message_context', 'member_name_history']
@@ -333,165 +333,167 @@ onMounted(async () => {
</script>
<template>
<div class="flex h-full flex-col">
<!-- Toolbar -->
<div class="relative z-20 flex items-center gap-2 border-b border-gray-200 px-4 py-2 dark:border-gray-700">
<!-- DB Source Tabs -->
<UITabs v-model="dbSource" :items="dbTabs" size="xs" />
<div class="h-full p-3">
<ThemeCard class="flex h-full min-w-0 flex-col">
<!-- Toolbar -->
<div class="relative z-20 flex items-center gap-2 border-b border-gray-200 px-4 py-2 dark:border-gray-700">
<!-- DB Source Tabs -->
<UITabs v-model="dbSource" :items="dbTabs" size="xs" />
<div class="mx-1 h-4 w-px bg-gray-200 dark:bg-gray-700" />
<div class="mx-1 h-4 w-px bg-gray-200 dark:bg-gray-700" />
<!-- Table Tabs -->
<div :class="isAiDb ? 'max-w-[50%]' : 'flex-1'" class="min-w-0">
<UITabs v-model="selectedTable" :items="tableItems" size="xs" />
<!-- Table Tabs -->
<div :class="isAiDb ? 'max-w-[50%]' : 'flex-1'" class="min-w-0">
<UITabs v-model="selectedTable" :items="tableItems" size="xs" />
</div>
<!-- Conversation Filter Dropdown (AI DB + ai_message) -->
<USelectMenu
v-if="showConversationFilter"
v-model="selectedAIChat"
:items="conversationItems"
value-key="value"
class="w-48"
size="xs"
:ui="{ content: 'z-50' }"
/>
<div class="flex-1" />
<UButton
v-if="isAiDb && selectedTable === 'ai_message'"
variant="ghost"
size="xs"
color="error"
icon="i-heroicons-trash"
:loading="isClearingDebug"
@click="handleClearDebugContext"
>
{{ t('analysis.debug.tableBrowser.clearDebugContext') }}
</UButton>
<UButton variant="ghost" size="xs" icon="i-heroicons-arrow-path" :loading="isLoading" @click="loadTableData">
{{ t('analysis.debug.tableBrowser.refresh') }}
</UButton>
</div>
<!-- Conversation Filter Dropdown (AI DB + ai_message) -->
<USelectMenu
v-if="showConversationFilter"
v-model="selectedAIChat"
:items="conversationItems"
value-key="value"
class="w-48"
size="xs"
:ui="{ content: 'z-50' }"
/>
<div class="flex-1" />
<UButton
v-if="isAiDb && selectedTable === 'ai_message'"
variant="ghost"
size="xs"
color="error"
icon="i-heroicons-trash"
:loading="isClearingDebug"
@click="handleClearDebugContext"
<!-- Error -->
<div
v-if="error"
class="mx-4 mt-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{{ t('analysis.debug.tableBrowser.clearDebugContext') }}
</UButton>
<UButton variant="ghost" size="xs" icon="i-heroicons-arrow-path" :loading="isLoading" @click="loadTableData">
{{ t('analysis.debug.tableBrowser.refresh') }}
</UButton>
</div>
<!-- Error -->
<div
v-if="error"
class="mx-4 mt-2 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{{ error }}
</div>
<!-- Table Content -->
<div class="flex-1 overflow-auto">
<div v-if="!result && !isLoading" class="flex h-full items-center justify-center">
<p class="text-sm text-gray-400">{{ t('analysis.debug.tableBrowser.noData') }}</p>
{{ error }}
</div>
<table v-else-if="result" class="w-full border-collapse text-xs">
<thead class="sticky top-0 bg-gray-50 dark:bg-page-dark">
<tr>
<th
class="whitespace-nowrap border-b border-r border-gray-200 px-2 py-1.5 text-left font-medium text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
#
</th>
<th
v-for="col in result.columns"
:key="col"
class="border-b border-r border-gray-200 px-2 py-1.5 text-left dark:border-gray-700"
>
<div class="font-mono text-xs font-medium text-gray-700 dark:text-gray-300">{{ col }}</div>
<div class="text-[10px] leading-tight text-gray-400">
{{ getColumnLabel(selectedTable, col, locale as LocaleType) }}
</div>
</th>
<th
class="whitespace-nowrap border-b border-gray-200 px-2 py-1.5 text-center font-medium text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
操作
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIdx) in result.rows"
:key="rowIdx"
class="transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<td class="border-b border-r border-gray-100 px-2 py-1.5 text-gray-400 dark:border-gray-800">
{{ (currentPage - 1) * pageSize + rowIdx + 1 }}
</td>
<td
v-for="(cell, colIdx) in row"
:key="colIdx"
class="max-w-[300px] border-b border-r border-gray-100 p-0 text-gray-700 dark:border-gray-800 dark:text-gray-300"
:title="cell == null ? 'NULL' : String(cell)"
>
<template v-if="editingRowIndex === rowIdx">
<input
v-model="editingValues[result.columns[colIdx]]"
class="w-full px-2 py-1.5 rounded border border-gray-300 bg-white text-xs dark:border-gray-600 dark:bg-gray-800"
/>
</template>
<UContextMenu v-else :items="getCellContextMenu(cell, colIdx, rowIdx)">
<div class="w-full truncate px-2 py-1.5">
<span v-if="cell == null" class="italic text-gray-400">NULL</span>
<span v-else>{{ String(cell).slice(0, 200) }}</span>
<!-- Table Content -->
<div class="flex-1 overflow-auto">
<div v-if="!result && !isLoading" class="flex h-full items-center justify-center">
<p class="text-sm text-gray-400">{{ t('analysis.debug.tableBrowser.noData') }}</p>
</div>
<table v-else-if="result" class="w-full border-collapse text-xs">
<thead class="sticky top-0 bg-gray-50 dark:bg-page-dark">
<tr>
<th
class="whitespace-nowrap border-b border-r border-gray-200 px-2 py-1.5 text-left font-medium text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
#
</th>
<th
v-for="col in result.columns"
:key="col"
class="border-b border-r border-gray-200 px-2 py-1.5 text-left dark:border-gray-700"
>
<div class="font-mono text-xs font-medium text-gray-700 dark:text-gray-300">{{ col }}</div>
<div class="text-[10px] leading-tight text-gray-400">
{{ getColumnLabel(selectedTable, col, locale as LocaleType) }}
</div>
</UContextMenu>
</td>
<td class="border-b border-gray-100 px-2 py-1.5 text-center dark:border-gray-800">
<template v-if="editingRowIndex === rowIdx">
<div class="flex items-center justify-center gap-1">
<UButton size="xs" color="primary" variant="ghost" icon="i-heroicons-check" @click="saveEdit" />
<UButton size="xs" color="neutral" variant="ghost" icon="i-heroicons-x-mark" @click="cancelEdit" />
</div>
</template>
<template v-else>
<div class="flex items-center justify-center gap-1">
<UButton size="xs" variant="ghost" icon="i-heroicons-pencil-square" @click="startEdit(rowIdx)" />
<UButton
size="xs"
variant="ghost"
color="error"
icon="i-heroicons-trash"
@click="confirmDelete(rowIdx)"
</th>
<th
class="whitespace-nowrap border-b border-gray-200 px-2 py-1.5 text-center font-medium text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
操作
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIdx) in result.rows"
:key="rowIdx"
class="transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<td class="border-b border-r border-gray-100 px-2 py-1.5 text-gray-400 dark:border-gray-800">
{{ (currentPage - 1) * pageSize + rowIdx + 1 }}
</td>
<td
v-for="(cell, colIdx) in row"
:key="colIdx"
class="max-w-[300px] border-b border-r border-gray-100 p-0 text-gray-700 dark:border-gray-800 dark:text-gray-300"
:title="cell == null ? 'NULL' : String(cell)"
>
<template v-if="editingRowIndex === rowIdx">
<input
v-model="editingValues[result.columns[colIdx]]"
class="w-full px-2 py-1.5 rounded border border-gray-300 bg-white text-xs dark:border-gray-600 dark:bg-gray-800"
/>
</div>
</template>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Footer: Row Count + Pagination -->
<div class="flex items-center justify-between border-t border-gray-200 px-4 py-2 dark:border-gray-700">
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ totalRows > 0 ? t('analysis.debug.tableBrowser.rowCount', { count: totalRows }) : '' }}
</span>
<div v-if="totalPages > 1" class="flex items-center gap-3">
<UButton
size="xs"
variant="ghost"
:disabled="currentPage <= 1"
icon="i-heroicons-chevron-left"
@click="currentPage--"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('analysis.debug.tableBrowser.page', { current: currentPage, total: totalPages }) }}
</span>
<UButton
size="xs"
variant="ghost"
:disabled="currentPage >= totalPages"
icon="i-heroicons-chevron-right"
@click="currentPage++"
/>
</template>
<UContextMenu v-else :items="getCellContextMenu(cell, colIdx, rowIdx)">
<div class="w-full truncate px-2 py-1.5">
<span v-if="cell == null" class="italic text-gray-400">NULL</span>
<span v-else>{{ String(cell).slice(0, 200) }}</span>
</div>
</UContextMenu>
</td>
<td class="border-b border-gray-100 px-2 py-1.5 text-center dark:border-gray-800">
<template v-if="editingRowIndex === rowIdx">
<div class="flex items-center justify-center gap-1">
<UButton size="xs" color="primary" variant="ghost" icon="i-heroicons-check" @click="saveEdit" />
<UButton size="xs" color="neutral" variant="ghost" icon="i-heroicons-x-mark" @click="cancelEdit" />
</div>
</template>
<template v-else>
<div class="flex items-center justify-center gap-1">
<UButton size="xs" variant="ghost" icon="i-heroicons-pencil-square" @click="startEdit(rowIdx)" />
<UButton
size="xs"
variant="ghost"
color="error"
icon="i-heroicons-trash"
@click="confirmDelete(rowIdx)"
/>
</div>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Footer: Row Count + Pagination -->
<div class="flex items-center justify-between border-t border-gray-200 px-4 py-2 dark:border-gray-700">
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ totalRows > 0 ? t('analysis.debug.tableBrowser.rowCount', { count: totalRows }) : '' }}
</span>
<div v-if="totalPages > 1" class="flex items-center gap-3">
<UButton
size="xs"
variant="ghost"
:disabled="currentPage <= 1"
icon="i-heroicons-chevron-left"
@click="currentPage--"
/>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('analysis.debug.tableBrowser.page', { current: currentPage, total: totalPages }) }}
</span>
<UButton
size="xs"
variant="ghost"
:disabled="currentPage >= totalPages"
icon="i-heroicons-chevron-right"
@click="currentPage++"
/>
</div>
</div>
</ThemeCard>
</div>
</template>
+95 -92
View File
@@ -2,6 +2,7 @@
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAIService } from '@/services'
import { ThemeCard } from '@/components/UI'
const { t } = useI18n()
@@ -162,7 +163,7 @@ async function cancel() {
<div class="flex h-full">
<!-- Left Sidebar: Tool List -->
<div
class="w-56 shrink-0 overflow-y-auto border-r border-gray-200 bg-gray-50/50 dark:border-gray-700 dark:bg-page-dark/50"
class="m-3 h-[calc(100%-1.5rem)] w-56 shrink-0 overflow-y-auto rounded-lg bg-white shadow-elevated dark:bg-sidebar-dark"
>
<!-- Core Tools Group -->
<div v-if="coreTools.length > 0" class="py-2">
@@ -210,101 +211,103 @@ async function cancel() {
</div>
<!-- Right Content: Parameters + Result -->
<div class="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
<!-- Tool Description -->
<div
v-if="selectedTool"
class="rounded-lg border border-primary-200 bg-primary-50/50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
>
<div class="text-sm font-medium text-primary-700 dark:text-primary-300">
{{ toolLabel(selectedTool.name) }}
</div>
<div class="mt-1 text-xs text-primary-600/70 dark:text-primary-400/70">
{{ selectedTool.description }}
</div>
</div>
<!-- Parameters Form -->
<div v-if="paramFields.length > 0" class="flex flex-col gap-3">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.lab.toolTest.parameters') }}
</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="field in paramFields" :key="field.name" class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">
{{ field.name }}
<span v-if="field.required" class="text-red-500">*</span>
<span v-if="field.type === 'number'" class="ml-1 text-gray-400">(number)</span>
<span v-if="field.isArray" class="ml-1 text-gray-400">({{ field.arrayItemType }}[])</span>
</label>
<USelectMenu
v-if="field.enumValues"
v-model="paramValues[field.name]"
:items="field.enumValues"
:placeholder="field.description"
size="sm"
/>
<UInput
v-else
v-model="paramValues[field.name]"
:placeholder="
field.isArray
? t('ai.lab.toolTest.arrayPlaceholder')
: field.defaultValue !== undefined
? String(field.defaultValue)
: field.description
"
:type="field.type === 'number' ? 'number' : 'text'"
size="sm"
/>
</div>
</div>
</div>
<!-- Execute / Cancel Button -->
<div class="flex items-center gap-3">
<UButton
v-if="!isExecuting"
color="primary"
:disabled="!selectedToolName"
icon="i-heroicons-play"
@click="execute"
>
{{ t('ai.lab.toolTest.execute') }}
</UButton>
<UButton v-else color="error" icon="i-heroicons-stop" @click="cancel">
{{ t('ai.lab.toolTest.cancel') }}
</UButton>
<UIcon v-if="isExecuting" name="i-heroicons-arrow-path" class="h-4 w-4 animate-spin text-gray-400" />
<span v-if="elapsed !== null" class="text-xs text-gray-500 dark:text-gray-400">
{{ t('ai.lab.toolTest.elapsed', { ms: elapsed }) }}
</span>
</div>
<!-- Result -->
<div v-if="resultJson || resultError" class="flex-1 min-h-0">
<h3 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.lab.toolTest.result') }}
</h3>
<ThemeCard class="my-3 mr-3 flex min-w-0 flex-1 flex-col">
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
<!-- Tool Description -->
<div
v-if="resultError"
class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
v-if="selectedTool"
class="rounded-lg border border-primary-200 bg-primary-50/50 px-4 py-3 dark:border-primary-800 dark:bg-primary-900/20"
>
{{ resultError }}
</div>
<template v-else>
<div
v-if="resultTruncated"
class="mb-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{ t('ai.lab.toolTest.truncated') }}
<div class="text-sm font-medium text-primary-700 dark:text-primary-300">
{{ toolLabel(selectedTool.name) }}
</div>
<pre
class="max-h-[60vh] overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs leading-relaxed text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
>{{ resultJson }}</pre
<div class="mt-1 text-xs text-primary-600/70 dark:text-primary-400/70">
{{ selectedTool.description }}
</div>
</div>
<!-- Parameters Form -->
<div v-if="paramFields.length > 0" class="flex flex-col gap-3">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.lab.toolTest.parameters') }}
</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div v-for="field in paramFields" :key="field.name" class="flex flex-col gap-1">
<label class="text-xs font-medium text-gray-600 dark:text-gray-400">
{{ field.name }}
<span v-if="field.required" class="text-red-500">*</span>
<span v-if="field.type === 'number'" class="ml-1 text-gray-400">(number)</span>
<span v-if="field.isArray" class="ml-1 text-gray-400">({{ field.arrayItemType }}[])</span>
</label>
<USelectMenu
v-if="field.enumValues"
v-model="paramValues[field.name]"
:items="field.enumValues"
:placeholder="field.description"
size="sm"
/>
<UInput
v-else
v-model="paramValues[field.name]"
:placeholder="
field.isArray
? t('ai.lab.toolTest.arrayPlaceholder')
: field.defaultValue !== undefined
? String(field.defaultValue)
: field.description
"
:type="field.type === 'number' ? 'number' : 'text'"
size="sm"
/>
</div>
</div>
</div>
<!-- Execute / Cancel Button -->
<div class="flex items-center gap-3">
<UButton
v-if="!isExecuting"
color="primary"
:disabled="!selectedToolName"
icon="i-heroicons-play"
@click="execute"
>
</template>
{{ t('ai.lab.toolTest.execute') }}
</UButton>
<UButton v-else color="error" icon="i-heroicons-stop" @click="cancel">
{{ t('ai.lab.toolTest.cancel') }}
</UButton>
<UIcon v-if="isExecuting" name="i-heroicons-arrow-path" class="h-4 w-4 animate-spin text-gray-400" />
<span v-if="elapsed !== null" class="text-xs text-gray-500 dark:text-gray-400">
{{ t('ai.lab.toolTest.elapsed', { ms: elapsed }) }}
</span>
</div>
<!-- Result -->
<div v-if="resultJson || resultError" class="flex-1 min-h-0">
<h3 class="mb-2 text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('ai.lab.toolTest.result') }}
</h3>
<div
v-if="resultError"
class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
>
{{ resultError }}
</div>
<template v-else>
<div
v-if="resultTruncated"
class="mb-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{ t('ai.lab.toolTest.truncated') }}
</div>
<pre
class="max-h-[60vh] overflow-auto rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs leading-relaxed text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
>{{ resultJson }}</pre
>
</template>
</div>
</div>
</div>
</ThemeCard>
</div>
</template>
+29 -9
View File
@@ -67,19 +67,39 @@ onMounted(() => {
<template>
<div
class="flex flex-col border-r border-gray-200 bg-white transition-all dark:border-gray-800 dark:bg-page-dark"
:class="isCollapsed ? 'w-10' : 'w-56'"
class="m-3 flex h-[calc(100%-1.5rem)] shrink-0 flex-col overflow-hidden rounded-lg bg-white shadow-elevated transition-all duration-300 ease-in-out dark:bg-sidebar-dark"
:class="isCollapsed ? 'w-14' : 'w-56'"
>
<!-- 面板头部 -->
<div class="flex items-center justify-between border-b border-gray-200 p-2 dark:border-gray-800">
<div
class="flex items-center"
:class="
isCollapsed
? 'justify-center px-2 pb-2 pt-5'
: 'justify-between border-b border-gray-200 py-2 pl-3 pr-2 dark:border-gray-800'
"
>
<span v-if="!isCollapsed" class="text-xs font-medium text-gray-500 dark:text-gray-400">
{{ t('ai.sqlLab.schema.tables') }}
</span>
<button
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
<UButton
v-if="!isCollapsed"
color="neutral"
variant="ghost"
size="sm"
class="group flex h-9 w-9 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-white/[0.06]"
@click="isCollapsed = !isCollapsed"
>
<UIcon :name="isCollapsed ? 'i-heroicons-chevron-right' : 'i-heroicons-chevron-left'" class="h-4 w-4" />
<UIcon name="i-lucide-panel-right" class="size-4 scale-x-[-1] group-hover:hidden" />
<UIcon name="i-lucide-panel-right-close" class="size-4 hidden scale-x-[-1] group-hover:block" />
</UButton>
<button
v-else
type="button"
class="group relative flex h-9 w-9 cursor-pointer items-center justify-center rounded-full hover:bg-gray-200/60 dark:hover:bg-white/[0.06]"
@click="isCollapsed = !isCollapsed"
>
<UIcon name="i-lucide-panel-right-open" class="size-4 scale-x-[-1]" />
</button>
</div>
@@ -127,15 +147,15 @@ onMounted(() => {
</div>
<!-- 折叠时显示图标列表 -->
<div v-else class="flex flex-1 flex-col items-center gap-2 overflow-y-auto py-2">
<div v-else class="flex flex-1 flex-col items-center gap-1 overflow-y-auto px-2 pb-2">
<button
v-for="table in schema"
:key="table.name"
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-pink-500 dark:hover:bg-gray-800"
class="flex h-10 w-10 items-center justify-center rounded-lg text-gray-600 transition-colors hover:bg-gray-200/40 hover:text-primary-600 dark:text-gray-300 dark:hover:bg-white/[0.06] dark:hover:text-primary-400"
:title="`${getTableLabel(table.name, locale as LocaleType)} (${table.name})`"
@click="expandTable(table.name)"
>
<UIcon name="i-heroicons-table-cells" class="h-4 w-4" />
<UIcon name="i-heroicons-table-cells" class="h-5 w-5" />
</button>
</div>
</div>