1986 lines
126 KiB
HTML
1986 lines
126 KiB
HTML
<!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">
|
||
|
||
<!-- Mobile Overlay -->
|
||
<transition name="fade">
|
||
<div v-if="isSidebarOpen" @click="isSidebarOpen = false" class="fixed inset-0 bg-slate-900/50 z-40 lg:hidden backdrop-blur-sm"></div>
|
||
</transition>
|
||
|
||
<!-- Sidebar -->
|
||
<aside :class="[
|
||
'fixed inset-y-0 left-0 z-50 w-72 bg-white border-r border-slate-200 flex flex-col shadow-sm transition-transform duration-300 ease-in-out lg:static lg:translate-x-0',
|
||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||
]">
|
||
<!-- Close Button (Mobile Only) -->
|
||
<button @click="isSidebarOpen = false" class="absolute top-4 right-4 lg:hidden text-slate-400 hover:text-slate-600 p-2 rounded-full hover:bg-slate-100 transition-colors z-20">
|
||
<i class="fas fa-times text-lg"></i>
|
||
</button>
|
||
|
||
<div class="p-8 pb-4 relative">
|
||
<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-xs 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('tarot')" :class="{ 'active': currentTab === 'tarot' }"
|
||
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-star w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'tarot' ? 'text-blue-600' : 'text-slate-400'"></i>
|
||
<span class="font-medium">塔罗牌识别</span>
|
||
</a>
|
||
<a href="#" @click.prevent="switchTab('segment')" :class="{ 'active': currentTab === 'segment' }"
|
||
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-crop-alt w-5 text-center transition-colors group-hover:text-blue-600" :class="currentTab === 'segment' ? '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-4 md:px-8 py-4 flex justify-between items-center shadow-sm transition-all">
|
||
<div class="flex items-center gap-3">
|
||
<button @click="isSidebarOpen = true" class="lg:hidden p-2 -ml-2 text-slate-500 hover:text-blue-600 hover:bg-slate-100 rounded-lg transition-colors active:scale-95">
|
||
<i class="fas fa-bars text-xl"></i>
|
||
</button>
|
||
<div>
|
||
<h1 class="text-xl md:text-2xl font-bold text-slate-800 transition-all">{{ getPageTitle(currentTab) }}</h1>
|
||
<p class="text-xs md:text-sm text-slate-500 mt-0.5 hidden sm:block">{{ getPageSubtitle(currentTab) }}</p>
|
||
</div>
|
||
</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-3 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-4 md:p-8 max-w-7xl mx-auto min-h-[calc(100vh-88px)] transition-all">
|
||
<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-xs 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>
|
||
|
||
<!-- Tarot Tab -->
|
||
<div v-if="currentTab === 'tarot'" key="tarot" class="space-y-6">
|
||
<!-- Input Section -->
|
||
<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 class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<!-- Image Upload -->
|
||
<div class="space-y-4">
|
||
<label class="block text-sm font-medium text-slate-700">上传图片</label>
|
||
<div class="flex items-center justify-center w-full">
|
||
<label for="dropzone-file" class="flex flex-col items-center justify-center w-full h-64 border-2 border-slate-300 border-dashed rounded-xl cursor-pointer bg-slate-50 hover:bg-slate-100 transition-colors relative overflow-hidden">
|
||
<div v-if="!tarotFile && !tarotImageUrl" class="flex flex-col items-center justify-center pt-5 pb-6">
|
||
<i class="fas fa-cloud-upload-alt text-4xl text-slate-400 mb-3"></i>
|
||
<p class="mb-2 text-sm text-slate-500"><span class="font-semibold">点击上传</span> 或拖拽文件到此处</p>
|
||
<p class="text-xs text-slate-400">支持 JPG, PNG (MAX. 10MB)</p>
|
||
</div>
|
||
<div v-else class="absolute inset-0 flex items-center justify-center bg-slate-100">
|
||
<img v-if="tarotPreview" :src="tarotPreview" class="max-h-full max-w-full object-contain">
|
||
<div v-else class="text-slate-500 flex flex-col items-center">
|
||
<i class="fas fa-link text-2xl mb-2"></i>
|
||
<span class="text-xs truncate max-w-[200px]">{{ tarotImageUrl }}</span>
|
||
</div>
|
||
<button @click.prevent="clearTarotInput" class="absolute top-2 right-2 bg-white/80 p-2.5 rounded-full hover:bg-white text-slate-600 shadow-sm">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
<input id="dropzone-file" type="file" class="hidden" accept="image/*" @change="handleTarotFileChange" />
|
||
</label>
|
||
</div>
|
||
|
||
<div class="relative">
|
||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||
<i class="fas fa-link text-slate-400"></i>
|
||
</div>
|
||
<input type="text" v-model="tarotImageUrl" @input="handleUrlInput"
|
||
class="bg-slate-50 border border-slate-200 text-slate-900 text-sm rounded-xl focus:ring-purple-500 focus:border-purple-500 block w-full pl-10 p-2.5 outline-none"
|
||
placeholder="或者输入图片 URL...">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings -->
|
||
<div class="space-y-6">
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-2">预期卡牌数量</label>
|
||
<div class="flex items-center gap-4">
|
||
<input type="number" v-model.number="tarotExpectedCount" min="1" max="10"
|
||
class="bg-slate-50 border border-slate-200 text-slate-900 text-sm rounded-xl focus:ring-purple-500 focus:border-purple-500 block w-full p-2.5 outline-none font-mono">
|
||
<span class="text-sm text-slate-500 whitespace-nowrap">张</span>
|
||
</div>
|
||
<p class="text-xs text-slate-400 mt-2">系统将尝试检测并分割指定数量的卡牌。如果检测数量不符,将返回错误提示。</p>
|
||
</div>
|
||
|
||
<div class="pt-4">
|
||
<button @click="recognizeTarot" :disabled="isRecognizing || (!tarotFile && !tarotImageUrl)"
|
||
class="w-full bg-gradient-to-r from-purple-600 to-indigo-600 hover:from-purple-700 hover:to-indigo-700 text-white font-bold py-3 px-4 rounded-xl shadow-lg shadow-purple-500/30 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||
<i class="fas fa-magic" :class="{'fa-spin': isRecognizing}"></i>
|
||
{{ isRecognizing ? '正在识别中...' : '开始识别 (Recognize)' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Results Section -->
|
||
<div v-if="tarotResult" class="space-y-6 animate-fade-in">
|
||
<!-- Status Banner -->
|
||
<div :class="tarotResult.status === 'success' ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700'"
|
||
class="p-4 rounded-xl border flex items-center gap-3 shadow-sm">
|
||
<i :class="tarotResult.status === 'success' ? 'fas fa-check-circle' : 'fas fa-exclamation-circle'" class="text-xl"></i>
|
||
<div>
|
||
<h4 class="font-bold">{{ tarotResult.status === 'success' ? '识别成功' : '识别失败' }}</h4>
|
||
<p class="text-sm opacity-90">{{ tarotResult.message }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Spread Info -->
|
||
<div v-if="tarotResult.spread_info" class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||
<h3 class="font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||
<i class="fas fa-layer-group text-purple-500"></i>
|
||
牌阵信息
|
||
</h3>
|
||
<div class="bg-purple-50 rounded-xl p-4 border border-purple-100">
|
||
<div class="flex flex-col md:flex-row gap-4">
|
||
<div class="md:w-1/3">
|
||
<span class="text-xs font-bold text-purple-400 uppercase tracking-wider">牌阵名称</span>
|
||
<div class="text-xl font-bold text-slate-800 mt-1">{{ tarotResult.spread_info.spread_name }}</div>
|
||
</div>
|
||
<div class="md:w-2/3 border-t md:border-t-0 md:border-l border-purple-200 pt-4 md:pt-0 md:pl-4">
|
||
<span class="text-xs font-bold text-purple-400 uppercase tracking-wider">描述 / 寓意</span>
|
||
<div class="text-sm text-slate-700 mt-1 leading-relaxed">{{ tarotResult.spread_info.description || '暂无描述' }}</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="tarotResult.spread_info.model_used" class="mt-3 pt-3 border-t border-purple-200 flex items-center gap-2">
|
||
<span class="text-xs bg-white text-purple-600 px-2 py-0.5 rounded border border-purple-200 font-mono">
|
||
<i class="fas fa-robot mr-1"></i>{{ tarotResult.spread_info.model_used }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Cards Grid -->
|
||
<div v-if="tarotResult.tarot_cards && tarotResult.tarot_cards.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
<div v-for="(card, index) in tarotResult.tarot_cards" :key="index" class="bg-white rounded-2xl shadow-sm border border-slate-100 overflow-hidden hover:shadow-md transition-shadow group">
|
||
<div class="relative aspect-[2/3] bg-slate-100 overflow-hidden cursor-pointer" @click="previewImage(card.url)">
|
||
<img :src="card.url" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500">
|
||
<div class="absolute top-2 right-2 bg-black/60 backdrop-blur-sm text-white text-xs font-bold px-2 py-1 rounded">
|
||
#{{ index + 1 }}
|
||
</div>
|
||
</div>
|
||
<div class="p-4 space-y-3">
|
||
<div class="flex justify-between items-start">
|
||
<div>
|
||
<h4 class="font-bold text-lg text-slate-800">{{ card.recognition?.name || '未知' }}</h4>
|
||
<div class="flex items-center gap-2 mt-1">
|
||
<span :class="card.recognition?.position === '正位' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
|
||
class="text-xs font-bold px-2 py-0.5 rounded">
|
||
{{ card.recognition?.position || '未知' }}
|
||
</span>
|
||
<span v-if="card.is_rotated" class="text-xs text-slate-400 bg-slate-100 px-1.5 rounded" title="已自动矫正方向">
|
||
<i class="fas fa-sync-alt"></i> Auto-Rotated
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="card.recognition?.model_used" class="pt-3 border-t border-slate-100 flex items-center justify-between text-xs">
|
||
<span class="text-slate-400">Model Used:</span>
|
||
<span class="font-mono text-purple-600 bg-purple-50 px-1.5 py-0.5 rounded border border-purple-100">
|
||
{{ card.recognition.model_used }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Full Visualization -->
|
||
<div v-if="tarotResult.full_visualization" class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||
<h3 class="font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||
<i class="fas fa-image text-blue-500"></i>
|
||
整体可视化结果
|
||
</h3>
|
||
<div class="rounded-xl overflow-hidden border border-slate-200 cursor-pointer" @click="previewImage(tarotResult.full_visualization)">
|
||
<img :src="tarotResult.full_visualization" class="w-full h-auto">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Segment Tab -->
|
||
<div v-if="currentTab === 'segment'" key="segment" class="space-y-6">
|
||
<!-- Input Section -->
|
||
<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>
|
||
通用图像分割任务
|
||
</h3>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<!-- Image Upload -->
|
||
<div class="space-y-4">
|
||
<label class="block text-sm font-medium text-slate-700">上传图片</label>
|
||
<div class="flex items-center justify-center w-full">
|
||
<label for="dropzone-segment-file" class="flex flex-col items-center justify-center w-full h-64 border-2 border-slate-300 border-dashed rounded-xl cursor-pointer bg-slate-50 hover:bg-slate-100 transition-colors relative overflow-hidden">
|
||
<div v-if="!segmentFile && !segmentImageUrl" class="flex flex-col items-center justify-center pt-5 pb-6">
|
||
<i class="fas fa-cloud-upload-alt text-4xl text-slate-400 mb-3"></i>
|
||
<p class="mb-2 text-sm text-slate-500"><span class="font-semibold">点击上传</span> 或拖拽文件到此处</p>
|
||
<p class="text-xs text-slate-400">支持 JPG, PNG (MAX. 10MB)</p>
|
||
</div>
|
||
<div v-else class="absolute inset-0 flex items-center justify-center bg-slate-100">
|
||
<img v-if="segmentPreview" :src="segmentPreview" class="max-h-full max-w-full object-contain">
|
||
<div v-else class="text-slate-500 flex flex-col items-center">
|
||
<i class="fas fa-link text-2xl mb-2"></i>
|
||
<span class="text-xs truncate max-w-[200px]">{{ segmentImageUrl }}</span>
|
||
</div>
|
||
<button @click.prevent="clearSegmentInput" class="absolute top-2 right-2 bg-white/80 p-2.5 rounded-full hover:bg-white text-slate-600 shadow-sm">
|
||
<i class="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
<input id="dropzone-segment-file" type="file" class="hidden" accept="image/*" @change="handleSegmentFileChange" />
|
||
</label>
|
||
</div>
|
||
|
||
<div class="relative">
|
||
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
||
<i class="fas fa-link text-slate-400"></i>
|
||
</div>
|
||
<input type="text" v-model="segmentImageUrl" @input="handleSegmentUrlInput"
|
||
class="bg-slate-50 border border-slate-200 text-slate-900 text-sm rounded-xl focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 outline-none"
|
||
placeholder="或者输入图片 URL...">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Settings -->
|
||
<div class="space-y-6">
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-2">Prompt 提示词 <span class="text-red-500">*</span></label>
|
||
<input type="text" v-model="segmentPrompt"
|
||
class="bg-slate-50 border border-slate-200 text-slate-900 text-sm rounded-xl focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 outline-none"
|
||
placeholder="例如: cat, person, red car...">
|
||
<p class="text-xs text-slate-400 mt-2">支持中英文,后端会自动翻译。多个对象可用逗号分隔。</p>
|
||
</div>
|
||
|
||
<div>
|
||
<label class="block text-sm font-medium text-slate-700 mb-2">置信度阈值 ({{ segmentConfidence }})</label>
|
||
<input type="range" v-model.number="segmentConfidence" min="0" max="1" step="0.05" 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 mt-1">
|
||
<span>0.0</span>
|
||
<span>1.0</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<label class="flex items-center space-x-2 cursor-pointer">
|
||
<input type="checkbox" v-model="segmentOptions.save_segment_images" class="rounded text-blue-600 focus:ring-blue-500 border-gray-300">
|
||
<span class="text-sm text-slate-700">保存分割对象</span>
|
||
</label>
|
||
<label class="flex items-center space-x-2 cursor-pointer">
|
||
<input type="checkbox" v-model="segmentOptions.cutout" class="rounded text-blue-600 focus:ring-blue-500 border-gray-300">
|
||
<span class="text-sm text-slate-700">透明背景裁剪</span>
|
||
</label>
|
||
<label class="flex items-center space-x-2 cursor-pointer">
|
||
<input type="checkbox" v-model="segmentOptions.perspective_correction" class="rounded text-blue-600 focus:ring-blue-500 border-gray-300">
|
||
<span class="text-sm text-slate-700">透视矫正</span>
|
||
</label>
|
||
<label class="flex items-center space-x-2 cursor-pointer">
|
||
<input type="checkbox" v-model="segmentOptions.highlight" class="rounded text-blue-600 focus:ring-blue-500 border-gray-300">
|
||
<span class="text-sm text-slate-700">高亮主体</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="pt-4">
|
||
<button @click="performSegment" :disabled="isSegmenting || (!segmentFile && !segmentImageUrl) || !segmentPrompt"
|
||
class="w-full bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-bold py-3 px-4 rounded-xl shadow-lg shadow-blue-500/30 transition-all transform active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2">
|
||
<i class="fas fa-crop-alt" :class="{'fa-spin': isSegmenting}"></i>
|
||
{{ isSegmenting ? '正在分割中...' : '开始分割 (Segment)' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Results Section -->
|
||
<div v-if="segmentResult" class="space-y-6 animate-fade-in">
|
||
<!-- Status Banner -->
|
||
<div :class="segmentResult.status === 'success' ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700'"
|
||
class="p-4 rounded-xl border flex items-center gap-3 shadow-sm">
|
||
<i :class="segmentResult.status === 'success' ? 'fas fa-check-circle' : 'fas fa-exclamation-circle'" class="text-xl"></i>
|
||
<div>
|
||
<h4 class="font-bold">{{ segmentResult.status === 'success' ? '分割成功' : '分割失败' }}</h4>
|
||
<p class="text-sm opacity-90">{{ segmentResult.message }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Result Visualization -->
|
||
<div v-if="segmentResult.visualization_url" class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||
<h3 class="font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||
<i class="fas fa-image text-blue-500"></i>
|
||
可视化结果
|
||
</h3>
|
||
<div class="rounded-xl overflow-hidden border border-slate-200 cursor-pointer" @click="previewImage(segmentResult.visualization_url)">
|
||
<img :src="segmentResult.visualization_url" class="w-full h-auto">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Segmented Objects Grid -->
|
||
<div v-if="segmentResult.segments && segmentResult.segments.length > 0" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||
<div v-for="(seg, index) in segmentResult.segments" :key="index" class="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden hover:shadow-md transition-all group">
|
||
<div class="relative aspect-square bg-slate-100 overflow-hidden cursor-pointer" @click="seg.image_url ? previewImage(seg.image_url) : null">
|
||
<img v-if="seg.image_url" :src="seg.image_url" class="w-full h-full object-contain p-2 group-hover:scale-110 transition-transform duration-500">
|
||
<div v-else class="w-full h-full flex items-center justify-center text-slate-300">
|
||
<i class="fas fa-image text-2xl"></i>
|
||
</div>
|
||
<div class="absolute top-1 right-1 bg-black/60 text-white text-[10px] font-bold px-1.5 py-0.5 rounded">
|
||
{{ (seg.score * 100).toFixed(0) }}%
|
||
</div>
|
||
</div>
|
||
<div class="p-2 text-center">
|
||
<p class="text-xs font-medium text-slate-700 truncate" :title="seg.label">{{ seg.label }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- JSON Result -->
|
||
<div class="bg-white rounded-2xl shadow-sm border border-slate-100 p-6">
|
||
<h3 class="font-bold text-slate-800 mb-4 flex items-center gap-2">
|
||
<i class="fas fa-code text-slate-500"></i>
|
||
JSON 结果
|
||
</h3>
|
||
<pre class="bg-slate-50 text-slate-700 p-4 rounded-xl text-xs font-mono overflow-x-auto border border-slate-200">{{ JSON.stringify(segmentResult, null, 2) }}</pre>
|
||
</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-xs"></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-3 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-3 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-xs 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-xs 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-xs 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-8 h-8 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 isSidebarOpen = 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;
|
||
|
||
// Tarot State
|
||
const tarotFile = ref(null);
|
||
const tarotImageUrl = ref('');
|
||
const tarotExpectedCount = ref(3);
|
||
const tarotPreview = ref(null);
|
||
const tarotResult = ref(null);
|
||
const isRecognizing = ref(false);
|
||
|
||
// Segment State
|
||
const segmentFile = ref(null);
|
||
const segmentImageUrl = ref('');
|
||
const segmentPreview = ref(null);
|
||
const segmentPrompt = ref('');
|
||
const segmentConfidence = ref(0.7);
|
||
const segmentOptions = ref({
|
||
save_segment_images: false,
|
||
cutout: false,
|
||
perspective_correction: false,
|
||
highlight: false
|
||
});
|
||
const segmentResult = ref(null);
|
||
const isSegmenting = ref(false);
|
||
|
||
// 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 (wait for slide-up transition ~700ms)
|
||
setTimeout(() => {
|
||
initGpuCharts();
|
||
fetchGpuStatus(); // Initial fetch
|
||
gpuInterval = setInterval(fetchGpuStatus, 2000); // Every 2s
|
||
}, 800);
|
||
};
|
||
|
||
const stopGpuMonitoring = () => {
|
||
if (gpuInterval) {
|
||
clearInterval(gpuInterval);
|
||
gpuInterval = null;
|
||
}
|
||
// Dispose charts to free memory and avoid resize issues on re-init
|
||
if (gpuUtilChartInst) { gpuUtilChartInst.dispose(); gpuUtilChartInst = null; }
|
||
if (gpuTempChartInst) { gpuTempChartInst.dispose(); gpuTempChartInst = 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: [] }
|
||
]
|
||
});
|
||
|
||
// Explicitly resize to ensure correct rendering
|
||
gpuUtilChartInst.resize();
|
||
gpuTempChartInst.resize();
|
||
|
||
// Render initial data if available
|
||
updateGpuCharts();
|
||
};
|
||
|
||
const updateGpuCharts = () => {
|
||
// Lazy init check
|
||
if ((!gpuUtilChartInst || !gpuTempChartInst) && gpuUtilChartRef.value && gpuTempChartRef.value) {
|
||
initGpuCharts();
|
||
}
|
||
|
||
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); }
|
||
};
|
||
|
||
// --- Tarot Actions ---
|
||
const handleTarotFileChange = (event) => {
|
||
const file = event.target.files[0];
|
||
if (file) {
|
||
tarotFile.value = file;
|
||
tarotImageUrl.value = ''; // Clear URL if file is selected
|
||
tarotPreview.value = URL.createObjectURL(file);
|
||
tarotResult.value = null; // Clear previous result
|
||
}
|
||
};
|
||
|
||
const handleUrlInput = () => {
|
||
if (tarotImageUrl.value) {
|
||
tarotFile.value = null; // Clear file if URL is entered
|
||
tarotPreview.value = null; // Can't preview external URL easily without loading it, or just use the URL
|
||
// Simple preview for URL
|
||
tarotPreview.value = tarotImageUrl.value;
|
||
tarotResult.value = null;
|
||
} else {
|
||
tarotPreview.value = null;
|
||
}
|
||
};
|
||
|
||
const clearTarotInput = () => {
|
||
tarotFile.value = null;
|
||
tarotImageUrl.value = '';
|
||
tarotPreview.value = null;
|
||
tarotResult.value = null;
|
||
// Reset file input value
|
||
const fileInput = document.getElementById('dropzone-file');
|
||
if (fileInput) fileInput.value = '';
|
||
};
|
||
|
||
const recognizeTarot = async () => {
|
||
if (!tarotFile.value && !tarotImageUrl.value) return;
|
||
|
||
isRecognizing.value = true;
|
||
tarotResult.value = null;
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
if (tarotFile.value) {
|
||
formData.append('file', tarotFile.value);
|
||
} else {
|
||
formData.append('image_url', tarotImageUrl.value);
|
||
}
|
||
formData.append('expected_count', tarotExpectedCount.value);
|
||
|
||
const config = {
|
||
headers: {
|
||
'X-API-Key': '123quant-speed'
|
||
},
|
||
timeout: 120000
|
||
};
|
||
|
||
const res = await axios.post('/recognize_tarot', formData, config);
|
||
tarotResult.value = res.data;
|
||
|
||
} catch (e) {
|
||
console.error(e);
|
||
let msg = e.response?.data?.detail || e.message || '识别请求失败';
|
||
|
||
if (e.response && e.response.status === 504) {
|
||
msg = '请求超时 (504):大模型处理时间较长。后台可能仍在运行,请稍后在“识别记录”中刷新查看结果。';
|
||
} else if (e.code === 'ECONNABORTED') {
|
||
msg = '请求超时:网络连接中断或服务器响应过慢。请稍后重试。';
|
||
}
|
||
|
||
tarotResult.value = {
|
||
status: 'failed',
|
||
message: msg
|
||
};
|
||
} finally {
|
||
isRecognizing.value = false;
|
||
}
|
||
};
|
||
|
||
// --- Segment Actions ---
|
||
const handleSegmentFileChange = (event) => {
|
||
const file = event.target.files[0];
|
||
if (file) {
|
||
segmentFile.value = file;
|
||
segmentImageUrl.value = '';
|
||
segmentPreview.value = URL.createObjectURL(file);
|
||
segmentResult.value = null;
|
||
}
|
||
};
|
||
|
||
const handleSegmentUrlInput = () => {
|
||
if (segmentImageUrl.value) {
|
||
segmentFile.value = null;
|
||
segmentPreview.value = segmentImageUrl.value;
|
||
segmentResult.value = null;
|
||
} else {
|
||
segmentPreview.value = null;
|
||
}
|
||
};
|
||
|
||
const clearSegmentInput = () => {
|
||
segmentFile.value = null;
|
||
segmentImageUrl.value = '';
|
||
segmentPreview.value = null;
|
||
segmentResult.value = null;
|
||
const fileInput = document.getElementById('dropzone-segment-file');
|
||
if (fileInput) fileInput.value = '';
|
||
};
|
||
|
||
const performSegment = async () => {
|
||
if ((!segmentFile.value && !segmentImageUrl.value) || !segmentPrompt.value) return;
|
||
|
||
isSegmenting.value = true;
|
||
segmentResult.value = null;
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
if (segmentFile.value) {
|
||
formData.append('file', segmentFile.value);
|
||
} else {
|
||
formData.append('image_url', segmentImageUrl.value);
|
||
}
|
||
formData.append('prompt', segmentPrompt.value);
|
||
formData.append('confidence', segmentConfidence.value);
|
||
formData.append('save_segment_images', segmentOptions.value.save_segment_images);
|
||
formData.append('cutout', segmentOptions.value.cutout);
|
||
formData.append('perspective_correction', segmentOptions.value.perspective_correction);
|
||
formData.append('highlight', segmentOptions.value.highlight);
|
||
|
||
const config = {
|
||
headers: {
|
||
'X-API-Key': '123quant-speed'
|
||
},
|
||
timeout: 120000
|
||
};
|
||
|
||
const res = await axios.post('/segment', formData, config);
|
||
segmentResult.value = res.data;
|
||
|
||
} catch (e) {
|
||
console.error(e);
|
||
let msg = e.response?.data?.detail || e.message || '分割请求失败';
|
||
|
||
if (e.response && e.response.status === 504) {
|
||
msg = '请求超时 (504):处理时间较长。';
|
||
} else if (e.code === 'ECONNABORTED') {
|
||
msg = '请求超时:网络连接中断或服务器响应过慢。';
|
||
}
|
||
|
||
segmentResult.value = {
|
||
status: 'failed',
|
||
message: msg
|
||
};
|
||
} finally {
|
||
isSegmenting.value = false;
|
||
}
|
||
};
|
||
|
||
// --- Navigation & Helpers ---
|
||
const switchTab = (tab) => {
|
||
const prevTab = currentTab.value;
|
||
currentTab.value = tab;
|
||
isSidebarOpen.value = false; // Close sidebar on mobile
|
||
|
||
// 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': '数据看板',
|
||
'tarot': '塔罗牌识别',
|
||
'segment': '通用分割',
|
||
'history': '识别记录',
|
||
'files': '文件资源管理',
|
||
'prompts': '提示词工程',
|
||
'settings': '全局配置',
|
||
'gpu': 'GPU 监控'
|
||
};
|
||
return map[tab];
|
||
};
|
||
|
||
const getPageSubtitle = (tab) => {
|
||
const map = {
|
||
'dashboard': '系统运行状态与核心指标概览',
|
||
'tarot': 'SAM3 + Qwen-VL 联合识别与分割',
|
||
'segment': '基于文本提示的通用图像分割 (Grounded SAM)',
|
||
'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, isSidebarOpen, 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,
|
||
// Tarot
|
||
tarotFile, tarotImageUrl, tarotExpectedCount, tarotPreview, tarotResult, isRecognizing,
|
||
handleTarotFileChange, handleUrlInput, clearTarotInput, recognizeTarot,
|
||
// Segment
|
||
segmentFile, segmentImageUrl, segmentPreview, segmentPrompt, segmentConfidence, segmentOptions, segmentResult, isSegmenting,
|
||
handleSegmentFileChange, handleSegmentUrlInput, clearSegmentInput, performSegment
|
||
};
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|