mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 12:27:53 +08:00
refactor(dashboard): componentize operational export, restore DB chip, polish export dialog
This commit is contained in:
parent
6b2024b71e
commit
9013f5d780
452
mateclaw-ui/src/components/dashboard/OperationalExport.vue
Normal file
452
mateclaw-ui/src/components/dashboard/OperationalExport.vue
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { Download, CircleCheckFilled, WarningFilled, Document, Files } from '@element-plus/icons-vue'
|
||||||
|
import { operationalApi } from '@/api'
|
||||||
|
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-contained operational-data export: a global-admin-only trigger button
|
||||||
|
* plus the generate → progress → download dialog and its polling state machine.
|
||||||
|
* Drop <OperationalExport /> anywhere; it gates its own visibility and owns all
|
||||||
|
* export state, so the host view stays free of export concerns.
|
||||||
|
*/
|
||||||
|
const { t } = useI18n()
|
||||||
|
const workspaceStore = useWorkspaceStore()
|
||||||
|
const isGlobalAdmin = computed(() => workspaceStore.isGlobalAdmin)
|
||||||
|
|
||||||
|
type ExportStatus = 'idle' | 'generating' | 'locked' | 'completed' | 'failed'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const dateRange = ref<[string, string] | null>(null)
|
||||||
|
const status = ref<ExportStatus>('idle')
|
||||||
|
const step = ref(0)
|
||||||
|
const total = ref(9)
|
||||||
|
const taskId = ref('')
|
||||||
|
const downloadToken = ref('')
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
// Excel sheet names, shown under the ring as the step advances. These mirror the
|
||||||
|
// server-side sheet titles (which are emitted in Chinese in the workbook itself).
|
||||||
|
const stepLabels = ['概览汇总', 'Token用量', '技能统计', '用户统计', '用户对话', '安全与审计', '渠道统计', '模型配置', '定时任务']
|
||||||
|
const stepLabel = computed(() => stepLabels[step.value - 1] || '')
|
||||||
|
|
||||||
|
const RADIUS = 52
|
||||||
|
const circumference = 2 * Math.PI * RADIUS
|
||||||
|
const progressOffset = computed(() => circumference * (1 - Math.min(step.value / total.value, 1)))
|
||||||
|
|
||||||
|
const isBusy = computed(() => status.value === 'generating' || status.value === 'locked')
|
||||||
|
|
||||||
|
function formatDate(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disable future dates in the picker; the 90-day span cap is enforced server-side. */
|
||||||
|
function disabledDate(date: Date): boolean {
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(23, 59, 59, 0)
|
||||||
|
return date > now
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick-range presets (inclusive day counts) shown as chips above the picker.
|
||||||
|
const PRESETS = [7, 30, 90]
|
||||||
|
function applyPreset(days: number) {
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date()
|
||||||
|
start.setDate(start.getDate() - days + 1)
|
||||||
|
dateRange.value = [formatDate(start), formatDate(end)]
|
||||||
|
}
|
||||||
|
function isPresetActive(days: number): boolean {
|
||||||
|
if (!dateRange.value) return false
|
||||||
|
const end = new Date()
|
||||||
|
const start = new Date()
|
||||||
|
start.setDate(start.getDate() - days + 1)
|
||||||
|
return dateRange.value[0] === formatDate(start) && dateRange.value[1] === formatDate(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (status.value === 'completed') {
|
||||||
|
// keep the completed state so the user can still download
|
||||||
|
} else if (isBusy.value) {
|
||||||
|
startPolling()
|
||||||
|
} else {
|
||||||
|
applyPreset(30)
|
||||||
|
status.value = 'idle'
|
||||||
|
step.value = 0
|
||||||
|
}
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer)
|
||||||
|
pollTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, (v) => {
|
||||||
|
if (!v) stopPolling()
|
||||||
|
})
|
||||||
|
onUnmounted(stopPolling)
|
||||||
|
|
||||||
|
async function doGenerate() {
|
||||||
|
if (!dateRange.value) return
|
||||||
|
try {
|
||||||
|
status.value = 'generating'
|
||||||
|
step.value = 0
|
||||||
|
const [start, end] = dateRange.value
|
||||||
|
const res: any = await operationalApi.generate(start, end)
|
||||||
|
taskId.value = res.data?.taskId || res.taskId || ''
|
||||||
|
if (!taskId.value) throw new Error('No taskId')
|
||||||
|
startPolling()
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.response?.status === 409) {
|
||||||
|
status.value = 'locked'
|
||||||
|
} else {
|
||||||
|
status.value = 'failed'
|
||||||
|
console.error('Export generate failed:', e?.response?.data?.msg || e?.message || e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling()
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const res: any = await operationalApi.progress(taskId.value)
|
||||||
|
const data = res.data || res
|
||||||
|
step.value = data.step || 0
|
||||||
|
total.value = data.total || 9
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
status.value = 'completed'
|
||||||
|
downloadToken.value = data.downloadToken || ''
|
||||||
|
stopPolling()
|
||||||
|
} else if (data.status === 'failed') {
|
||||||
|
status.value = 'failed'
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
status.value = 'failed'
|
||||||
|
stopPolling()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doDownload() {
|
||||||
|
try {
|
||||||
|
await operationalApi.download(taskId.value, downloadToken.value)
|
||||||
|
visible.value = false
|
||||||
|
status.value = 'idle'
|
||||||
|
taskId.value = ''
|
||||||
|
downloadToken.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Download failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button v-if="isGlobalAdmin" class="oe-trigger" @click="open">
|
||||||
|
<el-icon :size="15"><Download /></el-icon>
|
||||||
|
{{ t('dashboard.operationalExport') }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
:title="t('dashboard.operationalExport')"
|
||||||
|
width="460px"
|
||||||
|
align-center
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
>
|
||||||
|
<div class="oe-body">
|
||||||
|
<!-- Pick a range (idle / failed) -->
|
||||||
|
<template v-if="status === 'idle' || status === 'failed'">
|
||||||
|
<div class="oe-intro">
|
||||||
|
<div class="oe-intro__icon"><el-icon :size="20"><Document /></el-icon></div>
|
||||||
|
<p class="oe-intro__desc">{{ t('dashboard.exportDescription') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oe-field">
|
||||||
|
<span class="oe-field__label">{{ t('common.selectRange') }}</span>
|
||||||
|
<div class="oe-quick">
|
||||||
|
<button
|
||||||
|
v-for="d in PRESETS" :key="d"
|
||||||
|
type="button"
|
||||||
|
class="oe-quick__chip"
|
||||||
|
:class="{ 'is-active': isPresetActive(d) }"
|
||||||
|
@click="applyPreset(d)"
|
||||||
|
>{{ t('common.lastDays', { n: d }) }}</button>
|
||||||
|
</div>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
range-separator="~"
|
||||||
|
:start-placeholder="t('common.startDate')"
|
||||||
|
:end-placeholder="t('common.endDate')"
|
||||||
|
format="YYYY-MM-DD"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
:disabled-date="disabledDate"
|
||||||
|
style="width:100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oe-includes">
|
||||||
|
<el-icon :size="13"><Files /></el-icon>
|
||||||
|
<span>{{ t('dashboard.exportIncludes', { count: total }) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="status === 'failed'" class="oe-alert oe-alert--error">
|
||||||
|
<el-icon><WarningFilled /></el-icon>
|
||||||
|
<span>{{ t('dashboard.generateFailed') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Generating (ring) -->
|
||||||
|
<div v-else-if="isBusy" class="oe-progress">
|
||||||
|
<div class="oe-ring">
|
||||||
|
<svg class="oe-ring__svg" viewBox="0 0 120 120">
|
||||||
|
<circle class="oe-ring__track" cx="60" cy="60" r="52" fill="none" />
|
||||||
|
<circle
|
||||||
|
class="oe-ring__arc" cx="60" cy="60" r="52" fill="none"
|
||||||
|
:stroke-dasharray="circumference"
|
||||||
|
:stroke-dashoffset="progressOffset"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div class="oe-ring__inner">
|
||||||
|
<span class="oe-ring__step">{{ step }}/{{ total }}</span>
|
||||||
|
<span class="oe-ring__label">{{ stepLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="oe-progress__hint">
|
||||||
|
{{ status === 'locked' ? t('dashboard.exportInProgress') : t('dashboard.generating') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Completed -->
|
||||||
|
<div v-else-if="status === 'completed'" class="oe-done">
|
||||||
|
<el-icon class="oe-done__icon" :size="44"><CircleCheckFilled /></el-icon>
|
||||||
|
<p class="oe-done__title">{{ t('dashboard.reportReady') }}</p>
|
||||||
|
<p class="oe-done__hint">{{ t('dashboard.expiredHint') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">{{ t('common.close') }}</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="status === 'idle' || status === 'failed'"
|
||||||
|
type="primary"
|
||||||
|
:disabled="!dateRange"
|
||||||
|
@click="doGenerate"
|
||||||
|
>
|
||||||
|
{{ status === 'failed' ? t('dashboard.regenerating') : t('dashboard.generateReport') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else-if="isBusy" type="primary" disabled loading>
|
||||||
|
{{ status === 'locked' ? t('dashboard.exportInProgress') : t('dashboard.generating') }}
|
||||||
|
</el-button>
|
||||||
|
<el-button v-else-if="status === 'completed'" type="primary" @click="doDownload">
|
||||||
|
<el-icon style="margin-right:4px"><Download /></el-icon>
|
||||||
|
{{ t('dashboard.downloadReport') }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* Pill matching the header's database chip shape, but tinted with the brand
|
||||||
|
color so the export action stands out from the neutral chip beside it. */
|
||||||
|
.oe-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid var(--mc-primary-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--mc-primary-bg);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
height: 26px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.oe-trigger:hover {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
background: var(--mc-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oe-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
min-height: 120px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.oe-intro {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.oe-intro__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--mc-primary-soft, rgba(217, 119, 87, 0.12));
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
.oe-intro__desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oe-quick {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.oe-quick__chip {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.oe-quick__chip:hover {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
.oe-quick__chip.is-active {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
background: var(--mc-primary-soft, rgba(217, 119, 87, 0.12));
|
||||||
|
color: var(--mc-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.oe-includes {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-muted);
|
||||||
|
}
|
||||||
|
.oe-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.oe-field__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oe-alert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.oe-alert--error {
|
||||||
|
background: var(--mc-danger-bg, rgba(239, 68, 68, 0.08));
|
||||||
|
color: var(--mc-danger, #ef4444);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oe-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.oe-ring {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.oe-ring__svg {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
animation: oe-ring-rotate 80s linear infinite;
|
||||||
|
}
|
||||||
|
.oe-ring__track {
|
||||||
|
stroke: var(--mc-border-light, #e5e7eb);
|
||||||
|
stroke-width: 8;
|
||||||
|
}
|
||||||
|
.oe-ring__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 oe-ring-rotate {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
.oe-ring__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;
|
||||||
|
}
|
||||||
|
.oe-ring__step {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
.oe-ring__label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mc-text-muted);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
.oe-progress__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.oe-done {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.oe-done__icon {
|
||||||
|
color: var(--mc-success, #67c23a);
|
||||||
|
}
|
||||||
|
.oe-done__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
}
|
||||||
|
.oe-done__hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -25,6 +25,10 @@ export default {
|
|||||||
copyFailed: 'Copy failed',
|
copyFailed: 'Copy failed',
|
||||||
confirm: 'Confirm',
|
confirm: 'Confirm',
|
||||||
close: 'Close',
|
close: 'Close',
|
||||||
|
selectRange: 'Select date range',
|
||||||
|
startDate: 'Start date',
|
||||||
|
endDate: 'End date',
|
||||||
|
lastDays: 'Last {n} days',
|
||||||
add: 'Add',
|
add: 'Add',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
noResults: 'No matches',
|
noResults: 'No matches',
|
||||||
@ -3963,6 +3967,8 @@ export default {
|
|||||||
exportInProgress: 'Export in progress...',
|
exportInProgress: 'Export in progress...',
|
||||||
expiredHint: 'Report is ready, please download soon',
|
expiredHint: 'Report is ready, please download soon',
|
||||||
generatingProgress: 'Generating... ({step}/{total})',
|
generatingProgress: 'Generating... ({step}/{total})',
|
||||||
|
reportReady: 'Report is ready',
|
||||||
|
exportIncludes: 'The report contains {count} data sheets',
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
kicker: 'Memory',
|
kicker: 'Memory',
|
||||||
|
|||||||
@ -25,6 +25,10 @@ export default {
|
|||||||
copyFailed: '复制失败',
|
copyFailed: '复制失败',
|
||||||
confirm: '确认',
|
confirm: '确认',
|
||||||
close: '关闭',
|
close: '关闭',
|
||||||
|
selectRange: '选择时间范围',
|
||||||
|
startDate: '开始日期',
|
||||||
|
endDate: '结束日期',
|
||||||
|
lastDays: '近 {n} 天',
|
||||||
add: '添加',
|
add: '添加',
|
||||||
search: '搜索',
|
search: '搜索',
|
||||||
noResults: '没有匹配项',
|
noResults: '没有匹配项',
|
||||||
@ -4055,6 +4059,8 @@ export default {
|
|||||||
exportInProgress: '后台生成中...',
|
exportInProgress: '后台生成中...',
|
||||||
expiredHint: '数据已生成,请尽快下载',
|
expiredHint: '数据已生成,请尽快下载',
|
||||||
generatingProgress: '生成中... ({step}/{total})',
|
generatingProgress: '生成中... ({step}/{total})',
|
||||||
|
reportReady: '报告已就绪',
|
||||||
|
exportIncludes: '报告包含 {count} 张数据表',
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
kicker: '记忆',
|
kicker: '记忆',
|
||||||
|
|||||||
@ -7,10 +7,14 @@
|
|||||||
<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>
|
||||||
<button v-if="isGlobalAdmin" class="export-btn" @click="openExportDialog">
|
<div class="header-meta">
|
||||||
<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>
|
<div v-if="dbLabel" class="db-chip" :title="t('doctor.database')">
|
||||||
{{ t('dashboard.operationalExport') }}
|
<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>
|
||||||
</button>
|
<span class="db-chip__label">{{ t('doctor.database') }}</span>
|
||||||
|
<span class="db-chip__value">{{ dbLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<OperationalExport />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<div class="hero-note mc-surface-card">
|
<div class="hero-note mc-surface-card">
|
||||||
@ -189,70 +193,6 @@
|
|||||||
</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"
|
|
||||||
@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>
|
||||||
|
|
||||||
@ -261,9 +201,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, operationalApi } from '@/api'
|
import { dashboardApi, modelApi, http } from '@/api'
|
||||||
import { getProviderIcon, onProviderIconError } from '@/utils/providerIcons'
|
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 * 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'
|
||||||
@ -273,8 +213,10 @@ 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)
|
// 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<Record<string, any>>({})
|
const overview = ref<Record<string, any>>({})
|
||||||
const recentRuns = ref<any[]>([])
|
const recentRuns = ref<any[]>([])
|
||||||
@ -359,6 +301,15 @@ onMounted(async () => {
|
|||||||
} catch {
|
} catch {
|
||||||
// Non-critical
|
// 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(() => {
|
onUnmounted(() => {
|
||||||
@ -461,121 +412,6 @@ function calcDuration(run: any): string {
|
|||||||
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>
|
||||||
@ -646,106 +482,35 @@ async function doDownload() {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Export button ── */
|
/* ── Header meta row: database chip + export trigger ── */
|
||||||
.export-btn {
|
.header-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.db-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
margin-top: 12px;
|
padding: 4px 10px;
|
||||||
padding: 8px 14px;
|
border: 1px solid var(--mc-border);
|
||||||
border: 1px solid var(--mc-border-light);
|
border-radius: 999px;
|
||||||
border-radius: 8px;
|
background: var(--mc-bg-sunken);
|
||||||
background: var(--mc-bg-surface);
|
font-size: 12px;
|
||||||
color: var(--mc-text-secondary);
|
line-height: 1;
|
||||||
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);
|
color: var(--mc-text-secondary);
|
||||||
}
|
}
|
||||||
.export-progress {
|
.db-chip__icon {
|
||||||
display: flex;
|
color: var(--mc-text-tertiary);
|
||||||
justify-content: center;
|
|
||||||
padding: 12px 0;
|
|
||||||
}
|
}
|
||||||
.circular-progress {
|
.db-chip__label {
|
||||||
width: 120px;
|
color: var(--mc-text-tertiary);
|
||||||
height: 120px;
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
.progress-ring {
|
.db-chip__value {
|
||||||
position: absolute;
|
font-weight: 600;
|
||||||
inset: 0;
|
color: var(--mc-text-primary);
|
||||||
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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user