API统计页面

This commit is contained in:
BBIT-Kai
2026-07-27 10:16:24 +08:00
parent 89f6944e65
commit 38efd8ef1a
21 changed files with 3554 additions and 123 deletions
+16 -4
View File
@@ -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,
});
}
+5 -1
View File
@@ -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