mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-14 19:45:08 +08:00
feat(operational): one-click operational data export with 9-sheet Excel (#411)
Add an async export feature on the Dashboard page -- global admins can generate and download a multi-sheet operational data report (.xlsx packaged as .zip). The export covers 9 sheets: 1. Overview - interval KPIs, system snapshot, 7-day trend, period comparison, model details (configured providers only), agent activity ranking top 10 2. Token Usage - daily breakdown by runtime_provider with avg tokens/msg 3. Skill Stats - skill list with usage count, last-call time, bound agents 4. User Stats - per-(workspace, user) aggregated tokens, duration, last active 5. User Conversations - detail rows pairing user-asst messages 6. Security and Audit - unified view across 6 sources (guard rules, audit logs, approvals, grants, config, business audit events) 7. Channel Stats - per-channel conversation count, tokens, unique users 8. Model Config - enabled plus API-key-configured models with parameters 9. Cron Jobs - execution records with duration and token usage Backend highlights: - generate/progress/download endpoints guarded by PreAuthorize hasRole ADMIN - single AtomicBoolean lock (409 when busy), 90-day frontend cap, 5-min deadline - metadata-based tool-call counting, deleted=0 filtering everywhere - value label mapping (chat to dialogue, TRUE to enabled, etc.) - one-time downloadToken, file auto-cleanup after 24h or download Frontend highlights: - SVG ring progress bar with smooth dashoffset transition plus slow rotation - visibility gated by workspaceStore.isGlobalAdmin (v-if on button) - 1-second polling driving progress state machine (idle/generating/done) - Element Plus date-picker (30-day default, 90-day max)
This commit is contained in:
parent
93f40b6dac
commit
c2620720d2
@ -0,0 +1,112 @@
|
|||||||
|
package vip.mate.operational.controller;
|
||||||
|
|
||||||
|
import org.springframework.core.io.InputStreamResource;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import vip.mate.operational.model.ExportTask;
|
||||||
|
import vip.mate.operational.service.OperationalDataExportService;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运营数据导出 Controller — 仅全局管理员可访问。
|
||||||
|
* <p>
|
||||||
|
* 不暴露标准 REST API 端点,下载通过一次性 token 保护。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/operational-data")
|
||||||
|
public class OperationalDataController {
|
||||||
|
|
||||||
|
private final OperationalDataExportService exportService;
|
||||||
|
|
||||||
|
public OperationalDataController(OperationalDataExportService exportService) {
|
||||||
|
this.exportService = exportService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/generate")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<Map<String, Object>> generate(
|
||||||
|
@RequestParam LocalDate startDate,
|
||||||
|
@RequestParam LocalDate endDate) {
|
||||||
|
try {
|
||||||
|
ExportTask task = exportService.generate(startDate, endDate);
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("taskId", task.getTaskId());
|
||||||
|
result.put("status", task.getStatus());
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Map<String, Object> err = new LinkedHashMap<>();
|
||||||
|
err.put("code", 500);
|
||||||
|
err.put("msg", e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
return ResponseEntity.internalServerError().body(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询生成进度(驱动圆形进度条)
|
||||||
|
*/
|
||||||
|
@GetMapping("/progress")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<Map<String, Object>> progress(@RequestParam String taskId) {
|
||||||
|
ExportTask task = exportService.getProgress(taskId);
|
||||||
|
if (task == null) {
|
||||||
|
Map<String, Object> err = new LinkedHashMap<>();
|
||||||
|
err.put("code", 404);
|
||||||
|
err.put("msg", "任务不存在或已过期");
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
result.put("taskId", task.getTaskId());
|
||||||
|
result.put("step", task.getStep());
|
||||||
|
result.put("total", task.getTotal());
|
||||||
|
result.put("status", task.getStatus());
|
||||||
|
if ("completed".equals(task.getStatus())) {
|
||||||
|
result.put("downloadToken", task.getDownloadToken());
|
||||||
|
}
|
||||||
|
if ("failed".equals(task.getStatus())) {
|
||||||
|
result.put("errorMessage", task.getErrorMessage());
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载已生成的文件(一次有效,需 downloadToken)
|
||||||
|
*/
|
||||||
|
@GetMapping("/download")
|
||||||
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
|
public ResponseEntity<Resource> download(
|
||||||
|
@RequestParam String taskId,
|
||||||
|
@RequestParam String token) {
|
||||||
|
ExportTask task = exportService.confirmDownload(taskId, token);
|
||||||
|
if (task == null) {
|
||||||
|
return ResponseEntity.status(HttpStatus.GONE)
|
||||||
|
.contentType(MediaType.TEXT_PLAIN)
|
||||||
|
.body(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!Files.exists(task.getFilePath())) {
|
||||||
|
return ResponseEntity.status(HttpStatus.GONE).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
task.setDownloaded(true);
|
||||||
|
InputStreamResource resource = new InputStreamResource(Files.newInputStream(task.getFilePath()));
|
||||||
|
|
||||||
|
String encodedName = new String(task.getFileName().getBytes("UTF-8"), "ISO-8859-1");
|
||||||
|
return ResponseEntity.ok()
|
||||||
|
.header("Content-Disposition", "attachment; filename=\"" + encodedName + "\"")
|
||||||
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
|
.body(resource);
|
||||||
|
} catch (IOException e) {
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,10 @@
|
|||||||
|
package vip.mate.operational.model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出任务并发冲突异常——{@code AtomicBoolean} 已被占用。
|
||||||
|
*/
|
||||||
|
public class ExportInProgressException extends RuntimeException {
|
||||||
|
public ExportInProgressException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
package vip.mate.operational.model;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运营数据导出任务——异步生成 + 一次下载模型。
|
||||||
|
*/
|
||||||
|
public class ExportTask {
|
||||||
|
private String taskId;
|
||||||
|
private volatile int step;
|
||||||
|
private int total = 9;
|
||||||
|
private volatile String status; // generating | completed | failed | timeout | oom
|
||||||
|
private volatile Path filePath;
|
||||||
|
private volatile long completedAt;
|
||||||
|
private volatile String downloadToken;
|
||||||
|
private volatile boolean downloaded;
|
||||||
|
private volatile String errorMessage;
|
||||||
|
|
||||||
|
public ExportTask() {
|
||||||
|
this.taskId = UUID.randomUUID().toString().substring(0, 8);
|
||||||
|
this.status = "generating";
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCompleted(Path filePath) {
|
||||||
|
this.status = "completed";
|
||||||
|
this.filePath = filePath;
|
||||||
|
this.completedAt = System.currentTimeMillis();
|
||||||
|
this.downloadToken = UUID.randomUUID().toString().substring(0, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setFailed(String errorMessage) {
|
||||||
|
this.status = "failed";
|
||||||
|
this.errorMessage = errorMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getFileName() {
|
||||||
|
if (filePath == null) return null;
|
||||||
|
return filePath.getFileName().toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manual getters/setters (avoid Lombok/Java25 issue) ──
|
||||||
|
|
||||||
|
public String getTaskId() { return taskId; }
|
||||||
|
public void setTaskId(String taskId) { this.taskId = taskId; }
|
||||||
|
|
||||||
|
public int getStep() { return step; }
|
||||||
|
public void setStep(int step) { this.step = step; }
|
||||||
|
|
||||||
|
public int getTotal() { return total; }
|
||||||
|
public void setTotal(int total) { this.total = total; }
|
||||||
|
|
||||||
|
public String getStatus() { return status; }
|
||||||
|
public void setStatus(String status) { this.status = status; }
|
||||||
|
|
||||||
|
public Path getFilePath() { return filePath; }
|
||||||
|
public void setFilePath(Path filePath) { this.filePath = filePath; }
|
||||||
|
|
||||||
|
public long getCompletedAt() { return completedAt; }
|
||||||
|
public void setCompletedAt(long completedAt) { this.completedAt = completedAt; }
|
||||||
|
|
||||||
|
public String getDownloadToken() { return downloadToken; }
|
||||||
|
public void setDownloadToken(String downloadToken) { this.downloadToken = downloadToken; }
|
||||||
|
|
||||||
|
public boolean isDownloaded() { return downloaded; }
|
||||||
|
public void setDownloaded(boolean downloaded) { this.downloaded = downloaded; }
|
||||||
|
|
||||||
|
public String getErrorMessage() { return errorMessage; }
|
||||||
|
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1039,6 +1039,28 @@ export const dashboardApi = {
|
|||||||
recentRuns: (limit = 20) => http.get('/dashboard/cron-runs', { params: { limit } }),
|
recentRuns: (limit = 20) => http.get('/dashboard/cron-runs', { params: { limit } }),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Operational Data Export ====================
|
||||||
|
export const operationalApi = {
|
||||||
|
generate: (startDate: string, endDate: string) =>
|
||||||
|
http.post('/operational-data/generate', null, { params: { startDate, endDate } }),
|
||||||
|
progress: (taskId: string) =>
|
||||||
|
http.get('/operational-data/progress', { params: { taskId } }),
|
||||||
|
/** Download file — uses native fetch to avoid axios R<T> interceptor */
|
||||||
|
download: async (taskId: string, token: string): Promise<void> => {
|
||||||
|
const jwt = localStorage.getItem('token')
|
||||||
|
const resp = await fetch(`/api/v1/operational-data/download?taskId=${taskId}&token=${token}`, {
|
||||||
|
headers: { Authorization: jwt ? `Bearer ${jwt}` : '' },
|
||||||
|
})
|
||||||
|
if (!resp.ok) throw new Error(`Download failed: ${resp.status}`)
|
||||||
|
const blob = await resp.blob()
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = URL.createObjectURL(blob)
|
||||||
|
a.download = `ops_data.zip`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(a.href)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Plugins ====================
|
// ==================== Plugins ====================
|
||||||
export const pluginApi = {
|
export const pluginApi = {
|
||||||
list: () => http.get('/plugins'),
|
list: () => http.get('/plugins'),
|
||||||
|
|||||||
@ -2028,8 +2028,8 @@ export default {
|
|||||||
tier: {
|
tier: {
|
||||||
toCore: '→ Core',
|
toCore: '→ Core',
|
||||||
toExtension: '→ Extension',
|
toExtension: '→ Extension',
|
||||||
toCoreHint: 'Make core: advertised to the model directly',
|
toCoreHint: 'Move to Core: advertised to the model directly',
|
||||||
toExtensionHint: 'Make extension: lives in the tool box, activated after enable_tool',
|
toExtensionHint: 'Move to Extension: lives in the tool box, activated after enable_tool',
|
||||||
locked: 'Source-owned',
|
locked: 'Source-owned',
|
||||||
lockedHint: "MCP / ACP / Skill tools are tiered by their owning server / endpoint / skill — change it there",
|
lockedHint: "MCP / ACP / Skill tools are tiered by their owning server / endpoint / skill — change it there",
|
||||||
core: { desc: 'Advertised to the model directly' },
|
core: { desc: 'Advertised to the model directly' },
|
||||||
@ -3953,6 +3953,16 @@ export default {
|
|||||||
duration: 'Duration',
|
duration: 'Duration',
|
||||||
tokens: 'Tokens',
|
tokens: 'Tokens',
|
||||||
},
|
},
|
||||||
|
operationalExport: 'Export Operational Data',
|
||||||
|
exportDescription: 'Select a date range to generate an operational report. Max 90 days.',
|
||||||
|
generateReport: 'Generate Report',
|
||||||
|
generating: 'Generating...',
|
||||||
|
downloadReport: 'Download',
|
||||||
|
regenerating: 'Regenerate',
|
||||||
|
generateFailed: 'Generation Failed',
|
||||||
|
exportInProgress: 'Export in progress...',
|
||||||
|
expiredHint: 'Report is ready, please download soon',
|
||||||
|
generatingProgress: 'Generating... ({step}/{total})',
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
kicker: 'Memory',
|
kicker: 'Memory',
|
||||||
|
|||||||
@ -1903,8 +1903,8 @@ export default {
|
|||||||
tier: {
|
tier: {
|
||||||
toCore: '→ 核心',
|
toCore: '→ 核心',
|
||||||
toExtension: '→ 扩展',
|
toExtension: '→ 扩展',
|
||||||
toCoreHint: '改为核心:直接进入模型可调用列表',
|
toCoreHint: '移至核心工具:直接进入模型可调用列表',
|
||||||
toExtensionHint: '改为扩展:进入工具盒目录,调用 enable_tool 后激活',
|
toExtensionHint: '移至扩展工具:进入工具盒目录,模型调用 enable_tool 后激活',
|
||||||
locked: '由来源决定',
|
locked: '由来源决定',
|
||||||
lockedHint: 'MCP / ACP / Skill 工具的分级由所属 server / endpoint / skill 决定,请到对应页面修改',
|
lockedHint: 'MCP / ACP / Skill 工具的分级由所属 server / endpoint / skill 决定,请到对应页面修改',
|
||||||
core: { desc: '直接进入模型可调用列表' },
|
core: { desc: '直接进入模型可调用列表' },
|
||||||
@ -4045,6 +4045,16 @@ export default {
|
|||||||
duration: '耗时',
|
duration: '耗时',
|
||||||
tokens: 'Token',
|
tokens: 'Token',
|
||||||
},
|
},
|
||||||
|
operationalExport: '导出运营数据',
|
||||||
|
exportDescription: '选择时间范围,生成运营数据报告。最长 90 天。',
|
||||||
|
generateReport: '生成报告',
|
||||||
|
generating: '生成中...',
|
||||||
|
downloadReport: '下载',
|
||||||
|
regenerating: '重新生成',
|
||||||
|
generateFailed: '生成失败',
|
||||||
|
exportInProgress: '后台生成中...',
|
||||||
|
expiredHint: '数据已生成,请尽快下载',
|
||||||
|
generatingProgress: '生成中... ({step}/{total})',
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
kicker: '记忆',
|
kicker: '记忆',
|
||||||
|
|||||||
@ -3,20 +3,21 @@
|
|||||||
<div class="mc-page-frame dashboard-frame">
|
<div class="mc-page-frame dashboard-frame">
|
||||||
<div class="mc-page-inner dashboard-inner">
|
<div class="mc-page-inner dashboard-inner">
|
||||||
<div class="mc-page-header">
|
<div class="mc-page-header">
|
||||||
<div>
|
<div class="header-title">
|
||||||
<div class="mc-page-kicker">{{ t('dashboard.kicker') }}</div>
|
<div class="mc-page-kicker">{{ t('dashboard.kicker') }}</div>
|
||||||
<h1 class="mc-page-title">{{ t('dashboard.title') }}</h1>
|
<h1 class="mc-page-title">{{ t('dashboard.title') }}</h1>
|
||||||
<p class="mc-page-desc">{{ t('dashboard.desc') }}</p>
|
<p class="mc-page-desc">{{ t('dashboard.desc') }}</p>
|
||||||
<div v-if="dbLabel" class="db-chip" :title="t('doctor.database')">
|
<button v-if="isGlobalAdmin" class="export-btn" @click="openExportDialog">
|
||||||
<svg class="db-chip__icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5v14a9 3 0 0 0 18 0V5"/><path d="M3 12a9 3 0 0 0 18 0"/></svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
<span class="db-chip__label">{{ t('doctor.database') }}</span>
|
{{ t('dashboard.operationalExport') }}
|
||||||
<span class="db-chip__value">{{ dbLabel }}</span>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-note mc-surface-card">
|
<div class="header-actions">
|
||||||
<div class="hero-note__label">{{ t('dashboard.periods.today') }}</div>
|
<div class="hero-note mc-surface-card">
|
||||||
<div class="hero-note__value">{{ todayStats.conversations }}</div>
|
<div class="hero-note__label">{{ t('dashboard.periods.today') }}</div>
|
||||||
<div class="hero-note__meta">{{ t('dashboard.conversations') }} · {{ todayStats.messages }} {{ t('dashboard.messages') }}</div>
|
<div class="hero-note__value">{{ todayStats.conversations }}</div>
|
||||||
|
<div class="hero-note__meta">{{ t('dashboard.conversations') }} · {{ todayStats.messages }} {{ t('dashboard.messages') }}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -187,6 +188,72 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 导出运营数据弹窗 -->
|
||||||
|
<el-dialog v-model="exportDialogVisible" :title="t('dashboard.operationalExport')" width="420px" :close-on-click-modal="false">
|
||||||
|
<div class="export-dialog-body">
|
||||||
|
<p class="export-dialog-desc">{{ t('dashboard.exportDescription') }}</p>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="exportDateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="~"
|
||||||
|
:start-placeholder="'开始日期'"
|
||||||
|
:end-placeholder="'结束日期'"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:disabled-date="disabledExportDate"
|
||||||
|
style="width:100%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Circular progress -->
|
||||||
|
<div v-if="exportStatus === 'generating' || exportStatus === 'locked'" class="export-progress">
|
||||||
|
<div class="circular-progress">
|
||||||
|
<svg class="progress-ring" viewBox="0 0 120 120">
|
||||||
|
<!-- background track -->
|
||||||
|
<circle class="progress-track" cx="60" cy="60" r="52" fill="none" />
|
||||||
|
<!-- animated progress arc -->
|
||||||
|
<circle class="progress-arc" cx="60" cy="60" r="52" fill="none"
|
||||||
|
:stroke-dasharray="circumference"
|
||||||
|
:stroke-dashoffset="progressOffset" />
|
||||||
|
</svg>
|
||||||
|
<div class="circular-inner">
|
||||||
|
<span class="circular-step">{{ exportStep }}/{{ exportTotal }}</span>
|
||||||
|
<span class="circular-label">{{ exportStepLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="exportStatus === 'completed'" class="export-expired-hint">{{ t('dashboard.expiredHint') }}</p>
|
||||||
|
<p v-if="exportStatus === 'failed'" class="export-error-hint">生成失败,请重试</p>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="exportDialogVisible = false">关闭</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="exportStatus === 'idle' || exportStatus === 'failed'"
|
||||||
|
type="primary"
|
||||||
|
:disabled="!exportDateRange"
|
||||||
|
:loading="exportStatus === 'generating'"
|
||||||
|
@click="doGenerate"
|
||||||
|
>
|
||||||
|
{{ exportStatus === 'failed' ? t('dashboard.regenerating') : t('dashboard.generateReport') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else-if="exportStatus === 'generating' || exportStatus === 'locked'"
|
||||||
|
type="primary"
|
||||||
|
disabled
|
||||||
|
loading
|
||||||
|
>
|
||||||
|
{{ exportStatus === 'locked' ? t('dashboard.exportInProgress') : t('dashboard.generating') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-else-if="exportStatus === 'completed'"
|
||||||
|
type="primary"
|
||||||
|
@click="doDownload"
|
||||||
|
>
|
||||||
|
{{ t('dashboard.downloadReport') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -195,8 +262,9 @@ import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ArrowRight, ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue'
|
import { ArrowRight, ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue'
|
||||||
import { dashboardApi, modelApi, http } from '@/api'
|
import { dashboardApi, modelApi, operationalApi } from '@/api'
|
||||||
import { getProviderIcon, onProviderIconError } from '@/utils/providerIcons'
|
import { getProviderIcon, onProviderIconError } from '@/utils/providerIcons'
|
||||||
|
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||||
import * as echarts from 'echarts/core'
|
import * as echarts from 'echarts/core'
|
||||||
import { LineChart } from 'echarts/charts'
|
import { LineChart } from 'echarts/charts'
|
||||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||||
@ -206,6 +274,8 @@ echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, Canvas
|
|||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
const isGlobalAdmin = computed(() => workspaceStore.isGlobalAdmin)
|
||||||
|
|
||||||
const overview = ref<Record<string, any>>({})
|
const overview = ref<Record<string, any>>({})
|
||||||
const recentRuns = ref<any[]>([])
|
const recentRuns = ref<any[]>([])
|
||||||
@ -224,9 +294,7 @@ const todayStats = reactive({
|
|||||||
// ── Model configuration card ──
|
// ── Model configuration card ──
|
||||||
const modelProviders = ref<any[]>([])
|
const modelProviders = ref<any[]>([])
|
||||||
const activeModel = ref<{ providerId: string; model: string } | null>(null)
|
const activeModel = ref<{ providerId: string; model: string } | null>(null)
|
||||||
// Connected database product name (e.g. "MySQL" / "H2" / "PostgreSQL"), surfaced
|
|
||||||
// as a subtle line in the page header. Empty string hides it when unavailable.
|
|
||||||
const dbLabel = ref('')
|
|
||||||
|
|
||||||
const readyProviderCount = computed(
|
const readyProviderCount = computed(
|
||||||
() => modelProviders.value.filter((p) => providerChipStatus(p) === 'ready').length,
|
() => modelProviders.value.filter((p) => providerChipStatus(p) === 'ready').length,
|
||||||
@ -280,15 +348,6 @@ onMounted(async () => {
|
|||||||
// Dashboard data is non-critical
|
// Dashboard data is 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 = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Model configuration card — loaded independently so a failure here never
|
// Model configuration card — loaded independently so a failure here never
|
||||||
// blanks the analytics above, and vice versa.
|
// blanks the analytics above, and vice versa.
|
||||||
try {
|
try {
|
||||||
@ -402,6 +461,122 @@ function calcDuration(run: any): string {
|
|||||||
if (ms < 1000) return ms + 'ms'
|
if (ms < 1000) return ms + 'ms'
|
||||||
return (ms / 1000).toFixed(1) + 's'
|
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<typeof setInterval> | 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -432,32 +607,16 @@ function calcDuration(run: any): string {
|
|||||||
padding-right: 4px;
|
padding-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.db-chip {
|
.header-title {
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
margin-top: 14px;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border: 1px solid var(--mc-border);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: var(--mc-bg-sunken);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1;
|
|
||||||
color: var(--mc-text-secondary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.db-chip__icon {
|
.header-actions {
|
||||||
color: var(--mc-text-tertiary);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
margin-top: 28px;
|
||||||
|
|
||||||
.db-chip__label {
|
|
||||||
color: var(--mc-text-tertiary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.db-chip__value {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--mc-text-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-note {
|
.hero-note {
|
||||||
@ -488,6 +647,108 @@ function calcDuration(run: any): string {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Export button ── */
|
||||||
|
.export-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mc-bg-surface);
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.export-btn:hover {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Export dialog ── */
|
||||||
|
.export-dialog-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.export-dialog-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
}
|
||||||
|
.export-progress {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.circular-progress {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.progress-ring {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
animation: ring-rotate 80s linear infinite;
|
||||||
|
}
|
||||||
|
.progress-track {
|
||||||
|
stroke: var(--mc-border-light, #e5e7eb);
|
||||||
|
stroke-width: 8;
|
||||||
|
}
|
||||||
|
.progress-arc {
|
||||||
|
stroke: var(--mc-primary, #4f7aff);
|
||||||
|
stroke-width: 8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke-dashoffset 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
transform-origin: 60px 60px;
|
||||||
|
}
|
||||||
|
@keyframes ring-rotate {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.circular-inner {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mc-bg-surface);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
.circular-step {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
.circular-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mc-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.export-expired-hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.export-error-hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-danger, #ef4444);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.section-head {
|
.section-head {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user