API统计页面
This commit is contained in:
@@ -108,6 +108,10 @@ export interface CubeReportExportResult {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface CubeReportExportRequest {
|
||||
columnKeys: string[];
|
||||
}
|
||||
|
||||
export type CubeReportStreamEvent =
|
||||
| { content: string; type: 'message_delta' | 'message_replace' }
|
||||
| {
|
||||
@@ -233,7 +237,7 @@ export async function getCubeReportSession(id: string) {
|
||||
export async function getCubeReportData(id: string, params: { page: number }) {
|
||||
return pyRequestClient.get<CubeReportPage>(
|
||||
`/llm/cube-report/sessions/${id}/data`,
|
||||
{ params },
|
||||
{ params, showErrorMessage: false },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -243,19 +247,27 @@ export async function getCubeSavedReportData(
|
||||
) {
|
||||
return pyRequestClient.get<CubeReportPage>(
|
||||
`/llm/cube-report/reports/${id}/data`,
|
||||
{ params },
|
||||
{ params, showErrorMessage: false },
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportCubeReportSession(id: string) {
|
||||
export async function exportCubeReportSession(
|
||||
id: string,
|
||||
data: CubeReportExportRequest,
|
||||
) {
|
||||
return pyRequestClient.post<CubeReportExportResult>(
|
||||
`/llm/cube-report/sessions/${id}/export`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export async function exportCubeSavedReport(id: string) {
|
||||
export async function exportCubeSavedReport(
|
||||
id: string,
|
||||
data: CubeReportExportRequest,
|
||||
) {
|
||||
return pyRequestClient.post<CubeReportExportResult>(
|
||||
`/llm/cube-report/reports/${id}/export`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
export namespace OpenApiCenterApi {
|
||||
export interface Endpoint {
|
||||
id: string;
|
||||
name: string;
|
||||
serviceCode: string;
|
||||
category: string;
|
||||
httpMethod: string;
|
||||
pathTemplate: string;
|
||||
kongServiceId: string;
|
||||
kongRouteId: string;
|
||||
description: string;
|
||||
owner: string;
|
||||
monitorEnabled: boolean;
|
||||
status: string;
|
||||
recentCalls: number;
|
||||
recentConsumers: number;
|
||||
lastCalledAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TrendPoint {
|
||||
bucket: string;
|
||||
calls: number;
|
||||
authenticatedCalls: number;
|
||||
anonymousCalls: number;
|
||||
failures: number;
|
||||
}
|
||||
|
||||
export interface RankItem {
|
||||
key: string;
|
||||
name: string;
|
||||
value: number;
|
||||
secondary: string;
|
||||
}
|
||||
|
||||
export interface Overview {
|
||||
totalCalls: number;
|
||||
authenticatedCalls: number;
|
||||
anonymousCalls: number;
|
||||
unauthorizedCalls: number;
|
||||
activeConsumers: number;
|
||||
activeIps: number;
|
||||
successRate: number;
|
||||
trend: TrendPoint[];
|
||||
topEndpoints: RankItem[];
|
||||
topConsumers: RankItem[];
|
||||
categoryDistribution: RankItem[];
|
||||
}
|
||||
|
||||
export interface Consumer {
|
||||
id: string;
|
||||
kongConsumerId: string;
|
||||
username: string;
|
||||
customId: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
status: string;
|
||||
callCount: number;
|
||||
endpointCount: number;
|
||||
recentIp: string;
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
bucket: string;
|
||||
endpointId: string;
|
||||
endpointName: string;
|
||||
serviceCode: string;
|
||||
category: string;
|
||||
callerType: string;
|
||||
callerName: string;
|
||||
consumerId: string;
|
||||
clientIp: string;
|
||||
callCount: number;
|
||||
successCount: number;
|
||||
failureCount: number;
|
||||
unauthorizedCount: number;
|
||||
lastCalledAt: string;
|
||||
}
|
||||
|
||||
export interface Query {
|
||||
from?: string;
|
||||
to?: string;
|
||||
serviceCode?: string;
|
||||
category?: string;
|
||||
endpointId?: string;
|
||||
callerType?: string;
|
||||
}
|
||||
|
||||
export type SaveEndpoint = Pick<
|
||||
Endpoint,
|
||||
| 'category'
|
||||
| 'description'
|
||||
| 'httpMethod'
|
||||
| 'kongRouteId'
|
||||
| 'kongServiceId'
|
||||
| 'monitorEnabled'
|
||||
| 'name'
|
||||
| 'owner'
|
||||
| 'pathTemplate'
|
||||
| 'serviceCode'
|
||||
| 'status'
|
||||
>;
|
||||
}
|
||||
|
||||
export function getOpenApiOverview(params: OpenApiCenterApi.Query) {
|
||||
return requestClient.get<OpenApiCenterApi.Overview>('/open-api/overview', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
export function getOpenApiEndpoints() {
|
||||
return requestClient.get<OpenApiCenterApi.Endpoint[]>('/open-api/endpoints');
|
||||
}
|
||||
|
||||
export function createOpenApiEndpoint(data: OpenApiCenterApi.SaveEndpoint) {
|
||||
return requestClient.post<OpenApiCenterApi.Endpoint>(
|
||||
'/open-api/endpoints',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function updateOpenApiEndpoint(
|
||||
id: string,
|
||||
data: OpenApiCenterApi.SaveEndpoint,
|
||||
) {
|
||||
return requestClient.put<OpenApiCenterApi.Endpoint>(
|
||||
`/open-api/endpoints/${id}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function getOpenApiConsumers() {
|
||||
return requestClient.get<OpenApiCenterApi.Consumer[]>('/open-api/consumers');
|
||||
}
|
||||
|
||||
export function updateOpenApiConsumer(
|
||||
id: string,
|
||||
data: Pick<OpenApiCenterApi.Consumer, 'description' | 'displayName' | 'status'>,
|
||||
) {
|
||||
return requestClient.put<OpenApiCenterApi.Consumer>(
|
||||
`/open-api/consumers/${id}`,
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
export function getOpenApiUsage(params: OpenApiCenterApi.Query) {
|
||||
return requestClient.get<OpenApiCenterApi.Usage[]>('/open-api/usage', {
|
||||
params,
|
||||
});
|
||||
}
|
||||
@@ -101,10 +101,14 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
|
||||
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
|
||||
client.addResponseInterceptor(
|
||||
errorMessageResponseInterceptor((msg: string, error) => {
|
||||
if (error?.config?.showErrorMessage === false) return;
|
||||
// 这里可以根据业务进行定制,你可以拿到 error 内的信息进行定制化处理,根据不同的 code 做不同的提示,而不是直接使用 message.error 提示 msg
|
||||
// 当前mock接口返回的错误字段是 error 或者 message
|
||||
const responseData = error?.response?.data ?? {};
|
||||
const errorMessage = responseData?.error ?? responseData?.message ?? '';
|
||||
const detailMessage =
|
||||
typeof responseData?.detail === 'string' ? responseData.detail : '';
|
||||
const errorMessage =
|
||||
detailMessage || responseData?.error || responseData?.message || '';
|
||||
// 如果没有错误信息,则会根据状态码进行提示
|
||||
message.error(errorMessage || msg);
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { RouteRecordRaw } from 'vue-router';
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
component: () => import('#/views/open-api/index.vue'),
|
||||
meta: {
|
||||
authority: ['manager'],
|
||||
icon: 'lucide:waypoints',
|
||||
keepAlive: true,
|
||||
order: 3,
|
||||
title: '开放接口中心',
|
||||
},
|
||||
name: 'OpenApiCenter',
|
||||
path: '/open-api/center',
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
@@ -0,0 +1,523 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { CubeReportColumn } from '#/api';
|
||||
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
} from 'vue';
|
||||
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Input, message, Segmented, Tooltip } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
|
||||
type CubeRow = Record<string, unknown>;
|
||||
type GridDensity = 'comfortable' | 'compact' | 'standard';
|
||||
interface GridColumnInfo {
|
||||
field: string;
|
||||
title?: string;
|
||||
visible?: boolean;
|
||||
}
|
||||
interface GridCellEvent {
|
||||
column: GridColumnInfo;
|
||||
row: CubeRow;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
columns: CubeReportColumn[];
|
||||
loading?: boolean;
|
||||
rows: CubeRow[];
|
||||
storageKey: string;
|
||||
}>();
|
||||
|
||||
const keyword = ref('');
|
||||
const density = ref<GridDensity>('standard');
|
||||
const selectedCell = ref<null | {
|
||||
field: string;
|
||||
title: string;
|
||||
value: unknown;
|
||||
}>(null);
|
||||
const gridHost = ref<HTMLElement | null>(null);
|
||||
let resizeObserver: null | ResizeObserver = null;
|
||||
let resizeFrame: number | undefined;
|
||||
|
||||
const densityOptions = [
|
||||
{ label: '紧凑', value: 'compact' },
|
||||
{ label: '标准', value: 'standard' },
|
||||
{ label: '宽松', value: 'comfortable' },
|
||||
];
|
||||
|
||||
const densityStorageKey = computed(
|
||||
() => `cube-report-grid-density:${props.storageKey}`,
|
||||
);
|
||||
const gridId = computed(
|
||||
() =>
|
||||
`cube-report-result-${props.storageKey.replaceAll(/[^\w-]/g, '-').slice(0, 100)}`,
|
||||
);
|
||||
const gridSize = computed(() => {
|
||||
if (density.value === 'compact') return 'mini' as const;
|
||||
if (density.value === 'comfortable') return 'medium' as const;
|
||||
return 'small' as const;
|
||||
});
|
||||
const rowHeight = computed(() => {
|
||||
if (density.value === 'compact') return 30;
|
||||
if (density.value === 'comfortable') return 44;
|
||||
return 36;
|
||||
});
|
||||
const normalizedKeyword = computed(() => keyword.value.trim().toLowerCase());
|
||||
const visibleRows = computed(() => {
|
||||
if (!normalizedKeyword.value) return props.rows;
|
||||
return props.rows.filter((row) =>
|
||||
props.columns.some((column) =>
|
||||
normalizeSearchValue(row[column.key]).includes(normalizedKeyword.value),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
function normalizeSearchValue(value: unknown) {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value).toLowerCase();
|
||||
} catch {
|
||||
return String(value).toLowerCase();
|
||||
}
|
||||
}
|
||||
return String(value).toLowerCase();
|
||||
}
|
||||
|
||||
function displayValue(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isNumericColumn(column: CubeReportColumn) {
|
||||
const sample = props.rows.find((row) => {
|
||||
const value = row[column.key];
|
||||
return value !== null && value !== undefined && value !== '';
|
||||
})?.[column.key];
|
||||
return typeof sample === 'number';
|
||||
}
|
||||
|
||||
function initialColumnWidth(column: CubeReportColumn) {
|
||||
const titleWidth = [...column.title].reduce(
|
||||
(width, character) =>
|
||||
width + ((character.codePointAt(0) ?? 0) > 255 ? 14 : 8),
|
||||
36,
|
||||
);
|
||||
return Math.min(280, Math.max(130, titleWidth));
|
||||
}
|
||||
|
||||
const vxeColumns = computed<VxeTableGridOptions<CubeRow>['columns']>(() => [
|
||||
{
|
||||
align: 'center',
|
||||
fixed: 'left',
|
||||
title: '#',
|
||||
type: 'seq',
|
||||
width: 54,
|
||||
},
|
||||
...props.columns.map((column) => ({
|
||||
align: isNumericColumn(column) ? ('right' as const) : ('left' as const),
|
||||
field: column.key,
|
||||
formatter: ({ cellValue }: { cellValue: unknown }) =>
|
||||
displayValue(cellValue),
|
||||
minWidth: initialColumnWidth(column),
|
||||
sortable: true,
|
||||
title: column.title,
|
||||
})),
|
||||
]);
|
||||
|
||||
const gridOptions = computed<VxeTableGridOptions<CubeRow>>(() => ({
|
||||
id: gridId.value,
|
||||
align: 'left',
|
||||
border: 'inner',
|
||||
columnConfig: {
|
||||
resizable: true,
|
||||
useKey: true,
|
||||
},
|
||||
columns: vxeColumns.value,
|
||||
customConfig: {
|
||||
allowFixed: true,
|
||||
allowResizable: true,
|
||||
allowSort: true,
|
||||
allowVisible: true,
|
||||
immediate: true,
|
||||
mode: 'simple',
|
||||
storage: true,
|
||||
},
|
||||
data: visibleRows.value,
|
||||
height: '100%',
|
||||
highlightCurrentColumn: true,
|
||||
highlightHoverRow: true,
|
||||
keyboardConfig: {
|
||||
isArrow: true,
|
||||
isEnter: true,
|
||||
isTab: true,
|
||||
},
|
||||
loading: props.loading,
|
||||
mouseConfig: {
|
||||
selected: true,
|
||||
},
|
||||
pagerConfig: {
|
||||
enabled: false,
|
||||
},
|
||||
rowConfig: {
|
||||
height: rowHeight.value,
|
||||
keyField: '__rowKey',
|
||||
useKey: true,
|
||||
},
|
||||
scrollX: {
|
||||
enabled: props.columns.length > 12,
|
||||
gt: 12,
|
||||
},
|
||||
scrollY: {
|
||||
enabled: visibleRows.value.length > 40,
|
||||
gt: 40,
|
||||
},
|
||||
showHeaderOverflow: 'tooltip',
|
||||
showOverflow: 'tooltip',
|
||||
size: gridSize.value,
|
||||
sortConfig: {
|
||||
multiple: true,
|
||||
remote: false,
|
||||
trigger: 'cell',
|
||||
},
|
||||
stripe: true,
|
||||
toolbarConfig: {
|
||||
custom: true,
|
||||
zoom: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid<CubeRow>({
|
||||
gridEvents: {
|
||||
cellClick: handleCellClick,
|
||||
cellDblclick: handleCellDoubleClick,
|
||||
},
|
||||
gridOptions: gridOptions.value,
|
||||
});
|
||||
|
||||
function handleCellClick(event: GridCellEvent) {
|
||||
const field = String(event.column.field || '');
|
||||
if (!field) {
|
||||
selectedCell.value = null;
|
||||
return;
|
||||
}
|
||||
selectedCell.value = {
|
||||
field,
|
||||
title: String(event.column.title || field),
|
||||
value: event.row[field],
|
||||
};
|
||||
}
|
||||
|
||||
function handleCellDoubleClick(event: GridCellEvent) {
|
||||
handleCellClick(event);
|
||||
void copySelectedCell();
|
||||
}
|
||||
|
||||
function escapeTabularValue(value: unknown) {
|
||||
return displayValue(value).replaceAll('\t', ' ').replaceAll(/\r?\n/g, ' ');
|
||||
}
|
||||
|
||||
async function writeClipboard(text: string, successText: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
message.success(successText);
|
||||
} catch {
|
||||
message.error('复制失败,请检查浏览器剪贴板权限');
|
||||
}
|
||||
}
|
||||
|
||||
async function copySelectedCell() {
|
||||
if (!selectedCell.value) return;
|
||||
await writeClipboard(
|
||||
displayValue(selectedCell.value.value),
|
||||
`已复制“${selectedCell.value.title}”单元格`,
|
||||
);
|
||||
}
|
||||
|
||||
function getVisibleDataColumns() {
|
||||
const renderedColumns = gridApi.grid?.getColumns?.() ?? [];
|
||||
return (renderedColumns as GridColumnInfo[]).filter(
|
||||
(column) => column.visible !== false && Boolean(column.field),
|
||||
);
|
||||
}
|
||||
|
||||
function getExportColumns() {
|
||||
return getVisibleDataColumns().map((column) => ({
|
||||
key: column.field,
|
||||
title: String(column.title || column.field),
|
||||
}));
|
||||
}
|
||||
|
||||
defineExpose({ getExportColumns });
|
||||
|
||||
async function copyCurrentPage() {
|
||||
const columns = getVisibleDataColumns();
|
||||
if (columns.length === 0 || visibleRows.value.length === 0) return;
|
||||
const header = columns.map((column) => escapeTabularValue(column.title));
|
||||
const body = visibleRows.value.map((row) =>
|
||||
columns.map((column) => escapeTabularValue(row[column.field])).join('\t'),
|
||||
);
|
||||
await writeClipboard(
|
||||
[header.join('\t'), ...body].join('\n'),
|
||||
`已复制当前页 ${visibleRows.value.length} 行`,
|
||||
);
|
||||
}
|
||||
|
||||
function restoreDensity() {
|
||||
const value = localStorage.getItem(densityStorageKey.value);
|
||||
if (['comfortable', 'compact', 'standard'].includes(String(value))) {
|
||||
density.value = value as GridDensity;
|
||||
}
|
||||
}
|
||||
|
||||
function observeGridHost() {
|
||||
resizeObserver?.disconnect();
|
||||
if (!gridHost.value) return;
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeFrame) cancelAnimationFrame(resizeFrame);
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
void gridApi.grid?.recalculate?.();
|
||||
});
|
||||
});
|
||||
resizeObserver.observe(gridHost.value);
|
||||
}
|
||||
|
||||
watch(gridOptions, (options) => gridApi.setGridOptions(options), {
|
||||
deep: true,
|
||||
});
|
||||
watch(density, (value) => {
|
||||
localStorage.setItem(densityStorageKey.value, value);
|
||||
void nextTick(() => gridApi.grid?.recalculate?.());
|
||||
});
|
||||
watch(
|
||||
() => props.storageKey,
|
||||
() => {
|
||||
keyword.value = '';
|
||||
selectedCell.value = null;
|
||||
restoreDensity();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
restoreDensity();
|
||||
observeGridHost();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect();
|
||||
if (resizeFrame) cancelAnimationFrame(resizeFrame);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="gridHost" class="cube-data-grid">
|
||||
<Grid :grid-options="gridOptions">
|
||||
<template #toolbar-actions>
|
||||
<div class="grid-toolbar-left">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
class="page-search"
|
||||
placeholder="搜索当前页全部列"
|
||||
>
|
||||
<template #prefix>
|
||||
<IconifyIcon icon="lucide:search" />
|
||||
</template>
|
||||
</Input>
|
||||
<Tooltip title="搜索与表头排序只作用于当前已加载页面">
|
||||
<span class="page-scope-badge">
|
||||
<IconifyIcon icon="lucide:info" />
|
||||
本页 {{ visibleRows.length }}/{{ rows.length }} 行
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #toolbar-tools>
|
||||
<div class="grid-toolbar-tools">
|
||||
<Tooltip title="双击单元格也可以复制">
|
||||
<Button
|
||||
class="grid-tool-button"
|
||||
size="small"
|
||||
:disabled="!selectedCell"
|
||||
@click="copySelectedCell"
|
||||
>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:copy" />
|
||||
</template>
|
||||
复制单元格
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title="复制当前页可见列,可直接粘贴到 Excel">
|
||||
<Button
|
||||
class="grid-tool-button"
|
||||
size="small"
|
||||
:disabled="visibleRows.length === 0"
|
||||
@click="copyCurrentPage"
|
||||
>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:clipboard-copy" />
|
||||
</template>
|
||||
复制本页
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Segmented
|
||||
v-model:value="density"
|
||||
class="density-switch"
|
||||
:options="densityOptions"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #empty>
|
||||
<div class="grid-empty">
|
||||
<IconifyIcon icon="lucide:search-x" />
|
||||
<span>{{ keyword ? '当前页没有匹配数据' : '暂无数据' }}</span>
|
||||
<Button v-if="keyword" size="small" type="link" @click="keyword = ''">
|
||||
清除搜索
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cube-data-grid {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.cube-data-grid :deep(.bg-card) {
|
||||
border-radius: 0;
|
||||
}
|
||||
.cube-data-grid :deep(.vxe-grid) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
.cube-data-grid :deep(.vxe-toolbar) {
|
||||
min-height: 44px;
|
||||
padding: 6px 10px;
|
||||
background: linear-gradient(180deg, #fff, #fbfdff);
|
||||
border-bottom: 1px solid #e8edf4;
|
||||
}
|
||||
.cube-data-grid :deep(.vxe-table--header-wrapper) {
|
||||
color: #334155;
|
||||
font-weight: 650;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
.cube-data-grid :deep(.vxe-header--column) {
|
||||
background: #f7f9fc;
|
||||
border-color: #e5eaf1;
|
||||
}
|
||||
.cube-data-grid :deep(.vxe-body--column) {
|
||||
color: #334155;
|
||||
border-color: #edf1f5;
|
||||
}
|
||||
.cube-data-grid :deep(.row--stripe .vxe-body--column) {
|
||||
background: #fafcff;
|
||||
}
|
||||
.cube-data-grid :deep(.row--hover .vxe-body--column) {
|
||||
background: #eff6ff !important;
|
||||
}
|
||||
.cube-data-grid :deep(.col--current) {
|
||||
background: #eef6ff !important;
|
||||
}
|
||||
.cube-data-grid :deep(.col--selected) {
|
||||
box-shadow: inset 0 0 0 1px #60a5fa;
|
||||
}
|
||||
.cube-data-grid :deep(.vxe-cell--sort) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.cube-data-grid :deep(.is--active .vxe-sort--asc-btn),
|
||||
.cube-data-grid :deep(.is--active .vxe-sort--desc-btn) {
|
||||
color: #2563eb;
|
||||
}
|
||||
.grid-toolbar-left,
|
||||
.grid-toolbar-tools {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.page-search {
|
||||
width: min(260px, 24vw);
|
||||
border-color: #dbe3ee;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.page-search :deep(.ant-input-prefix) {
|
||||
color: #94a3b8;
|
||||
}
|
||||
.page-scope-badge {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 3px 8px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 99px;
|
||||
}
|
||||
.grid-tool-button {
|
||||
color: #475569;
|
||||
border-color: #dbe3ee;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.grid-tool-button:hover:not(:disabled) {
|
||||
color: #1d4ed8;
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
.density-switch {
|
||||
flex-shrink: 0;
|
||||
padding: 2px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
.density-switch :deep(.ant-segmented-item-label) {
|
||||
min-height: 24px;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
line-height: 24px;
|
||||
}
|
||||
.grid-empty {
|
||||
display: flex;
|
||||
min-height: 160px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.grid-empty > svg {
|
||||
font-size: 30px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.page-scope-badge,
|
||||
.grid-tool-button span:not(.ant-btn-icon) {
|
||||
display: none;
|
||||
}
|
||||
.page-search {
|
||||
width: 190px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,6 @@
|
||||
import type { DefaultOptionType } from 'ant-design-vue/es/select';
|
||||
|
||||
import type {
|
||||
CubeReportColumn,
|
||||
CubeReportFile,
|
||||
CubeReportMessage,
|
||||
CubeReportMetaStatus,
|
||||
@@ -32,7 +31,6 @@ import {
|
||||
Segmented,
|
||||
Select,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
@@ -41,7 +39,15 @@ import dayjs from 'dayjs';
|
||||
|
||||
import * as api from '#/api';
|
||||
|
||||
import CubeDataGrid from './cube-data-grid.vue';
|
||||
|
||||
type WorkspaceView = 'chat' | 'data' | 'split';
|
||||
interface DataLoadError {
|
||||
detail: string;
|
||||
hint?: string;
|
||||
message: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const reports = ref<CubeSavedReport[]>([]);
|
||||
const reportPage = ref(1);
|
||||
@@ -78,6 +84,7 @@ const loadingSessions = ref(false);
|
||||
const loadingMoreSessions = ref(false);
|
||||
const loadingConversation = ref(false);
|
||||
const loadingData = ref(false);
|
||||
const dataLoadError = ref<DataLoadError | null>(null);
|
||||
const exporting = ref(false);
|
||||
const favoriting = ref(false);
|
||||
const refreshingCubeMetadata = ref(false);
|
||||
@@ -92,11 +99,9 @@ const renameValue = ref('');
|
||||
const renaming = ref(false);
|
||||
const chatContainer = ref<HTMLElement | null>(null);
|
||||
const panelContainer = ref<HTMLElement | null>(null);
|
||||
const tableRegion = ref<HTMLElement | null>(null);
|
||||
const tableScrollY = ref(240);
|
||||
const dataGridRef = ref<InstanceType<typeof CubeDataGrid> | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const cubeMetadataStatus = ref<CubeReportMetaStatus | null>(null);
|
||||
let tableResizeObserver: null | ResizeObserver = null;
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const selectedCompany = computed(() =>
|
||||
@@ -117,14 +122,8 @@ const hasData = computed(
|
||||
Boolean(currentSession.value?.hasData) ||
|
||||
dataPage.value.columns.length > 0,
|
||||
);
|
||||
const tableColumns = computed(() =>
|
||||
dataPage.value.columns.map((column: CubeReportColumn) => ({
|
||||
key: column.key,
|
||||
dataIndex: column.key,
|
||||
title: column.title,
|
||||
ellipsis: true,
|
||||
minWidth: 140,
|
||||
})),
|
||||
const dataGridStorageKey = computed(
|
||||
() => currentReportId.value || currentSessionId.value || 'draft',
|
||||
);
|
||||
const dataPanelStyle = computed(() =>
|
||||
activeView.value === 'split'
|
||||
@@ -169,6 +168,7 @@ async function scrollToBottom() {
|
||||
}
|
||||
|
||||
function resetData() {
|
||||
dataLoadError.value = null;
|
||||
dataPage.value = {
|
||||
columns: [],
|
||||
page: 1,
|
||||
@@ -178,6 +178,59 @@ function resetData() {
|
||||
};
|
||||
}
|
||||
|
||||
function getErrorDetail(error: unknown) {
|
||||
if (typeof error === 'string') return error;
|
||||
if (error && typeof error === 'object') {
|
||||
const payload = error as Record<string, unknown>;
|
||||
if (typeof payload.detail === 'string') return payload.detail;
|
||||
if (typeof payload.message === 'string') return payload.message;
|
||||
}
|
||||
return '数据查询失败,请稍后重试';
|
||||
}
|
||||
|
||||
function extractCubeError(detail: string) {
|
||||
const prefix = 'Cube SQL 生成失败:';
|
||||
if (!detail.startsWith(prefix)) return detail;
|
||||
const rawPayload = detail.slice(prefix.length).trim();
|
||||
try {
|
||||
const payload = JSON.parse(rawPayload) as Record<string, unknown>;
|
||||
if (typeof payload.error === 'string') return payload.error;
|
||||
} catch {
|
||||
const match = rawPayload.match(/"error"\s*:\s*"((?:\\.|[^"\\])*)"/);
|
||||
if (match?.[1]) {
|
||||
try {
|
||||
return JSON.parse(`"${match[1]}"`) as string;
|
||||
} catch {
|
||||
return match[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
function createDataLoadError(error: unknown): DataLoadError {
|
||||
const detail = getErrorDetail(error);
|
||||
const cubeError = extractCubeError(detail);
|
||||
const missingField = cubeError.match(
|
||||
/'([^']+)' not found for path '([^']+)'/,
|
||||
);
|
||||
if (detail.startsWith('Cube SQL 生成失败:')) {
|
||||
return {
|
||||
detail,
|
||||
hint: missingField
|
||||
? `Cube 模型中找不到字段 ${missingField[2]}(成员 ${missingField[1]})。字段可能已被删除、重命名或元数据尚未同步。`
|
||||
: 'Cube 无法根据当前报表配置生成 SQL,请检查报表字段或刷新 Cube 元数据后重试。',
|
||||
message: cubeError,
|
||||
title: missingField ? 'Cube 字段不可用' : 'Cube SQL 生成失败',
|
||||
};
|
||||
}
|
||||
return {
|
||||
detail,
|
||||
message: detail,
|
||||
title: '数据加载失败',
|
||||
};
|
||||
}
|
||||
|
||||
async function loadScope() {
|
||||
const scope = await api.getCubeReportScope();
|
||||
companies.value = scope.companies;
|
||||
@@ -309,23 +362,44 @@ async function loadReport(id: string) {
|
||||
const report = await api.getCubeReport(id);
|
||||
currentReport.value = report;
|
||||
currentSessionId.value = report.conversationId;
|
||||
try {
|
||||
const detail = await api.getCubeReportSession(report.conversationId);
|
||||
currentSession.value = detail.session;
|
||||
messages.value = detail.messages;
|
||||
} catch {
|
||||
currentSession.value = {
|
||||
createdAt: report.createdAt,
|
||||
hasData: true,
|
||||
id: report.conversationId,
|
||||
reportId: report.id,
|
||||
tenantId: report.tenantId,
|
||||
tenantName: report.tenantName,
|
||||
title: report.title,
|
||||
updatedAt: report.updatedAt,
|
||||
};
|
||||
messages.value = [];
|
||||
}
|
||||
currentSession.value = {
|
||||
createdAt: report.createdAt,
|
||||
hasData: true,
|
||||
id: report.conversationId,
|
||||
reportId: report.id,
|
||||
tenantId: report.tenantId,
|
||||
tenantName: report.tenantName,
|
||||
title: report.title,
|
||||
updatedAt: report.updatedAt,
|
||||
};
|
||||
messages.value = [
|
||||
...(report.requirement
|
||||
? [
|
||||
{
|
||||
content: report.requirement,
|
||||
createdAt: report.createdAt,
|
||||
difyMessageId: null,
|
||||
files: [],
|
||||
id: `saved-report-${report.id}-user`,
|
||||
role: 'user' as const,
|
||||
status: 'completed' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(report.message
|
||||
? [
|
||||
{
|
||||
content: report.message,
|
||||
createdAt: report.updatedAt,
|
||||
difyMessageId: null,
|
||||
files: [],
|
||||
id: `saved-report-${report.id}-assistant`,
|
||||
role: 'assistant' as const,
|
||||
status: 'completed' as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
pendingFiles.value = [];
|
||||
resetData();
|
||||
activeView.value = 'split';
|
||||
@@ -496,11 +570,14 @@ async function stopResponse() {
|
||||
|
||||
async function loadData(page = dataPage.value.page) {
|
||||
if (!currentSessionId.value) return;
|
||||
dataLoadError.value = null;
|
||||
loadingData.value = true;
|
||||
try {
|
||||
dataPage.value = currentReportId.value
|
||||
? await api.getCubeSavedReportData(currentReportId.value, { page })
|
||||
: await api.getCubeReportData(currentSessionId.value, { page });
|
||||
} catch (error) {
|
||||
dataLoadError.value = createDataLoadError(error);
|
||||
} finally {
|
||||
loadingData.value = false;
|
||||
}
|
||||
@@ -520,13 +597,32 @@ async function copyCurrentSql() {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDataError() {
|
||||
if (!dataLoadError.value) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(dataLoadError.value.detail);
|
||||
message.success('错误详情已复制');
|
||||
} catch {
|
||||
message.error('复制失败,请手动选择错误详情');
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCurrentData() {
|
||||
if (!currentSessionId.value || exporting.value) return;
|
||||
const exportColumns =
|
||||
dataGridRef.value?.getExportColumns() ?? dataPage.value.columns;
|
||||
const columnKeys = exportColumns.map((column) => column.key);
|
||||
if (columnKeys.length === 0) {
|
||||
message.warning('请至少保留一列后再导出');
|
||||
return;
|
||||
}
|
||||
exporting.value = true;
|
||||
try {
|
||||
const result = currentReportId.value
|
||||
? await api.exportCubeSavedReport(currentReportId.value)
|
||||
: await api.exportCubeReportSession(currentSessionId.value);
|
||||
? await api.exportCubeSavedReport(currentReportId.value, { columnKeys })
|
||||
: await api.exportCubeReportSession(currentSessionId.value, {
|
||||
columnKeys,
|
||||
});
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = result.url;
|
||||
anchor.download = result.filename;
|
||||
@@ -534,7 +630,9 @@ async function exportCurrentData() {
|
||||
document.body.append(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
message.success(`已生成 ${result.rowCount} 条数据`);
|
||||
message.success(
|
||||
`已生成 ${result.rowCount} 条数据,共 ${columnKeys.length} 列`,
|
||||
);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '数据导出失败');
|
||||
} finally {
|
||||
@@ -735,20 +833,6 @@ function stopResize() {
|
||||
resizing.value = false;
|
||||
}
|
||||
|
||||
function observeTableRegion() {
|
||||
tableResizeObserver?.disconnect();
|
||||
if (!tableRegion.value) return;
|
||||
tableResizeObserver = new ResizeObserver(([entry]) => {
|
||||
if (!entry) return;
|
||||
tableScrollY.value = Math.max(
|
||||
120,
|
||||
Math.floor(entry.contentRect.height - 48),
|
||||
);
|
||||
});
|
||||
tableResizeObserver.observe(tableRegion.value);
|
||||
}
|
||||
|
||||
watch(tableRegion, () => nextTick(observeTableRegion));
|
||||
watch(sessionKeyword, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => void reloadNavigation(), 320);
|
||||
@@ -763,13 +847,11 @@ onMounted(async () => {
|
||||
loadAppParameters(),
|
||||
loadCubeMetadataStatus(),
|
||||
]);
|
||||
await nextTick(observeTableRegion);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', stopResize);
|
||||
tableResizeObserver?.disconnect();
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
});
|
||||
</script>
|
||||
@@ -1021,18 +1103,20 @@ onBeforeUnmount(() => {
|
||||
刷新表头
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
class="toolbar-action"
|
||||
size="small"
|
||||
:loading="exporting"
|
||||
:disabled="loadingData"
|
||||
@click="exportCurrentData"
|
||||
>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:download" />
|
||||
</template>
|
||||
导出表格
|
||||
</Button>
|
||||
<Tooltip :title="`导出全部 ${dataPage.total} 条数据的当前可见列`">
|
||||
<Button
|
||||
class="toolbar-action"
|
||||
size="small"
|
||||
:loading="exporting"
|
||||
:disabled="loadingData"
|
||||
@click="exportCurrentData"
|
||||
>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:download" />
|
||||
</template>
|
||||
导出表格
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
class="toolbar-action"
|
||||
size="small"
|
||||
@@ -1049,56 +1133,84 @@ onBeforeUnmount(() => {
|
||||
|
||||
<Spin :spinning="loadingData" class="min-h-0 flex-1">
|
||||
<div v-if="hasData" class="data-table-card">
|
||||
<details v-if="dataPage.sql" class="sql-disclosure">
|
||||
<summary>
|
||||
<span class="sql-summary-title">
|
||||
<IconifyIcon icon="lucide:terminal-square" />
|
||||
执行 SQL
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
@click.stop.prevent="copyCurrentSql"
|
||||
>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:copy" />
|
||||
</template>
|
||||
复制
|
||||
</Button>
|
||||
</summary>
|
||||
<pre>{{ dataPage.sql }}</pre>
|
||||
</details>
|
||||
<div ref="tableRegion" class="data-table-region">
|
||||
<Table
|
||||
:columns="tableColumns"
|
||||
:data-source="dataPage.rows"
|
||||
:pagination="false"
|
||||
:row-key="
|
||||
(record: Record<string, unknown>) => String(record.__rowKey)
|
||||
"
|
||||
:scroll="{ x: 'max-content', y: tableScrollY }"
|
||||
bordered
|
||||
size="middle"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<span class="cell-value">{{
|
||||
record[String(column.key ?? column.dataIndex ?? '')] ??
|
||||
'—'
|
||||
}}</span>
|
||||
</template>
|
||||
</Table>
|
||||
</div>
|
||||
<div class="pagination-row">
|
||||
<Pagination
|
||||
:current="dataPage.page"
|
||||
:page-size="dataPage.pageSize"
|
||||
:show-size-changer="false"
|
||||
:total="dataPage.total"
|
||||
show-less-items
|
||||
:show-total="(total: number) => `共 ${total} 条`"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
<div v-if="dataLoadError" class="data-error-state">
|
||||
<div class="data-error-icon">
|
||||
<IconifyIcon icon="lucide:database-zap" />
|
||||
</div>
|
||||
<div class="data-error-content">
|
||||
<span class="data-error-eyebrow">数据查询未完成</span>
|
||||
<h2>{{ dataLoadError.title }}</h2>
|
||||
<p class="data-error-message">{{ dataLoadError.message }}</p>
|
||||
<p v-if="dataLoadError.hint" class="data-error-hint">
|
||||
<IconifyIcon icon="lucide:lightbulb" />
|
||||
<span>{{ dataLoadError.hint }}</span>
|
||||
</p>
|
||||
<div class="data-error-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
:loading="loadingData"
|
||||
@click="loadData(dataPage.page)"
|
||||
>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:refresh-cw" />
|
||||
</template>
|
||||
重新加载
|
||||
</Button>
|
||||
<Button @click="copyDataError">
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:copy" />
|
||||
</template>
|
||||
复制错误详情
|
||||
</Button>
|
||||
</div>
|
||||
<details class="data-error-details">
|
||||
<summary>查看技术详情</summary>
|
||||
<pre>{{ dataLoadError.detail }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<details v-if="dataPage.sql" class="sql-disclosure">
|
||||
<summary>
|
||||
<span class="sql-summary-title">
|
||||
<IconifyIcon icon="lucide:terminal-square" />
|
||||
执行 SQL
|
||||
</span>
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
@click.stop.prevent="copyCurrentSql"
|
||||
>
|
||||
<template #icon>
|
||||
<IconifyIcon icon="lucide:copy" />
|
||||
</template>
|
||||
复制
|
||||
</Button>
|
||||
</summary>
|
||||
<pre>{{ dataPage.sql }}</pre>
|
||||
</details>
|
||||
<div class="data-table-region">
|
||||
<CubeDataGrid
|
||||
:key="dataGridStorageKey"
|
||||
ref="dataGridRef"
|
||||
:columns="dataPage.columns"
|
||||
:loading="loadingData"
|
||||
:rows="dataPage.rows"
|
||||
:storage-key="dataGridStorageKey"
|
||||
/>
|
||||
</div>
|
||||
<div class="pagination-row">
|
||||
<Pagination
|
||||
:current="dataPage.page"
|
||||
:page-size="dataPage.pageSize"
|
||||
:show-size-changer="false"
|
||||
:total="dataPage.total"
|
||||
show-less-items
|
||||
:show-total="(total: number) => `共 ${total} 条`"
|
||||
@change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div v-else class="empty-workspace">
|
||||
<div class="empty-illustration">
|
||||
@@ -1628,6 +1740,116 @@ onBeforeUnmount(() => {
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
.data-error-state {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
flex: 1;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 18px;
|
||||
padding: clamp(34px, 7vh, 72px) clamp(20px, 5vw, 64px);
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgb(254 242 242 / 72%), transparent 45%),
|
||||
linear-gradient(180deg, #fff, #fcfdff);
|
||||
}
|
||||
.data-error-icon {
|
||||
display: grid;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
color: #dc2626;
|
||||
font-size: 23px;
|
||||
background: #fff1f2;
|
||||
border: 1px solid #fecdd3;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 8px 22px rgb(190 18 60 / 9%);
|
||||
}
|
||||
.data-error-content {
|
||||
width: min(680px, 100%);
|
||||
min-width: 0;
|
||||
}
|
||||
.data-error-eyebrow {
|
||||
display: block;
|
||||
margin: 1px 0 5px;
|
||||
color: #e11d48;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.data-error-content h2 {
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
font-size: 20px;
|
||||
font-weight: 680;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.data-error-message {
|
||||
margin: 10px 0 0;
|
||||
color: #475569;
|
||||
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.75;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.data-error-hint {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 16px 0 0;
|
||||
padding: 11px 13px;
|
||||
color: #854d0e;
|
||||
font-size: 12px;
|
||||
line-height: 1.65;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.data-error-hint svg {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
color: #d97706;
|
||||
font-size: 15px;
|
||||
}
|
||||
.data-error-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 9px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.data-error-actions :deep(.ant-btn) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
.data-error-details {
|
||||
margin-top: 17px;
|
||||
overflow: hidden;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.data-error-details summary {
|
||||
padding: 9px 12px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.data-error-details pre {
|
||||
max-height: 220px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 12px;
|
||||
color: #cbd5e1;
|
||||
font-family: 'JetBrains Mono', 'Cascadia Code', Consolas, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.65;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
background: #0f172a;
|
||||
border-top: 1px solid #1e293b;
|
||||
}
|
||||
.sql-disclosure {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
@@ -1675,13 +1897,6 @@ onBeforeUnmount(() => {
|
||||
background: #0f172a;
|
||||
border-top: 1px solid #1e293b;
|
||||
}
|
||||
.cell-value {
|
||||
display: inline-block;
|
||||
max-width: 360px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pagination-row {
|
||||
display: flex;
|
||||
position: relative;
|
||||
@@ -2030,6 +2245,12 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.data-error-state {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
padding: 28px 20px;
|
||||
}
|
||||
.session-sidebar {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user