Files
sam3_local/static/admin.html
2026-02-18 01:26:22 +08:00

1458 lines
88 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>量迹AI - SAM3 视觉分割控制台</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/echarts/5.5.0/echarts.min.js"></script>
<script src="https://unpkg.com/echarts-wordcloud@2.1.0/dist/echarts-wordcloud.min.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<style>
:root {
--primary-color: #2563eb;
--sidebar-width: 280px;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: #f8fafc;
}
[v-cloak] { display: none; }
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Animations */
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
.slide-up-enter-active {
transition: all 0.4s ease-out;
}
.slide-up-leave-active {
transition: all 0.3s ease-in;
}
.slide-up-enter-from {
opacity: 0;
transform: translateY(20px);
}
.slide-up-leave-to {
opacity: 0;
transform: translateY(-20px);
}
/* Glassmorphism & Cards */
.glass-panel {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.hover-card {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.hover-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 20px -8px rgba(0, 0, 0, 0.15);
}
/* Sidebar Link Active State */
.nav-link.active {
background: linear-gradient(90deg, rgba(59, 130, 246, 0.08) 0%, rgba(59, 130, 246, 0) 100%);
border-left: 3px solid #3b82f6;
color: #2563eb;
}
.nav-link {
border-left: 3px solid transparent;
}
/* Chart Containers */
.chart-container {
position: relative;
height: 320px;
overflow: hidden;
}
.stat-value {
background: linear-gradient(to right, #1e293b, #334155);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
</style>
</head>
<body class="text-slate-800 antialiased h-screen overflow-hidden">
<div id="app" v-cloak class="h-full flex flex-col">
<!-- Login Screen -->
<transition name="fade">
<div v-if="!isLoggedIn" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900 bg-opacity-50 backdrop-blur-sm">
<div class="bg-white p-10 rounded-2xl shadow-2xl w-full max-w-md transform transition-all scale-100 border border-white/20">
<div class="text-center mb-8">
<div class="w-16 h-16 bg-gradient-to-tr from-blue-600 to-indigo-500 rounded-xl mx-auto flex items-center justify-center mb-4 shadow-lg shadow-blue-500/30">
<i class="fas fa-cube text-white text-3xl"></i>
</div>
<h1 class="text-3xl font-bold text-slate-800 tracking-tight">量迹AI SAM3</h1>
<p class="text-slate-500 mt-2">视觉分割管理后台</p>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-slate-700 mb-1.5">访问密码</label>
<input v-model="password" type="password" @keyup.enter="login"
class="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-blue-500 focus:ring-2 focus:ring-blue-100 transition-all outline-none bg-slate-50 focus:bg-white"
placeholder="请输入管理员密码">
</div>
<button @click="login"
class="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-semibold py-3 px-4 rounded-xl shadow-lg shadow-blue-500/30 transition-all duration-300 transform active:scale-[0.98]">
进入系统
</button>
<p v-if="loginError" class="text-red-500 text-sm text-center bg-red-50 py-2 rounded-lg flex items-center justify-center gap-2 border border-red-100">
<i class="fas fa-exclamation-circle"></i> {{ loginError }}
</p>
</div>
</div>
</div>
</transition>
<!-- Main App Interface -->
<div v-if="isLoggedIn" class="flex flex-1 h-full overflow-hidden bg-slate-50">
<!-- Sidebar -->
<aside class="w-72 bg-white border-r border-slate-200 flex flex-col shadow-sm z-10">
<div class="p-8 pb-4">
<div class="flex items-center gap-3 text-slate-800 mb-8">
<div class="w-10 h-10 bg-gradient-to-br from-blue-600 to-indigo-600 rounded-lg flex items-center justify-center shadow-md shadow-blue-200">
<i class="fas fa-layer-group text-white text-lg"></i>
</div>
<div>
<h2 class="font-bold text-lg leading-tight tracking-tight">SAM3 Admin</h2>
<p class="text-[10px] text-slate-400 font-bold tracking-widest uppercase mt-0.5">Quantum Track AI</p>
</div>
</div>
<div class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3 px-4">主菜单</div>
<nav class="space-y-1 px-2">
<a href="#" @click.prevent="switchTab('dashboard')" :class="{ 'active': currentTab === 'dashboard' }"
class="nav-link flex items-center gap-3 px-4 py-3 text-slate-600 hover:bg-slate-50 hover:text-blue-600 rounded-xl transition-all duration-200 group">
<i class="fas fa-chart-pie w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'dashboard' ? 'text-blue-600' : 'text-slate-400'"></i>
<span class="font-medium">数据看板</span>
</a>
<a href="#" @click.prevent="switchTab('gpu')" :class="{ 'active': currentTab === 'gpu' }"
class="nav-link flex items-center gap-3 px-4 py-3 text-slate-600 hover:bg-slate-50 hover:text-blue-600 rounded-xl transition-all duration-200 group">
<i class="fas fa-microchip w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'gpu' ? 'text-blue-600' : 'text-slate-400'"></i>
<span class="font-medium">GPU 监控</span>
</a>
<a href="#" @click.prevent="switchTab('history')" :class="{ 'active': currentTab === 'history' }"
class="nav-link flex items-center gap-3 px-4 py-3 text-slate-600 hover:bg-slate-50 hover:text-blue-600 rounded-xl transition-all duration-200 group">
<i class="fas fa-list w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'history' ? 'text-blue-600' : 'text-slate-400'"></i>
<span class="font-medium">识别记录</span>
</a>
<a href="#" @click.prevent="switchTab('files')" :class="{ 'active': currentTab === 'files' }"
class="nav-link flex items-center gap-3 px-4 py-3 text-slate-600 hover:bg-slate-50 hover:text-blue-600 rounded-xl transition-all duration-200 group">
<i class="fas fa-folder-open w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'files' ? 'text-blue-600' : 'text-slate-400'"></i>
<span class="font-medium">文件资源</span>
</a>
<a href="#" @click.prevent="switchTab('prompts')" :class="{ 'active': currentTab === 'prompts' }"
class="nav-link flex items-center gap-3 px-4 py-3 text-slate-600 hover:bg-slate-50 hover:text-blue-600 rounded-xl transition-all duration-200 group">
<i class="fas fa-magic w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'prompts' ? 'text-blue-600' : 'text-slate-400'"></i>
<span class="font-medium">提示词工程</span>
</a>
</nav>
<div class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3 mt-8 px-4">系统</div>
<nav class="space-y-1 px-2">
<a href="#" @click.prevent="switchTab('settings')" :class="{ 'active': currentTab === 'settings' }"
class="nav-link flex items-center gap-3 px-4 py-3 text-slate-600 hover:bg-slate-50 hover:text-blue-600 rounded-xl transition-all duration-200 group">
<i class="fas fa-cog w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'settings' ? 'text-blue-600' : 'text-slate-400'"></i>
<span class="font-medium">全局配置</span>
</a>
</nav>
</div>
<div class="mt-auto p-6 border-t border-slate-100">
<button @click="logout" class="flex items-center gap-3 w-full px-4 py-2.5 text-slate-500 hover:text-red-600 hover:bg-red-50 rounded-xl transition-all duration-200 group">
<i class="fas fa-sign-out-alt group-hover:translate-x-1 transition-transform"></i>
<span class="font-medium">退出登录</span>
</button>
</div>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-y-auto overflow-x-hidden relative scroll-smooth">
<!-- Header -->
<header class="sticky top-0 z-20 bg-white/80 backdrop-blur-md border-b border-slate-200 px-8 py-4 flex justify-between items-center shadow-sm">
<div>
<h1 class="text-2xl font-bold text-slate-800">{{ getPageTitle(currentTab) }}</h1>
<p class="text-sm text-slate-500 mt-0.5">{{ getPageSubtitle(currentTab) }}</p>
</div>
<div class="flex items-center gap-4">
<div class="bg-white border border-slate-200 rounded-full px-4 py-1.5 flex items-center gap-2 shadow-sm">
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-green-500"></span>
</span>
<span class="text-sm font-medium text-slate-600">系统正常</span>
</div>
<button @click="refreshData" class="p-2.5 text-slate-400 hover:text-blue-600 transition-colors rounded-full hover:bg-slate-100 active:scale-95" title="刷新数据">
<i class="fas fa-sync-alt" :class="{'fa-spin': isLoading}"></i>
</button>
</div>
</header>
<div class="p-8 max-w-7xl mx-auto min-h-[calc(100vh-88px)]">
<transition name="slide-up" mode="out-in">
<!-- Dashboard Tab -->
<div v-if="currentTab === 'dashboard'" key="dashboard" class="space-y-8">
<!-- Filter Bar -->
<div class="flex flex-wrap items-center gap-4 bg-white p-4 rounded-2xl shadow-sm border border-slate-100">
<div class="flex items-center gap-2 text-sm font-medium text-slate-600">
<i class="fas fa-filter text-blue-500"></i>
<span>筛选数据:</span>
</div>
<select v-model="selectedTimeRange" class="bg-slate-50 border border-slate-200 text-slate-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none hover:bg-slate-100 transition">
<option value="all">全部时间</option>
<option value="24h">最近 24 小时</option>
<option value="7d">最近 7 天</option>
<option value="30d">最近 30 天</option>
</select>
<select v-model="selectedType" class="bg-slate-50 border border-slate-200 text-slate-700 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none hover:bg-slate-100 transition">
<option value="all">所有类型</option>
<option v-for="type in uniqueTypes" :key="type" :value="type">{{ type }}</option>
</select>
<div class="ml-auto text-xs text-slate-400">
共筛选出 <span class="font-bold text-slate-700">{{ filteredHistory.length }}</span> 条记录
</div>
</div>
<!-- Core Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Total Count -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover-card group">
<div class="flex justify-between items-start mb-4">
<div>
<p class="text-slate-500 text-sm font-medium">总识别次数</p>
<h3 class="text-3xl font-bold stat-value mt-1">{{ stats.totalCount }}</h3>
</div>
<div class="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center text-blue-600 group-hover:scale-110 transition-transform">
<i class="fas fa-camera text-xl"></i>
</div>
</div>
<div class="flex items-center text-xs text-slate-400">
<span class="text-blue-500 font-medium flex items-center gap-1 bg-blue-50 px-1.5 py-0.5 rounded">
今日: {{ stats.todayCount }}
</span>
<span class="ml-auto">最新: {{ stats.lastActivity }}</span>
</div>
</div>
<!-- Success Rate -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover-card group">
<div class="flex justify-between items-start mb-4">
<div>
<p class="text-slate-500 text-sm font-medium">识别成功率</p>
<h3 class="text-3xl font-bold stat-value mt-1">{{ stats.successRate }}%</h3>
</div>
<div class="w-12 h-12 bg-green-50 rounded-xl flex items-center justify-center text-green-600 group-hover:scale-110 transition-transform">
<i class="fas fa-check-circle text-xl"></i>
</div>
</div>
<div class="w-full bg-slate-100 rounded-full h-1.5 mt-2 overflow-hidden">
<div class="bg-green-500 h-1.5 rounded-full transition-all duration-1000" :style="{ width: stats.successRate + '%' }"></div>
</div>
<div class="mt-2 text-xs text-slate-400 flex justify-between">
<span>失败次数: <span class="text-red-500 font-medium">{{ stats.failCount }}</span></span>
</div>
</div>
<!-- Duration -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover-card group">
<div class="flex justify-between items-start mb-4">
<div>
<p class="text-slate-500 text-sm font-medium">平均耗时</p>
<h3 class="text-3xl font-bold stat-value mt-1">{{ stats.avgDuration }}s</h3>
</div>
<div class="w-12 h-12 bg-yellow-50 rounded-xl flex items-center justify-center text-yellow-600 group-hover:scale-110 transition-transform">
<i class="fas fa-stopwatch text-xl"></i>
</div>
</div>
<div class="flex items-center text-xs text-slate-400">
<span class="font-medium bg-slate-100 px-1.5 py-0.5 rounded mr-2">
Max: {{ stats.maxDuration }}s
</span>
<span class="font-medium" :class="stats.avgDuration < 3 ? 'text-green-500' : 'text-yellow-500'">
{{ stats.avgDuration < 3 ? '性能优异' : '性能正常' }}
</span>
</div>
</div>
<!-- Model Info -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover-card group">
<div class="flex justify-between items-start mb-4">
<div>
<p class="text-slate-500 text-sm font-medium">当前模型</p>
<h3 class="text-xl font-bold text-slate-800 mt-2 truncate max-w-[140px]" :title="currentModel">{{ currentModel || 'Loading...' }}</h3>
</div>
<div class="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center text-purple-600 group-hover:scale-110 transition-transform">
<i class="fas fa-brain text-xl"></i>
</div>
</div>
<div class="flex items-center text-xs text-slate-400">
<span class="bg-purple-100 text-purple-700 px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wide">Qwen-VL Engine</span>
</div>
</div>
</div>
<!-- Charts Row 1: Trends & Distribution -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 bg-white rounded-2xl shadow-sm border border-slate-100 p-6 relative">
<h3 class="font-bold text-slate-800 mb-6 flex items-center gap-2">
<span class="w-1 h-5 bg-blue-500 rounded-full"></span>
识别量趋势
</h3>
<div ref="barChartRef" class="chart-container"></div>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-800 mb-6 flex items-center gap-2">
<span class="w-1 h-5 bg-purple-500 rounded-full"></span>
识别类型分布
</h3>
<div ref="pieChartRef" class="chart-container"></div>
</div>
</div>
<!-- Charts Row 2: Prompt Analytics -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-1 bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-800 mb-6 flex items-center gap-2">
<span class="w-1 h-5 bg-indigo-500 rounded-full"></span>
Prompt 词频分布
</h3>
<div ref="promptPieChartRef" class="chart-container"></div>
</div>
<div class="lg:col-span-2 bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-800 mb-6 flex items-center gap-2">
<span class="w-1 h-5 bg-orange-500 rounded-full"></span>
Top 10 热门 Prompt
</h3>
<div ref="promptBarChartRef" class="chart-container"></div>
</div>
</div>
<!-- Charts Row 3: Word Cloud -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-800 mb-6 flex items-center gap-2">
<span class="w-1 h-5 bg-teal-500 rounded-full"></span>
Prompt 词云
</h3>
<div ref="wordCloudRef" class="chart-container" style="height: 400px;"></div>
</div>
</div>
<!-- History Tab -->
<div v-if="currentTab === 'history'" key="history" class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full text-left">
<thead>
<tr class="bg-slate-50 border-b border-slate-100 text-xs font-semibold text-slate-500 uppercase tracking-wider">
<th class="px-6 py-4">时间</th>
<th class="px-6 py-4">识别类型</th>
<th class="px-6 py-4">Prompt / 详情</th>
<th class="px-6 py-4 text-center">耗时</th>
<th class="px-6 py-4 text-center">状态</th>
<th class="px-6 py-4 text-center">操作</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<tr v-for="record in filteredHistory" :key="record.timestamp" class="hover:bg-slate-50/80 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-slate-900">{{ formatDate(record.timestamp).split(' ')[0] }}</div>
<div class="text-xs text-slate-400">{{ formatDate(record.timestamp).split(' ')[1] }}</div>
</td>
<td class="px-6 py-4">
<span :class="getTypeBadgeClass(record.type)" class="px-2.5 py-1 text-xs font-semibold rounded-full border">
{{ record.type }}
</span>
</td>
<td class="px-6 py-4">
<div class="flex flex-col gap-1 max-w-md">
<div v-if="record.prompt" class="text-sm text-slate-800 line-clamp-2" :title="record.prompt">
<i class="fas fa-keyboard text-xs text-slate-400 mr-1"></i>{{ record.prompt }}
</div>
<div v-if="record.final_prompt && record.final_prompt !== record.prompt" class="text-xs text-slate-500 bg-slate-50 p-1.5 rounded border border-slate-100">
<div class="flex items-center gap-1 mb-0.5 text-purple-600 font-medium">
<i class="fas fa-magic text-[10px]"></i> 优化后 Prompt
</div>
<div class="italic line-clamp-2" :title="record.final_prompt">{{ record.final_prompt }}</div>
</div>
<div v-if="record.details" class="text-xs text-slate-400 flex items-center gap-1 mt-0.5">
<i class="fas fa-info-circle"></i> {{ record.details }}
</div>
</div>
</td>
<td class="px-6 py-4 text-center">
<span class="text-xs font-mono text-slate-600 bg-slate-100 px-2 py-1 rounded">
{{ record.duration ? record.duration.toFixed(2) + 's' : '-' }}
</span>
</td>
<td class="px-6 py-4 text-center">
<span v-if="record.status === 'success'" class="text-green-500 text-lg"><i class="fas fa-check-circle"></i></span>
<span v-else class="text-red-500 text-lg"><i class="fas fa-times-circle"></i></span>
</td>
<td class="px-6 py-4 text-center">
<button v-if="record.result_path" @click="viewResult(record.result_path)"
class="text-blue-600 hover:text-blue-800 hover:bg-blue-50 p-2 rounded-lg transition-all" title="查看结果">
<i class="fas fa-external-link-alt"></i>
</button>
<span v-else class="text-slate-300 cursor-not-allowed"><i class="fas fa-eye-slash"></i></span>
</td>
</tr>
<tr v-if="filteredHistory.length === 0">
<td colspan="6" class="px-6 py-12 text-center text-slate-400">
<div class="flex flex-col items-center gap-3">
<div class="w-12 h-12 bg-slate-100 rounded-full flex items-center justify-center">
<i class="fas fa-inbox text-xl text-slate-300"></i>
</div>
<p>暂无符合筛选条件的记录</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Files Tab -->
<div v-if="currentTab === 'files'" key="files" class="space-y-4">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-4 flex items-center justify-between sticky top-0 z-10">
<div class="flex items-center gap-3 text-sm">
<button @click="currentPath = ''; fetchFiles()" class="text-slate-500 hover:text-blue-600 transition-colors">
<i class="fas fa-home"></i> 根目录
</button>
<span v-if="currentPath" class="text-slate-300">/</span>
<span v-if="currentPath" class="font-mono text-slate-700">results/{{ currentPath }}</span>
<button v-if="currentPath" @click="navigateUp" class="ml-2 px-2 py-1 bg-slate-100 hover:bg-slate-200 rounded text-xs text-slate-600 transition-colors">
<i class="fas fa-level-up-alt"></i> 返回上一级
</button>
</div>
<div class="flex gap-2">
<button @click="fetchFiles" class="p-2 text-slate-400 hover:text-blue-600 transition-colors rounded hover:bg-slate-50">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<div v-for="file in files" :key="file.name"
class="group relative bg-white border border-slate-200 rounded-xl p-3 hover:shadow-lg hover:border-blue-300 transition-all cursor-pointer h-40 flex flex-col items-center justify-center gap-2">
<!-- Folder -->
<div v-if="file.is_dir" @click="enterDir(file.name)" class="flex flex-col items-center w-full h-full justify-center">
<i class="fas fa-folder text-yellow-400 text-5xl mb-2 drop-shadow-sm group-hover:scale-110 transition-transform"></i>
<span class="text-xs font-medium text-slate-700 truncate w-full text-center px-2">{{ file.name }}</span>
<span class="text-[10px] text-slate-400">{{ file.count }} 项</span>
</div>
<!-- Image -->
<div v-else-if="isImage(file.name)" @click="previewImage(file.url)" class="flex flex-col items-center w-full h-full justify-center">
<div class="w-full h-24 bg-slate-50 rounded-lg overflow-hidden mb-2 border border-slate-100">
<img :src="file.url" class="w-full h-full object-contain" loading="lazy">
</div>
<span class="text-xs font-medium text-slate-700 truncate w-full text-center px-1">{{ file.name }}</span>
<span class="text-[10px] text-slate-400 font-mono mt-0.5 bg-slate-100 px-1.5 rounded">{{ formatBytes(file.size) }}</span>
</div>
<!-- Other -->
<div v-else class="flex flex-col items-center w-full h-full justify-center">
<i class="fas fa-file-alt text-slate-300 text-4xl mb-2"></i>
<span class="text-xs font-medium text-slate-700 truncate w-full text-center px-1">{{ file.name }}</span>
<span class="text-[10px] text-slate-400 font-mono mt-0.5 bg-slate-100 px-1.5 rounded">{{ formatBytes(file.size) }}</span>
</div>
<!-- Delete Action -->
<button @click.stop="deleteFile(file.name)"
class="absolute top-2 right-2 w-6 h-6 bg-red-500 text-white rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all transform scale-90 group-hover:scale-100 hover:bg-red-600 shadow-sm z-10">
<i class="fas fa-times text-xs"></i>
</button>
</div>
</div>
<div v-if="files.length === 0" class="flex flex-col items-center justify-center py-20 text-slate-400">
<i class="fas fa-folder-open text-4xl mb-3 opacity-30"></i>
<p>此目录下暂无文件</p>
</div>
</div>
<!-- Prompts Tab -->
<div v-if="currentTab === 'prompts'" key="prompts" class="grid grid-cols-1 gap-6">
<div v-for="(content, key) in prompts" :key="key" class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<div>
<div class="flex items-center gap-2">
<span class="bg-blue-100 text-blue-700 text-xs font-bold px-2 py-0.5 rounded uppercase tracking-wider font-mono">{{ key }}</span>
<h3 class="text-sm font-semibold text-slate-700">{{ getPromptDescription(key) }}</h3>
</div>
</div>
<button @click="savePrompt(key)" class="bg-slate-900 hover:bg-blue-600 text-white text-xs font-bold py-2 px-4 rounded-lg transition-all flex items-center gap-2 shadow-sm">
<i class="fas fa-save"></i> 保存修改
</button>
</div>
<div class="p-0">
<textarea v-model="prompts[key]" rows="8"
class="w-full p-4 font-mono text-sm text-slate-700 bg-white border-0 focus:ring-0 resize-y outline-none leading-relaxed"
placeholder="请输入 Prompt 内容..."></textarea>
</div>
</div>
</div>
<!-- GPU Monitor Tab -->
<div v-if="currentTab === 'gpu'" key="gpu" class="space-y-6">
<div v-if="!gpuStatus.available" class="bg-red-50 text-red-600 p-6 rounded-2xl border border-red-100 flex items-center gap-4">
<i class="fas fa-exclamation-triangle text-2xl"></i>
<div>
<h3 class="font-bold text-lg">无法获取 GPU 信息</h3>
<p class="text-sm opacity-80">{{ gpuStatus.error || '未检测到 NVIDIA GPU 或 nvidia-smi 不可用' }}</p>
</div>
</div>
<div v-else class="space-y-6">
<!-- Top Stats Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- GPU Utilization -->
<div class="bg-slate-900 text-white rounded-2xl p-6 shadow-lg relative overflow-hidden group">
<div class="absolute top-0 right-0 w-24 h-24 bg-blue-500 rounded-full blur-3xl opacity-20 group-hover:opacity-30 transition-opacity"></div>
<div class="relative z-10">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-white/10 rounded-lg backdrop-blur-sm">
<i class="fas fa-microchip text-blue-400"></i>
</div>
<span class="text-xs font-mono text-slate-400 bg-white/5 px-2 py-0.5 rounded">Core Load</span>
</div>
<h3 class="text-3xl font-bold font-mono">{{ gpuStatus.gpu_util }}<span class="text-sm text-slate-400 ml-1">%</span></h3>
<div class="w-full bg-white/10 rounded-full h-1.5 mt-4 overflow-hidden">
<div class="bg-blue-500 h-1.5 rounded-full transition-all duration-500" :style="{ width: gpuStatus.gpu_util + '%' }"></div>
</div>
</div>
</div>
<!-- Memory Utilization -->
<div class="bg-slate-900 text-white rounded-2xl p-6 shadow-lg relative overflow-hidden group">
<div class="absolute top-0 right-0 w-24 h-24 bg-purple-500 rounded-full blur-3xl opacity-20 group-hover:opacity-30 transition-opacity"></div>
<div class="relative z-10">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-white/10 rounded-lg backdrop-blur-sm">
<i class="fas fa-memory text-purple-400"></i>
</div>
<span class="text-xs font-mono text-slate-400 bg-white/5 px-2 py-0.5 rounded">VRAM</span>
</div>
<h3 class="text-3xl font-bold font-mono">{{ (gpuStatus.mem_used / 1024).toFixed(2) }}<span class="text-sm text-slate-400 ml-1">GB</span></h3>
<p class="text-xs text-slate-400 mt-1">of {{ (gpuStatus.mem_total / 1024).toFixed(2) }} GB</p>
<div class="w-full bg-white/10 rounded-full h-1.5 mt-3 overflow-hidden">
<div class="bg-purple-500 h-1.5 rounded-full transition-all duration-500" :style="{ width: (gpuStatus.mem_used / gpuStatus.mem_total * 100) + '%' }"></div>
</div>
</div>
</div>
<!-- Temperature -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 relative overflow-hidden group hover:border-orange-200 transition-colors">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-orange-50 rounded-lg text-orange-500">
<i class="fas fa-thermometer-half"></i>
</div>
<span class="text-xs font-mono text-slate-400 bg-slate-100 px-2 py-0.5 rounded">Temp</span>
</div>
<h3 class="text-3xl font-bold font-mono text-slate-800">{{ gpuStatus.temperature }}<span class="text-sm text-slate-400 ml-1">°C</span></h3>
<div class="mt-4 flex items-center gap-2 text-xs">
<span class="px-2 py-0.5 rounded" :class="gpuStatus.temperature > 80 ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'">
{{ gpuStatus.temperature > 80 ? 'High' : 'Normal' }}
</span>
</div>
</div>
<!-- Power Usage -->
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 relative overflow-hidden group hover:border-green-200 transition-colors">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-green-50 rounded-lg text-green-500">
<i class="fas fa-bolt"></i>
</div>
<span class="text-xs font-mono text-slate-400 bg-slate-100 px-2 py-0.5 rounded">Power</span>
</div>
<h3 class="text-3xl font-bold font-mono text-slate-800">{{ gpuStatus.power_draw }}<span class="text-sm text-slate-400 ml-1">W</span></h3>
<p class="text-xs text-slate-400 mt-1">Limit: {{ gpuStatus.power_limit }} W</p>
<div class="w-full bg-slate-100 rounded-full h-1.5 mt-3 overflow-hidden">
<div class="bg-green-500 h-1.5 rounded-full transition-all duration-500" :style="{ width: (gpuStatus.power_draw / gpuStatus.power_limit * 100) + '%' }"></div>
</div>
</div>
</div>
<!-- Real-time Charts -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-800 mb-6 flex items-center gap-2">
<span class="w-1 h-5 bg-blue-500 rounded-full"></span>
GPU & Memory Utilization
</h3>
<div ref="gpuUtilChartRef" class="chart-container" style="height: 300px;"></div>
</div>
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<h3 class="font-bold text-slate-800 mb-6 flex items-center gap-2">
<span class="w-1 h-5 bg-orange-500 rounded-full"></span>
Temperature & Power
</h3>
<div ref="gpuTempChartRef" class="chart-container" style="height: 300px;"></div>
</div>
</div>
<!-- Device Details Table -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50">
<h3 class="font-bold text-slate-800">设备详细信息</h3>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 divide-y md:divide-y-0 md:divide-x divide-slate-100">
<div class="p-6">
<div class="text-xs text-slate-400 uppercase font-bold tracking-wider mb-1">Product Name</div>
<div class="text-lg font-bold text-slate-800 font-mono">{{ gpuStatus.name }}</div>
</div>
<div class="p-6">
<div class="text-xs text-slate-400 uppercase font-bold tracking-wider mb-1">Driver Version</div>
<div class="text-lg font-bold text-slate-800 font-mono">{{ gpuStatus.driver_version }}</div>
</div>
<div class="p-6">
<div class="text-xs text-slate-400 uppercase font-bold tracking-wider mb-1">CUDA Version</div>
<div class="text-lg font-bold text-slate-800 font-mono">{{ gpuStatus.cuda_version }}</div>
</div>
<div class="p-6">
<div class="text-xs text-slate-400 uppercase font-bold tracking-wider mb-1">Data Source</div>
<div class="text-lg font-bold text-slate-800 font-mono flex items-center gap-2">
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
{{ gpuStatus.source }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div v-if="currentTab === 'settings'" key="settings" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Cleanup Config -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<div class="flex items-center gap-3 mb-6">
<div class="w-10 h-10 rounded-full bg-orange-50 flex items-center justify-center text-orange-500">
<i class="fas fa-broom"></i>
</div>
<h3 class="text-lg font-bold text-slate-800">自动清理策略</h3>
</div>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<label class="font-medium text-slate-700">启用自动清理</label>
<p class="text-xs text-slate-400">定时删除过期的结果文件</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" v-model="cleanupConfig.enabled" class="sr-only peer">
<div class="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-100 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
<div class="space-y-2">
<div class="flex justify-between text-sm">
<label class="text-slate-600">文件保留时长 (秒)</label>
<span class="text-blue-600 font-mono bg-blue-50 px-2 rounded">{{ (cleanupConfig.lifetime / 3600).toFixed(1) }}h</span>
</div>
<input type="range" v-model.number="cleanupConfig.lifetime" min="60" max="86400" step="60" class="w-full h-2 bg-slate-200 rounded-lg appearance-none cursor-pointer accent-blue-600">
<div class="flex justify-between text-xs text-slate-400">
<span>1分钟</span>
<span>24小时</span>
</div>
</div>
<div class="pt-4 flex gap-3">
<button @click="saveCleanupConfig" class="flex-1 bg-slate-900 hover:bg-slate-800 text-white font-medium py-2.5 rounded-xl transition shadow-lg shadow-slate-200">
保存配置
</button>
<button @click="triggerCleanup" :disabled="cleaning" class="flex-1 bg-orange-100 hover:bg-orange-200 text-orange-700 font-medium py-2.5 rounded-xl transition flex items-center justify-center gap-2">
<i class="fas fa-recycle" :class="{'fa-spin': cleaning}"></i> {{ cleaning ? '清理中' : '立即清理' }}
</button>
</div>
</div>
</div>
<!-- Model Config -->
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
<div class="flex items-center gap-3 mb-6">
<div class="w-10 h-10 rounded-full bg-purple-50 flex items-center justify-center text-purple-500">
<i class="fas fa-server"></i>
</div>
<h3 class="text-lg font-bold text-slate-800">模型与环境</h3>
</div>
<div class="space-y-5">
<div>
<label class="block text-sm font-medium text-slate-700 mb-2">多模态模型版本</label>
<div class="relative">
<select v-model="currentModel" @change="updateModel" class="w-full appearance-none bg-slate-50 border border-slate-200 text-slate-700 py-3 px-4 pr-8 rounded-xl focus:outline-none focus:ring-2 focus:ring-purple-200 focus:border-purple-500 font-mono text-sm">
<option v-for="model in availableModels" :key="model" :value="model">{{ model }}</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center px-2 text-slate-500">
<i class="fas fa-chevron-down text-xs"></i>
</div>
</div>
<p class="text-xs text-slate-400 mt-2">切换模型可能会影响识别速度和准确率。</p>
</div>
<div class="p-4 bg-slate-50 rounded-xl border border-slate-100">
<div class="flex justify-between items-center mb-2">
<span class="text-xs font-bold text-slate-500 uppercase">运行设备</span>
<span class="text-xs bg-slate-200 text-slate-600 px-2 py-0.5 rounded font-mono">{{ deviceInfo }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-xs font-bold text-slate-500 uppercase">客户端时间</span>
<span class="text-xs font-mono text-slate-600">{{ new Date().toLocaleTimeString() }}</span>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</main>
</div>
<!-- Image Preview Modal -->
<transition name="fade">
<div v-if="previewUrl" class="fixed inset-0 z-[60] flex items-center justify-center bg-black/95 backdrop-blur-sm" @click="previewUrl = null">
<div class="relative max-w-7xl max-h-screen p-4 flex flex-col items-center">
<img :src="previewUrl" class="max-h-[85vh] max-w-full rounded-lg shadow-2xl border border-white/10" @click.stop>
<div class="mt-4 flex gap-4">
<a :href="previewUrl" download class="bg-white/10 hover:bg-white/20 text-white px-6 py-2 rounded-full transition backdrop-blur-md flex items-center gap-2" @click.stop>
<i class="fas fa-download"></i> 下载原图
</a>
<button class="bg-white/10 hover:bg-white/20 text-white px-6 py-2 rounded-full transition backdrop-blur-md" @click="previewUrl = null">
关闭预览
</button>
</div>
</div>
</div>
</transition>
</div>
<script>
const { createApp, ref, onMounted, watch, nextTick, computed } = Vue;
createApp({
setup() {
// State
const isLoggedIn = ref(false);
const password = ref('');
const loginError = ref('');
const currentTab = ref('dashboard');
const history = ref([]);
const files = ref([]);
const currentPath = ref('');
const previewUrl = ref(null);
const cleaning = ref(false);
const isLoading = ref(false);
const deviceInfo = ref('Loading...');
const currentModel = ref('');
const availableModels = ref([]);
const prompts = ref({});
const cleanupConfig = ref({ enabled: true, lifetime: 3600, interval: 600 });
const gpuStatus = ref({});
const gpuHistory = ref([]);
let gpuInterval = null;
// Filters
const selectedTimeRange = ref('all');
const selectedType = ref('all');
// Charts Refs
const barChartRef = ref(null);
const pieChartRef = ref(null);
const promptPieChartRef = ref(null);
const promptBarChartRef = ref(null);
const wordCloudRef = ref(null);
const gpuUtilChartRef = ref(null);
const gpuTempChartRef = ref(null);
let barChartInst = null;
let pieChartInst = null;
let promptPieChartInst = null;
let promptBarChartInst = null;
let wordCloudInst = null;
let gpuUtilChartInst = null;
let gpuTempChartInst = null;
// Computed Properties
const uniqueTypes = computed(() => {
const types = new Set(history.value.map(h => h.type));
return Array.from(types);
});
const filteredHistory = computed(() => {
let filtered = history.value;
const now = Date.now() / 1000; // current timestamp in seconds
// Filter by Time Range
if (selectedTimeRange.value !== 'all') {
let cutoff = 0;
if (selectedTimeRange.value === '24h') cutoff = now - 24 * 3600;
if (selectedTimeRange.value === '7d') cutoff = now - 7 * 24 * 3600;
if (selectedTimeRange.value === '30d') cutoff = now - 30 * 24 * 3600;
filtered = filtered.filter(h => h.timestamp >= cutoff);
}
// Filter by Type
if (selectedType.value !== 'all') {
filtered = filtered.filter(h => h.type === selectedType.value);
}
return filtered;
});
const stats = computed(() => {
const data = filteredHistory.value;
if (!data.length) return { totalCount: 0, successRate: 0, avgDuration: 0, todayCount: 0, failCount: 0, maxDuration: 0, lastActivity: '-' };
const total = data.length;
const success = data.filter(h => h.status === 'success').length;
const fail = total - success;
const totalDuration = data.reduce((acc, curr) => acc + (curr.duration || 0), 0);
const countWithDuration = data.filter(h => h.duration).length;
const maxDuration = data.reduce((max, curr) => Math.max(max, curr.duration || 0), 0);
// Today's Count
const todayStart = new Date();
todayStart.setHours(0,0,0,0);
const todayTs = todayStart.getTime() / 1000;
const todayCount = data.filter(h => h.timestamp >= todayTs).length;
// Last Activity
const lastTs = data.length > 0 ? Math.max(...data.map(h => h.timestamp)) : 0;
const lastActivity = lastTs ? new Date(lastTs * 1000).toLocaleTimeString() : '-';
return {
totalCount: total,
successRate: total ? Math.round((success / total) * 100) : 0,
avgDuration: countWithDuration ? (totalDuration / countWithDuration).toFixed(2) : 0,
todayCount,
failCount: fail,
maxDuration: maxDuration.toFixed(2),
lastActivity
};
});
const promptStats = computed(() => {
const counts = {};
filteredHistory.value.forEach(h => {
if (h.prompt) {
const p = h.prompt.trim();
counts[p] = (counts[p] || 0) + 1;
}
});
// Sort by count desc
const sorted = Object.entries(counts)
.sort((a, b) => b[1] - a[1])
.slice(0, 10); // Top 10
return sorted.map(([name, value]) => ({ name, value }));
});
// --- Auth ---
const checkLogin = () => {
const token = localStorage.getItem('admin_token');
if (token) {
isLoggedIn.value = true;
initData();
}
};
const login = async () => {
if (!password.value) return;
try {
const formData = new FormData();
formData.append('password', password.value);
const res = await axios.post('/admin/login', formData);
if (res.data.status === 'success') {
localStorage.setItem('admin_token', 'logged_in');
isLoggedIn.value = true;
loginError.value = '';
initData();
}
} catch (e) {
loginError.value = '密码错误,请重试';
}
};
const logout = () => {
localStorage.removeItem('admin_token');
document.cookie = "admin_session=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
isLoggedIn.value = false;
password.value = '';
};
// --- Data Fetching ---
const initData = () => {
fetchHistory();
fetchSystemInfo();
fetchPrompts();
};
const refreshData = () => {
isLoading.value = true;
Promise.all([fetchHistory(), fetchFiles(), fetchSystemInfo()])
.finally(() => setTimeout(() => isLoading.value = false, 500));
};
const fetchHistory = async () => {
try {
const res = await axios.get('/admin/api/history');
history.value = res.data.reverse();
if (currentTab.value === 'dashboard') {
nextTick(initCharts);
}
} catch (e) {
if (e.response && e.response.status === 401) logout();
}
};
const fetchFiles = async () => {
try {
const res = await axios.get(`/admin/api/files?path=${currentPath.value}`);
files.value = res.data;
} catch (e) { console.error(e); }
};
const fetchSystemInfo = async () => {
try {
const res = await axios.get('/admin/api/config');
deviceInfo.value = res.data.device;
currentModel.value = res.data.current_qwen_model;
availableModels.value = res.data.available_qwen_models;
if (res.data.cleanup_config) cleanupConfig.value = res.data.cleanup_config;
if (res.data.gpu_status) gpuStatus.value = res.data.gpu_status;
} catch (e) { console.error(e); }
};
const fetchPrompts = async () => {
try {
const res = await axios.get('/admin/api/prompts');
prompts.value = res.data;
} catch (e) { console.error(e); }
};
// --- GPU Monitoring ---
const fetchGpuStatus = async () => {
try {
const res = await axios.get('/admin/api/gpu/status');
const data = res.data;
gpuStatus.value = data;
// Update History
const now = new Date();
const timeStr = now.toLocaleTimeString();
if (gpuHistory.value.length > 60) gpuHistory.value.shift();
gpuHistory.value.push({
time: timeStr,
gpu_util: data.gpu_util,
mem_util: data.mem_util,
temp: data.temperature,
power: data.power_draw
});
updateGpuCharts();
} catch (e) { console.error(e); }
};
const startGpuMonitoring = () => {
if (gpuInterval) clearInterval(gpuInterval);
// Use setTimeout to ensure DOM is ready after v-if switch
setTimeout(() => {
initGpuCharts();
fetchGpuStatus(); // Initial fetch
gpuInterval = setInterval(fetchGpuStatus, 2000); // Every 2s
}, 300);
};
const stopGpuMonitoring = () => {
if (gpuInterval) {
clearInterval(gpuInterval);
gpuInterval = null;
}
};
const initGpuCharts = () => {
if (!gpuUtilChartRef.value || !gpuTempChartRef.value) return;
const commonOption = {
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'category', boundaryGap: false, data: [] },
yAxis: { type: 'value', min: 0, max: 100 },
animation: false
};
// Util Chart
if (gpuUtilChartInst) gpuUtilChartInst.dispose();
gpuUtilChartInst = echarts.init(gpuUtilChartRef.value);
gpuUtilChartInst.setOption({
...commonOption,
legend: { data: ['GPU Core', 'Memory Controller'] },
series: [
{ name: 'GPU Core', type: 'line', smooth: true, showSymbol: false, areaStyle: { opacity: 0.1 }, itemStyle: { color: '#3b82f6' }, data: [] },
{ name: 'Memory Controller', type: 'line', smooth: true, showSymbol: false, areaStyle: { opacity: 0.1 }, itemStyle: { color: '#a855f7' }, data: [] }
]
});
// Temp/Power Chart
if (gpuTempChartInst) gpuTempChartInst.dispose();
gpuTempChartInst = echarts.init(gpuTempChartRef.value);
gpuTempChartInst.setOption({
tooltip: { trigger: 'axis' },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
legend: { data: ['Temperature (°C)', 'Power (W)'] },
xAxis: { type: 'category', boundaryGap: false, data: [] },
yAxis: [
{ type: 'value', name: 'Temp', min: 0, max: 100, position: 'left' },
{ type: 'value', name: 'Power', min: 0, position: 'right' }
],
animation: false,
series: [
{ name: 'Temperature (°C)', type: 'line', smooth: true, showSymbol: false, itemStyle: { color: '#f97316' }, yAxisIndex: 0, data: [] },
{ name: 'Power (W)', type: 'line', smooth: true, showSymbol: false, areaStyle: { opacity: 0.1 }, itemStyle: { color: '#22c55e' }, yAxisIndex: 1, data: [] }
]
});
// Render initial data if available
updateGpuCharts();
};
const updateGpuCharts = () => {
if (!gpuUtilChartInst || !gpuTempChartInst) return;
const times = gpuHistory.value.map(h => h.time);
const gpuUtils = gpuHistory.value.map(h => h.gpu_util);
const memUtils = gpuHistory.value.map(h => h.mem_util);
const temps = gpuHistory.value.map(h => h.temp);
const powers = gpuHistory.value.map(h => h.power);
gpuUtilChartInst.setOption({
xAxis: { data: times },
series: [
{ data: gpuUtils },
{ data: memUtils }
]
});
gpuTempChartInst.setOption({
xAxis: { data: times },
series: [
{ data: temps },
{ data: powers }
]
});
};
// --- Actions ---
const saveCleanupConfig = async () => {
try {
const formData = new FormData();
formData.append('enabled', cleanupConfig.value.enabled);
formData.append('lifetime', cleanupConfig.value.lifetime);
formData.append('interval', cleanupConfig.value.interval);
const res = await axios.post('/admin/api/config/cleanup', formData);
alert(res.data.message);
} catch (e) { alert('保存失败'); }
};
const savePrompt = async (key) => {
try {
const formData = new FormData();
formData.append('key', key);
formData.append('content', prompts.value[key]);
const res = await axios.post('/admin/api/prompts', formData);
alert(res.data.message);
} catch (e) { alert('保存失败'); }
};
const updateModel = async () => {
if(!confirm(`确定要切换模型到 ${currentModel.value} 吗?`)) return;
try {
const formData = new FormData();
formData.append('model', currentModel.value);
const res = await axios.post('/admin/api/config/model', formData);
alert(res.data.message);
} catch (e) {
alert('更新失败');
fetchSystemInfo();
}
};
const triggerCleanup = async () => {
cleaning.value = true;
try {
const res = await axios.post('/admin/api/cleanup');
alert(res.data.message);
if (currentTab.value === 'files') fetchFiles();
} catch (e) { alert('清理失败'); }
finally { cleaning.value = false; }
};
const deleteFile = async (name) => {
if (!confirm(`确定要删除 ${name} 吗?`)) return;
try {
const fullPath = currentPath.value ? `${currentPath.value}/${name}` : name;
await axios.delete(`/admin/api/files/${fullPath}`);
fetchFiles();
} catch (e) { alert('删除失败: ' + e.message); }
};
// --- Navigation & Helpers ---
const switchTab = (tab) => {
const prevTab = currentTab.value;
currentTab.value = tab;
// GPU Monitor Logic
if (tab === 'gpu') {
startGpuMonitoring();
} else if (prevTab === 'gpu') {
stopGpuMonitoring();
}
if (tab === 'files') fetchFiles();
if (tab === 'dashboard') {
fetchHistory();
// Wait for transition to complete before initializing charts
// CSS transition is around 0.3s-0.4s
setTimeout(() => {
nextTick(initCharts);
}, 400);
}
};
const enterDir = (dirName) => {
currentPath.value = currentPath.value ? `${currentPath.value}/${dirName}` : dirName;
fetchFiles();
};
const navigateUp = () => {
if (!currentPath.value) return;
const parts = currentPath.value.split('/');
parts.pop();
currentPath.value = parts.join('/');
fetchFiles();
};
const viewResult = (path) => {
switchTab('files');
let relativePath = path.startsWith('results/') ? path.substring(8) : path;
const isFile = /\.[a-zA-Z0-9]+$/.test(relativePath);
if (isFile) {
const lastSlashIndex = relativePath.lastIndexOf('/');
currentPath.value = lastSlashIndex !== -1 ? relativePath.substring(0, lastSlashIndex) : '';
fetchFiles();
setTimeout(() => previewUrl.value = '/static/' + path, 500);
} else {
currentPath.value = relativePath;
fetchFiles();
}
};
const previewImage = (path) => previewUrl.value = path;
const isImage = (name) => /\.(jpg|jpeg|png|gif|webp)$/i.test(name);
const formatDate = (ts) => new Date(ts * 1000).toLocaleString();
const formatBytes = (bytes, decimals = 2) => {
if (bytes === 0) return '0 B';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const getPromptDescription = (key) => {
const map = {
'translate': 'Prompt 翻译 (CN->EN)',
'tarot_card_dual': '塔罗牌识别 (对比模式)',
'tarot_card_single': '塔罗牌识别 (单图模式)',
'tarot_spread': '塔罗牌阵识别',
'face_analysis': '人脸/属性分析'
};
return map[key] || key;
};
const getTypeBadgeClass = (type) => {
const map = {
'general': 'bg-blue-50 text-blue-600 border-blue-200',
'tarot': 'bg-purple-50 text-purple-600 border-purple-200',
'tarot-recognize': 'bg-indigo-50 text-indigo-600 border-indigo-200',
'face': 'bg-pink-50 text-pink-600 border-pink-200'
};
return map[type] || 'bg-slate-100 text-slate-600 border-slate-200';
};
const getPageTitle = (tab) => {
const map = {
'dashboard': '数据看板',
'history': '识别记录',
'files': '文件资源管理',
'prompts': '提示词工程',
'settings': '全局配置',
'gpu': 'GPU 监控'
};
return map[tab];
};
const getPageSubtitle = (tab) => {
const map = {
'dashboard': '系统运行状态与核心指标概览',
'history': '所有视觉识别任务的历史流水',
'files': '查看和管理生成的图像及JSON结果',
'prompts': '调整各个识别场景的 System Prompt',
'settings': '系统环境参数与自动清理策略',
'gpu': '实时监控 GPU 核心、显存、温度与功耗'
};
return map[tab];
};
// --- Charts ---
const initCharts = () => {
const data = filteredHistory.value;
// 1. Common Chart Config
const commonGrid = { left: '3%', right: '4%', bottom: '3%', containLabel: true };
// 2. Bar Chart Data (Group by Date)
const dateCounts = {};
// Sort by timestamp asc for chart
const sortedHistory = [...data].sort((a, b) => a.timestamp - b.timestamp);
sortedHistory.forEach(h => {
const date = new Date(h.timestamp * 1000).toLocaleDateString();
dateCounts[date] = (dateCounts[date] || 0) + 1;
});
if (barChartRef.value) {
if (barChartInst) barChartInst.dispose();
barChartInst = echarts.init(barChartRef.value);
barChartInst.setOption({
tooltip: { trigger: 'axis' },
grid: commonGrid,
xAxis: { type: 'category', data: Object.keys(dateCounts) },
yAxis: { type: 'value' },
series: [{
data: Object.values(dateCounts),
type: 'bar',
barWidth: '40%',
itemStyle: { color: '#3b82f6', borderRadius: [4, 4, 0, 0] }
}]
});
}
// 3. Pie Chart (Type Distribution)
const typeCounts = {};
data.forEach(h => { typeCounts[h.type] = (typeCounts[h.type] || 0) + 1; });
const pieData = Object.keys(typeCounts).map(k => ({ value: typeCounts[k], name: k }));
if (pieChartRef.value) {
if (pieChartInst) pieChartInst.dispose();
pieChartInst = echarts.init(pieChartRef.value);
pieChartInst.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: '0%' },
series: [{
type: 'pie',
radius: ['40%', '70%'],
itemStyle: { borderRadius: 5, borderColor: '#fff', borderWidth: 2 },
data: pieData
}]
});
}
// 4. Prompt Charts
const topPrompts = promptStats.value;
// Prompt Pie
if (promptPieChartRef.value) {
if (promptPieChartInst) promptPieChartInst.dispose();
promptPieChartInst = echarts.init(promptPieChartRef.value);
promptPieChartInst.setOption({
tooltip: {
trigger: 'item',
formatter: function(params) {
const val = params.data.name;
const shortName = val.length > 50 ? val.substring(0, 50) + '...' : val;
return `${params.marker} ${shortName}<br/><b>${params.value} 次</b> (${params.percent}%)`;
}
},
legend: { show: false }, // Too long for legend
series: [{
type: 'pie',
radius: '70%',
center: ['50%', '50%'],
data: topPrompts,
itemStyle: { borderRadius: 5 }
}]
});
}
// Prompt Bar (Horizontal)
if (promptBarChartRef.value) {
if (promptBarChartInst) promptBarChartInst.dispose();
promptBarChartInst = echarts.init(promptBarChartRef.value);
promptBarChartInst.setOption({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: topPrompts.map(p => p.name.length > 15 ? p.name.substring(0, 15) + '...' : p.name),
axisLabel: { interval: 0 }
},
series: [{
type: 'bar',
data: topPrompts.map(p => p.value),
itemStyle: { color: '#f97316', borderRadius: [0, 4, 4, 0] },
label: { show: true, position: 'right' }
}]
});
}
// 5. Word Cloud
if (wordCloudRef.value) {
if (wordCloudInst) wordCloudInst.dispose();
wordCloudInst = echarts.init(wordCloudRef.value);
// Calculate word frequencies
const wordCounts = {};
data.forEach(h => {
if (h.prompt) {
// Split by space and common separators
const words = h.prompt.split(/[\s,.。!?]+/);
words.forEach(w => {
w = w.trim();
if (w.length > 1 && !['the', 'a', 'an', 'in', 'on', 'at', 'to', 'for', 'of', 'and', 'or', 'with', 'is', 'are'].includes(w.toLowerCase())) {
wordCounts[w] = (wordCounts[w] || 0) + 1;
}
});
}
});
const cloudData = Object.entries(wordCounts)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 100);
wordCloudInst.setOption({
tooltip: { show: true },
series: [{
type: 'wordCloud',
shape: 'circle',
left: 'center',
top: 'center',
width: '90%',
height: '90%',
sizeRange: [12, 60],
rotationRange: [-90, 90],
rotationStep: 45,
gridSize: 8,
drawOutOfBound: false,
layoutAnimation: true,
textStyle: {
fontFamily: 'sans-serif',
fontWeight: 'bold',
color: function () {
return 'rgb(' + [
Math.round(Math.random() * 160),
Math.round(Math.random() * 160),
Math.round(Math.random() * 160)
].join(',') + ')';
}
},
emphasis: {
focus: 'self',
textStyle: {
shadowBlur: 10,
shadowColor: '#333'
}
},
data: cloudData
}]
});
}
};
// Watchers for filtering
watch([selectedTimeRange, selectedType], () => {
nextTick(initCharts);
});
// Watch window resize for charts
window.addEventListener('resize', () => {
barChartInst && barChartInst.resize();
pieChartInst && pieChartInst.resize();
promptPieChartInst && promptPieChartInst.resize();
promptBarChartInst && promptBarChartInst.resize();
wordCloudInst && wordCloudInst.resize();
gpuUtilChartInst && gpuUtilChartInst.resize();
gpuTempChartInst && gpuTempChartInst.resize();
});
onMounted(() => {
checkLogin();
});
return {
isLoggedIn, password, loginError, login, logout,
currentTab, switchTab, history, files, currentPath,
enterDir, navigateUp, deleteFile, triggerCleanup,
viewResult, previewImage, isImage, previewUrl,
formatDate, getTypeBadgeClass, cleaning, deviceInfo,
currentModel, availableModels, updateModel,
cleanupConfig, saveCleanupConfig,
prompts, savePrompt, getPromptDescription,
getPageTitle, getPageSubtitle,
isLoading, refreshData,
// New/Updated exports
stats, filteredHistory, uniqueTypes,
selectedTimeRange, selectedType,
barChartRef, pieChartRef, promptPieChartRef, promptBarChartRef, wordCloudRef,
formatBytes, gpuStatus,
gpuUtilChartRef, gpuTempChartRef
};
}
}).mount('#app');
</script>
</body>
</html>