904 lines
54 KiB
HTML
904 lines
54 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>
|
|
<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: #f3f4f6;
|
|
}
|
|
[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: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
.hover-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
/* Sidebar Link Active State */
|
|
.nav-link.active {
|
|
background: linear-gradient(90deg, rgba(59, 130, 246, 0.1) 0%, rgba(59, 130, 246, 0) 100%);
|
|
border-left: 3px solid #3b82f6;
|
|
color: #3b82f6;
|
|
}
|
|
.nav-link {
|
|
border-left: 3px solid transparent;
|
|
}
|
|
|
|
/* Chart Containers */
|
|
.chart-container {
|
|
position: relative;
|
|
height: 300px;
|
|
overflow: hidden;
|
|
}
|
|
</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">
|
|
<div class="text-center mb-8">
|
|
<div class="w-16 h-16 bg-blue-600 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-200 transition-all outline-none"
|
|
placeholder="请输入管理员密码">
|
|
</div>
|
|
<button @click="login"
|
|
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-4 rounded-xl shadow-lg shadow-blue-500/30 transition-all duration-300 transform active:scale-95">
|
|
进入系统
|
|
</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">
|
|
<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">
|
|
<i class="fas fa-layer-group text-white text-lg"></i>
|
|
</div>
|
|
<div>
|
|
<h2 class="font-bold text-lg leading-tight">SAM3 Admin</h2>
|
|
<p class="text-xs text-slate-400 font-medium">QUANT SPEED AI</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 px-2">主菜单</div>
|
|
<nav class="space-y-1">
|
|
<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-r-lg 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('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-r-lg 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-r-lg 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-r-lg 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-semibold text-slate-400 uppercase tracking-wider mb-3 mt-8 px-2">系统</div>
|
|
<nav class="space-y-1">
|
|
<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-r-lg 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 text-slate-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all duration-200">
|
|
<i class="fas fa-sign-out-alt"></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">
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-800">{{ getPageTitle(currentTab) }}</h1>
|
|
<p class="text-sm text-slate-500">{{ 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="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
|
<span class="text-sm font-medium text-slate-600">系统正常</span>
|
|
</div>
|
|
<button @click="refreshData" class="p-2 text-slate-400 hover:text-blue-600 transition-colors rounded-full hover:bg-slate-100" 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-6">
|
|
<!-- Stats Cards -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover-card">
|
|
<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 text-slate-800 mt-1">{{ stats.totalCount }}</h3>
|
|
</div>
|
|
<div class="w-10 h-10 bg-blue-50 rounded-lg flex items-center justify-center text-blue-600">
|
|
<i class="fas fa-camera"></i>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center text-xs text-slate-400">
|
|
<span class="text-green-500 font-medium flex items-center gap-1">
|
|
<i class="fas fa-arrow-up"></i> 100%
|
|
</span>
|
|
<span class="ml-2">历史累计</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover-card">
|
|
<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 text-slate-800 mt-1">{{ stats.successRate }}%</h3>
|
|
</div>
|
|
<div class="w-10 h-10 bg-green-50 rounded-lg flex items-center justify-center text-green-600">
|
|
<i class="fas fa-check-circle"></i>
|
|
</div>
|
|
</div>
|
|
<div class="w-full bg-slate-100 rounded-full h-1.5 mt-2">
|
|
<div class="bg-green-500 h-1.5 rounded-full" :style="{ width: stats.successRate + '%' }"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover-card">
|
|
<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 text-slate-800 mt-1">{{ stats.avgDuration }}s</h3>
|
|
</div>
|
|
<div class="w-10 h-10 bg-yellow-50 rounded-lg flex items-center justify-center text-yellow-600">
|
|
<i class="fas fa-stopwatch"></i>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center text-xs text-slate-400">
|
|
<span class="font-medium" :class="stats.avgDuration < 3 ? 'text-green-500' : 'text-yellow-500'">
|
|
{{ stats.avgDuration < 3 ? '性能优异' : '性能正常' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bg-white rounded-2xl p-6 shadow-sm border border-slate-100 hover-card">
|
|
<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-1 truncate max-w-[140px]" :title="currentModel">{{ currentModel || 'Loading...' }}</h3>
|
|
</div>
|
|
<div class="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center text-purple-600">
|
|
<i class="fas fa-brain"></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">Qwen-VL</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts -->
|
|
<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">
|
|
<h3 class="font-bold text-slate-800 mb-6 flex items-center gap-2">
|
|
<i class="fas fa-chart-bar text-blue-500"></i> 识别量趋势
|
|
</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">
|
|
<i class="fas fa-chart-pie text-purple-500"></i> 识别类型分布
|
|
</h3>
|
|
<div ref="pieChartRef" class="chart-container"></div>
|
|
</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 history" :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">
|
|
{{ record.prompt }}
|
|
</div>
|
|
<div v-if="record.details" class="text-xs text-slate-400 flex items-center gap-1">
|
|
<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="history.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>
|
|
</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>
|
|
</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>
|
|
|
|
<!-- 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 });
|
|
|
|
// Charts Refs
|
|
const barChartRef = ref(null);
|
|
const pieChartRef = ref(null);
|
|
let barChartInst = null;
|
|
let pieChartInst = null;
|
|
|
|
// Stats Computed
|
|
const stats = computed(() => {
|
|
if (!history.value.length) return { totalCount: 0, successRate: 0, avgDuration: 0 };
|
|
|
|
const total = history.value.length;
|
|
const success = history.value.filter(h => h.status === 'success').length;
|
|
const totalDuration = history.value.reduce((acc, curr) => acc + (curr.duration || 0), 0);
|
|
const countWithDuration = history.value.filter(h => h.duration).length;
|
|
|
|
return {
|
|
totalCount: total,
|
|
successRate: Math.round((success / total) * 100),
|
|
avgDuration: countWithDuration ? (totalDuration / countWithDuration).toFixed(2) : 0
|
|
};
|
|
});
|
|
|
|
// --- 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;
|
|
} 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); }
|
|
};
|
|
|
|
// --- 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) => {
|
|
currentTab.value = tab;
|
|
if (tab === 'files') fetchFiles();
|
|
if (tab === 'dashboard') {
|
|
fetchHistory();
|
|
nextTick(initCharts);
|
|
}
|
|
};
|
|
|
|
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 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': '全局配置'
|
|
};
|
|
return map[tab];
|
|
};
|
|
|
|
const getPageSubtitle = (tab) => {
|
|
const map = {
|
|
'dashboard': '系统运行状态与核心指标概览',
|
|
'history': '所有视觉识别任务的历史流水',
|
|
'files': '查看和管理生成的图像及JSON结果',
|
|
'prompts': '调整各个识别场景的 System Prompt',
|
|
'settings': '系统环境参数与自动清理策略'
|
|
};
|
|
return map[tab];
|
|
};
|
|
|
|
// --- Charts ---
|
|
const initCharts = () => {
|
|
if (!barChartRef.value || !pieChartRef.value) return;
|
|
|
|
// 1. Pie Chart Data
|
|
const typeCounts = {};
|
|
history.value.forEach(h => {
|
|
typeCounts[h.type] = (typeCounts[h.type] || 0) + 1;
|
|
});
|
|
const pieData = Object.keys(typeCounts).map(k => ({ value: typeCounts[k], name: k }));
|
|
|
|
// 2. Bar Chart Data (Group by Date)
|
|
const dateCounts = {};
|
|
// Reverse history to get chronological order for chart
|
|
const sortedHistory = [...history.value].sort((a, b) => a.timestamp - b.timestamp);
|
|
|
|
sortedHistory.forEach(h => {
|
|
const date = new Date(h.timestamp * 1000).toLocaleDateString();
|
|
dateCounts[date] = (dateCounts[date] || 0) + 1;
|
|
});
|
|
const barCategories = Object.keys(dateCounts);
|
|
const barData = Object.values(dateCounts);
|
|
|
|
// Render Pie
|
|
if (pieChartInst) pieChartInst.dispose();
|
|
pieChartInst = echarts.init(pieChartRef.value);
|
|
pieChartInst.setOption({
|
|
tooltip: { trigger: 'item' },
|
|
legend: { bottom: '0%', left: 'center' },
|
|
color: ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b'],
|
|
series: [{
|
|
name: '识别类型',
|
|
type: 'pie',
|
|
radius: ['40%', '70%'],
|
|
avoidLabelOverlap: false,
|
|
itemStyle: { borderRadius: 10, borderColor: '#fff', borderWidth: 2 },
|
|
label: { show: false, position: 'center' },
|
|
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
|
|
data: pieData
|
|
}]
|
|
});
|
|
|
|
// Render Bar
|
|
if (barChartInst) barChartInst.dispose();
|
|
barChartInst = echarts.init(barChartRef.value);
|
|
barChartInst.setOption({
|
|
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
|
|
grid: { left: '3%', right: '4%', bottom: '3%', containLabel: true },
|
|
xAxis: { type: 'category', data: barCategories, axisLine: { show: false }, axisTick: { show: false } },
|
|
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
|
|
series: [{
|
|
name: '识别数量',
|
|
type: 'bar',
|
|
barWidth: '40%',
|
|
data: barData,
|
|
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [{ offset: 0, color: '#3b82f6' }, { offset: 1, color: '#60a5fa' }]), borderRadius: [4, 4, 0, 0] }
|
|
}]
|
|
});
|
|
};
|
|
|
|
// Watch window resize for charts
|
|
window.addEventListener('resize', () => {
|
|
barChartInst && barChartInst.resize();
|
|
pieChartInst && pieChartInst.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, stats,
|
|
barChartRef, pieChartRef
|
|
};
|
|
}
|
|
}).mount('#app');
|
|
</script>
|
|
</body>
|
|
</html>
|