improve(ui):优化租户端页面显示效果

1. 表格占位内容居中显示
2. 在线体验菜单前移
3. 在线体验功能切换tab任务不丢失
This commit is contained in:
qian committed 2026-09-15 00:29:29 +08:00
1 parent c9cdfd7138
commit f3e930d027
5 files changed
+84 -89

No files matched your search

+11 -11
View File
@@ -109,35 +109,41 @@ const tenantRoutes: RouteRecordRaw[] = [
component: () => import('@/views/service/ModelsPage.vue'),
meta: { title: '可用模型', icon: 'cube', sort: 30, roles: ['owner', 'admin', 'member'] },
},
{
path: 'playground',
name: 'TenantPlayground',
component: () => import('@/views/service/PlaygroundPage.vue'),
meta: { title: '在线体验', icon: 'terminal', sort: 31, roles: ['owner', 'admin', 'member'], badge: '⚡' },
},
{
path: 'model-comparison',
name: 'TenantModelComparison',
component: () => import('@/views/service/ModelComparisonPage.vue'),
meta: { title: '模型对比', icon: 'chart', sort: 31, roles: ['owner', 'admin', 'member'] },
meta: { title: '模型对比', icon: 'chart', sort: 32, roles: ['owner', 'admin', 'member'] },
},
{
path: 'api-keys',
name: 'TenantApiKeys',
component: () => import('@/views/service/ApiKeysPage.vue'),
meta: { title: 'API 密钥', icon: 'key', sort: 32, roles: ['owner', 'admin', 'member'], viewportTable: true },
meta: { title: 'API 密钥', icon: 'key', sort: 33, roles: ['owner', 'admin', 'member'], viewportTable: true },
},
{
path: 'usage-logs',
name: 'TenantUsageLogs',
component: () => import('@/views/service/UsageLogsPage.vue'),
meta: { title: '用量日志', icon: 'chart', sort: 33, roles: ['owner', 'admin', 'member'], viewportTable: true },
meta: { title: '用量日志', icon: 'chart', sort: 34, roles: ['owner', 'admin', 'member'], viewportTable: true },
},
{
path: 'request-audit-logs',
name: 'TenantRequestAuditLogs',
component: () => import('@/views/service/RequestAuditLogsPage.vue'),
meta: { title: '请求审计日志', icon: 'clipboard', sort: 34, roles: ['owner', 'admin'], viewportTable: true },
meta: { title: '请求审计日志', icon: 'clipboard', sort: 35, roles: ['owner', 'admin'], viewportTable: true },
},
{
path: 'task-logs',
name: 'TenantTaskLogs',
component: () => import('@/views/service/TaskLogsPage.vue'),
meta: { title: '任务日志', icon: 'clock', sort: 35, roles: ['owner', 'admin', 'member'], viewportTable: true },
meta: { title: '任务日志', icon: 'clock', sort: 36, roles: ['owner', 'admin', 'member'], viewportTable: true },
},
// 财务 (40-49)
{
@@ -177,12 +183,6 @@ const tenantRoutes: RouteRecordRaw[] = [
component: () => import('@/views/notification/NotificationPreferencesPage.vue'),
meta: { title: '通知偏好', roles: ['owner', 'admin'] },
},
{
path: 'playground',
name: 'TenantPlayground',
component: () => import('@/views/service/PlaygroundPage.vue'),
meta: { title: '在线体验', icon: 'terminal', sort: 50, roles: ['owner', 'admin', 'member'], badge: '⚡' },
},
{
path: 'tickets',
name: 'TenantTickets',
+10
View File
@@ -459,6 +459,16 @@ body.modal-open {
flex: 0 0 auto;
}
}
/* Naive UI flex-height 表格空态垂直居中:空态节点 .n-data-table-empty 自带 flex-grow:1 + 居中,
但其父级滚动 content(.n-scrollbar-content)默认高度塌成内容高且非 flex 容器,flex-grow 无从生效,
「暂无数据」会顶在表体上方。空数据时把 content 拉满表体高度并转纵向 flex 即可;
限定 :has(空态节点) 保证有数据时表格布局零影响 */
.n-data-table--flex-height .n-data-table-base-table-body .n-scrollbar-content:has(> .n-data-table-empty) {
min-height: 100%;
display: flex;
flex-direction: column;
}
.table-container-flush {
border: 0;
border-radius: 0;
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, type Component } from 'vue'
import { NSelect } from 'naive-ui'
import request from '@/utils/request'
import ChatTab from './playground/ChatTab.vue'
@@ -39,8 +39,21 @@ const allModels = ref<ModelItem[]>([])
const { apiKeys, selectedKeyId, revealedKey, loading: keyLoading, error: keyError, selectKey } = usePlaygroundApiKey()
const modelsByCategory = (category: string) =>
computed(() => allModels.value.filter(m => m.category === category))
// Tab 与组件的映射:六个 Tab 的 props 签名一致(models + apiKey),配合 KeepAlive
// 按 key 缓存实例,切换 Tab 不销毁组件,对话/任务/参数等状态全部保留。
const tabComponents: Record<string, Component> = {
chat: ChatTab,
image: ImageTab,
video: VideoTab,
audio: AudioTab,
embedding: EmbeddingTab,
rerank: RerankTab,
}
// 当前 Tab 对应分类的模型列表(传给 KeepAlive 缓存的组件,切换回来时 props 保持响应)
const activeTabModels = computed(() =>
allModels.value.filter(m => m.category === activeTab.value),
)
const modelsLoading = ref(false)
let loadingTimer: number | null = null
@@ -131,12 +144,15 @@ watch(selectedKeyId, loadModels, { immediate: true })
</div>
<template v-if="revealedKey">
<ChatTab v-if="activeTab === 'chat'" :models="modelsByCategory('chat').value" :api-key="revealedKey" />
<ImageTab v-if="activeTab === 'image'" :models="modelsByCategory('image').value" :api-key="revealedKey" />
<VideoTab v-if="activeTab === 'video'" :models="modelsByCategory('video').value" :api-key="revealedKey" />
<AudioTab v-if="activeTab === 'audio'" :models="modelsByCategory('audio').value" :api-key="revealedKey" />
<EmbeddingTab v-if="activeTab === 'embedding'" :models="modelsByCategory('embedding').value" :api-key="revealedKey" />
<RerankTab v-if="activeTab === 'rerank'" :models="modelsByCategory('rerank').value" :api-key="revealedKey" />
<!-- KeepAlive 缓存各 Tab 实例:切换不销毁,切回即恢复(含进行中的任务轮询与已生成的结果) -->
<KeepAlive>
<component
:is="tabComponents[activeTab]"
:key="activeTab"
:models="activeTabModels"
:api-key="revealedKey"
/>
</KeepAlive>
</template>
</div>
</template>
@@ -5,6 +5,7 @@ import { createPlaygroundApi } from '@/utils/playgroundApi'
import { createPoller } from '@/composables/usePolling'
import { calculateCost } from './calculateCost'
import ParamRefModal from './ParamRefModal.vue'
import GenerationProgressCard from './GenerationProgressCard.vue'
import Icon from '@/components/common/Icon.vue'
import BaseSelect from '../../../components/common/BaseSelect.vue'
@@ -203,14 +204,6 @@ const statusLabel: Record<string, string> = {
SUCCESS: '已完成',
FAILURE: '失败',
}
const statusColor: Record<string, string> = {
SUBMITTED: 'badge-primary',
IN_PROGRESS: 'badge-warning',
NOT_START: 'badge-gray',
QUEUED: 'badge-gray',
SUCCESS: 'badge-success',
FAILURE: 'badge-danger',
}
// 切换模型时停止上一模型的轮询并清空结果,重置模式为默认(异步)
watch(selectedModel, () => {
@@ -578,34 +571,29 @@ function closeZoom() {
<h3 class="empty-state-title">等待生成</h3>
<p class="empty-state-description">输入提示词并点击生成</p>
</div>
<div v-if="sending" class="flex items-center justify-center py-12">
<div class="spinner h-8 w-8 text-primary-600"></div>
<span class="ml-3 text-sm text-gray-500">{{ effectiveMode === 'async' ? '提交图片生成任务中...' : '图片生成中...' }}</span>
</div>
<!-- 提交中 / 同步生成中:卡片式加载占位 -->
<GenerationProgressCard
v-if="sending"
kind="image"
:label="effectiveMode === 'async' ? '正在提交生成任务...' : '图片生成中...'"
/>
<!-- 异步任务状态 -->
<div v-if="asyncTask && !sending" class="mb-4 space-y-3">
<div class="flex items-center gap-3">
<span class="badge" :class="statusColor[asyncTask.status] || 'badge-gray'">
{{ statusLabel[asyncTask.status] || asyncTask.status }}
</span>
<span v-if="asyncTask.progress" class="text-xs text-gray-500">进度: {{ asyncTask.progress }}</span>
<span v-if="polling" class="text-xs text-primary-600 flex items-center gap-1">
<div class="spinner h-3 w-3"></div>
轮询中
</span>
<span v-if="asyncTask.id" class="text-xs text-gray-400 ml-auto">task_id: {{ asyncTask.id }}</span>
</div>
<div v-if="asyncTask.status === 'IN_PROGRESS' || asyncTask.status === 'SUBMITTED' || asyncTask.status === 'QUEUED'" class="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div class="h-full rounded-full bg-primary-500 transition-all duration-500" :style="{ width: asyncTask.progress || '10%' }" />
</div>
<div v-if="asyncTask.status === 'FAILURE'" class="rounded-xl border border-red-200 bg-red-50 p-4">
<div class="flex items-center gap-2 text-red-700">
<Icon name="xCircle" size="sm" />
<span class="text-sm font-medium">生成失败</span>
</div>
<p v-if="asyncTask.error" class="mt-2 text-sm text-red-600">{{ asyncTask.error }}</p>
<!-- 异步任务进行中:卡片式进度占位(成功由下方图片区接管) -->
<GenerationProgressCard
v-else-if="asyncTask && asyncTask.status !== 'SUCCESS' && asyncTask.status !== 'FAILURE'"
kind="image"
:progress="asyncTask.progress"
:label="statusLabel[asyncTask.status] || asyncTask.status"
:sublabel="asyncTask.id ? 'task_id: ' + asyncTask.id : ''"
/>
<!-- 任务失败 -->
<div v-if="asyncTask && asyncTask.status === 'FAILURE'" class="rounded-xl border border-red-200 bg-red-50 p-4">
<div class="flex items-center gap-2 text-red-700">
<Icon name="xCircle" size="sm" />
<span class="text-sm font-medium">生成失败</span>
</div>
<p v-if="asyncTask.error" class="mt-2 text-sm text-red-600">{{ asyncTask.error }}</p>
</div>
<div v-if="images.length > 0" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -4,6 +4,7 @@ import { NInput, NInputNumber } from 'naive-ui'
import { createPlaygroundApi } from '@/utils/playgroundApi'
import { createPoller } from '@/composables/usePolling'
import Icon from '@/components/common/Icon.vue'
import GenerationProgressCard from './GenerationProgressCard.vue'
import BaseSelect from '../../../components/common/BaseSelect.vue'
interface ModelItem {
@@ -89,13 +90,6 @@ const statusLabel: Record<string, string> = {
failed: '失败',
}
const statusColor: Record<string, string> = {
queued: 'badge-gray',
in_progress: 'badge-warning',
completed: 'badge-success',
failed: 'badge-danger',
}
function applyResolutionPreset(val: string) {
resolution.value = val
}
@@ -305,33 +299,19 @@ function downloadVideo() {
<p class="empty-state-description">输入提示词并点击生成</p>
</div>
<!-- 提交中 -->
<div v-if="submitting" class="flex items-center justify-center py-12">
<div class="spinner h-8 w-8 text-primary-600"></div>
<span class="ml-3 text-sm text-gray-500">提交视频生成任务中...</span>
</div>
<!-- 提交中:卡片式占位 -->
<GenerationProgressCard v-if="submitting" kind="video" label="正在提交生成任务..." />
<!-- 任务状态 -->
<div v-if="currentTask" class="space-y-4">
<!-- 状态信息 -->
<div class="flex items-center gap-3">
<span class="badge" :class="statusColor[currentTask.status] || 'badge-gray'">
{{ statusLabel[currentTask.status] || currentTask.status }}
</span>
<span v-if="currentTask.progress" class="text-xs text-gray-500">进度: {{ currentTask.progress }}</span>
<span v-if="polling" class="text-xs text-primary-600 flex items-center gap-1">
<div class="spinner h-3 w-3"></div>
轮询中
</span>
</div>
<!-- 进度条 -->
<div v-if="currentTask.status === 'in_progress' || currentTask.status === 'queued'" class="w-full bg-gray-100 rounded-full h-2 overflow-hidden">
<div
class="h-full rounded-full bg-primary-500 transition-all duration-500"
:style="{ width: currentTask.progress || '10%' }"
/>
</div>
<!-- 任务进行中:按成品 16:9 形状的卡片式进度占位 -->
<GenerationProgressCard
v-if="currentTask.status === 'queued' || currentTask.status === 'in_progress'"
kind="video"
:progress="currentTask.progress"
:label="statusLabel[currentTask.status] || currentTask.status"
:sublabel="currentTask.id ? 'task_id: ' + currentTask.id : ''"
/>
<!-- 视频结果 -->
<div v-if="currentTask.status === 'completed' && videoUrl" class="space-y-3">
@@ -342,10 +322,11 @@ function downloadVideo() {
</button>
</div>
<!-- 成品加载中(content 端点拉流转 blob) -->
<div v-else-if="currentTask.status === 'completed'" class="flex items-center justify-center py-10">
<div class="spinner h-6 w-6 text-primary-600"></div>
<span class="ml-3 text-sm text-gray-500">视频加载中...</span>
</div>
<GenerationProgressCard
v-else-if="currentTask.status === 'completed'"
kind="video"
label="视频加载中..."
/>
<!-- 错误信息 -->
<div v-if="currentTask.status === 'failed'" class="rounded-xl border border-red-200 bg-red-50 p-4">