diff --git a/mateclaw-ui/src/components/dashboard/OperationalExport.vue b/mateclaw-ui/src/components/dashboard/OperationalExport.vue new file mode 100644 index 00000000..cca0c38b --- /dev/null +++ b/mateclaw-ui/src/components/dashboard/OperationalExport.vue @@ -0,0 +1,452 @@ + + + + + diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 9d824d6e..dac2005c 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -25,6 +25,10 @@ export default { copyFailed: 'Copy failed', confirm: 'Confirm', close: 'Close', + selectRange: 'Select date range', + startDate: 'Start date', + endDate: 'End date', + lastDays: 'Last {n} days', add: 'Add', search: 'Search', noResults: 'No matches', @@ -3963,6 +3967,8 @@ export default { exportInProgress: 'Export in progress...', expiredHint: 'Report is ready, please download soon', generatingProgress: 'Generating... ({step}/{total})', + reportReady: 'Report is ready', + exportIncludes: 'The report contains {count} data sheets', }, memory: { kicker: 'Memory', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 74c47a9b..41a297db 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -25,6 +25,10 @@ export default { copyFailed: '复制失败', confirm: '确认', close: '关闭', + selectRange: '选择时间范围', + startDate: '开始日期', + endDate: '结束日期', + lastDays: '近 {n} 天', add: '添加', search: '搜索', noResults: '没有匹配项', @@ -4055,6 +4059,8 @@ export default { exportInProgress: '后台生成中...', expiredHint: '数据已生成,请尽快下载', generatingProgress: '生成中... ({step}/{total})', + reportReady: '报告已就绪', + exportIncludes: '报告包含 {count} 张数据表', }, memory: { kicker: '记忆', diff --git a/mateclaw-ui/src/views/Dashboard.vue b/mateclaw-ui/src/views/Dashboard.vue index 0cb5c7f2..39a94a5e 100644 --- a/mateclaw-ui/src/views/Dashboard.vue +++ b/mateclaw-ui/src/views/Dashboard.vue @@ -7,10 +7,14 @@
{{ t('dashboard.kicker') }}

{{ t('dashboard.title') }}

{{ t('dashboard.desc') }}

- +
+
+ + {{ t('doctor.database') }} + {{ dbLabel }} +
+ +
@@ -189,70 +193,6 @@
- - -
-

{{ t('dashboard.exportDescription') }}

- - - -
-
- - - - - - -
- {{ exportStep }}/{{ exportTotal }} - {{ exportStepLabel }} -
-
-
- -

{{ t('dashboard.expiredHint') }}

-

生成失败,请重试

-
- -
@@ -261,9 +201,9 @@ import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import { ArrowRight, ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue' -import { dashboardApi, modelApi, operationalApi } from '@/api' +import { dashboardApi, modelApi, http } from '@/api' import { getProviderIcon, onProviderIconError } from '@/utils/providerIcons' -import { useWorkspaceStore } from '@/stores/useWorkspaceStore' +import OperationalExport from '@/components/dashboard/OperationalExport.vue' import * as echarts from 'echarts/core' import { LineChart } from 'echarts/charts' import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components' @@ -273,8 +213,10 @@ echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, Canvas const { t, locale } = useI18n() const router = useRouter() -const workspaceStore = useWorkspaceStore() -const isGlobalAdmin = computed(() => workspaceStore.isGlobalAdmin) + +// Connected database product name (e.g. "MySQL" / "H2" / "PostgreSQL"), shown as +// a subtle chip in the page header. Empty string hides it when unavailable. +const dbLabel = ref('') const overview = ref>({}) const recentRuns = ref([]) @@ -359,6 +301,15 @@ onMounted(async () => { } catch { // Non-critical } + + // Connected database label — independent and non-critical. Reuses the existing + // system health endpoint, which already reports the product name. + try { + const healthRes: any = await http.get('/system/health') + dbLabel.value = (healthRes?.data || healthRes)?.database || '' + } catch { + dbLabel.value = '' + } }) onUnmounted(() => { @@ -461,121 +412,6 @@ function calcDuration(run: any): string { return (ms / 1000).toFixed(1) + 's' } -// ── Operational Data Export ── -const exportDialogVisible = ref(false) -const exportDateRange = ref<[string, string] | null>(null) -const exportStatus = ref<'idle' | 'generating' | 'locked' | 'completed' | 'failed'>('idle') -const exportStep = ref(0) -const exportTotal = ref(9) -const exportTaskId = ref('') -const exportDownloadToken = ref('') -let exportPollTimer: ReturnType | null = null - -const stepLabels = ['概览汇总', 'Token用量', '技能统计', '用户统计', '用户对话', '安全与审计', '渠道统计', '模型配置', '定时任务'] - -const exportStepLabel = computed(() => { - const i = exportStep.value - 1 - return stepLabels[i] || '' -}) - -const circumference = 2 * Math.PI * 52 // r=52 - -const progressOffset = computed(() => { - const pct = Math.min(exportStep.value / exportTotal.value, 1) - return circumference * (1 - pct) -}) - -const disabledExportDate = (date: Date) => { - // Max 90 days, not in the future - const now = new Date() - now.setHours(23, 59, 59, 0) - return date > now -} - -function openExportDialog() { - // Reconnect to in-progress or completed task - if (exportStatus.value === 'completed') { - // Keep completed state — user can still download - } else if (exportStatus.value === 'generating' || exportStatus.value === 'locked') { - // Resume polling for in-progress task - startPolling() - } else { - // Fresh start - const end = new Date() - const start = new Date() - start.setDate(start.getDate() - 30) - exportDateRange.value = [formatDateStr(start), formatDateStr(end)] - exportStatus.value = 'idle' - exportStep.value = 0 - } - exportDialogVisible.value = true -} - -watch(exportDialogVisible, (v) => { - if (!v && exportPollTimer) { - clearInterval(exportPollTimer) - exportPollTimer = null - } -}) - -function formatDateStr(d: Date): string { - return d.toISOString().slice(0, 10) -} - -async function doGenerate() { - if (!exportDateRange.value) return - try { - exportStatus.value = 'generating' - const [start, end] = exportDateRange.value - const res: any = await operationalApi.generate(start, end) - exportTaskId.value = res.data?.taskId || res.taskId || '' - if (!exportTaskId.value) throw new Error('No taskId') - startPolling() - } catch (e: any) { - const msg = e?.response?.data?.msg || e?.message || 'Unknown error' - if (e?.response?.status === 409) { - exportStatus.value = 'locked' - } else { - exportStatus.value = 'failed' - console.error('Export generate failed:', msg) - } - } -} - -function startPolling() { - if (exportPollTimer) clearInterval(exportPollTimer) - exportPollTimer = setInterval(async () => { - try { - const res: any = await operationalApi.progress(exportTaskId.value) - const data = res.data || res - exportStep.value = data.step || 0 - exportTotal.value = data.total || 9 - if (data.status === 'completed') { - exportStatus.value = 'completed' - exportDownloadToken.value = data.downloadToken || '' - if (exportPollTimer) { clearInterval(exportPollTimer); exportPollTimer = null } - } else if (data.status === 'failed') { - exportStatus.value = 'failed' - if (exportPollTimer) { clearInterval(exportPollTimer); exportPollTimer = null } - } - } catch { - exportStatus.value = 'failed' - if (exportPollTimer) { clearInterval(exportPollTimer); exportPollTimer = null } - } - }, 1000) -} - -async function doDownload() { - try { - await operationalApi.download(exportTaskId.value, exportDownloadToken.value) - exportDialogVisible.value = false - exportStatus.value = 'idle' - exportTaskId.value = '' - exportDownloadToken.value = '' - } catch (e) { - console.error('Download failed:', e) - } -}