400 lines
22 KiB
HTML
400 lines
22 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>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>
|
||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||
<style>
|
||
[v-cloak] { display: none; }
|
||
.fade-enter-active, .fade-leave-active { transition: opacity 0.5s; }
|
||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||
</style>
|
||
</head>
|
||
<body class="bg-gray-100 min-h-screen text-gray-800">
|
||
<div id="app" v-cloak>
|
||
<!-- 登录页 -->
|
||
<div v-if="!isLoggedIn" class="flex items-center justify-center min-h-screen">
|
||
<div class="bg-white p-8 rounded-lg shadow-lg w-96">
|
||
<h1 class="text-2xl font-bold mb-6 text-center text-blue-600">SAM3 管理后台</h1>
|
||
<div class="mb-4">
|
||
<label class="block text-gray-700 text-sm font-bold mb-2">管理员密码</label>
|
||
<input v-model="password" type="password" @keyup.enter="login" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" placeholder="请输入密码">
|
||
</div>
|
||
<button @click="login" class="w-full bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline transition duration-300">
|
||
登录
|
||
</button>
|
||
<p v-if="loginError" class="text-red-500 text-xs italic mt-2">{{ loginError }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 主界面 -->
|
||
<div v-else class="flex h-screen overflow-hidden">
|
||
<!-- 侧边栏 -->
|
||
<aside class="w-64 bg-slate-800 text-white flex flex-col">
|
||
<div class="p-6 border-b border-slate-700">
|
||
<h2 class="text-xl font-bold flex items-center gap-2">
|
||
<i class="fas fa-layer-group"></i> SAM3 Admin
|
||
</h2>
|
||
</div>
|
||
<nav class="flex-1 p-4 space-y-2">
|
||
<a href="#" @click.prevent="currentTab = 'dashboard'" :class="{'bg-blue-600': currentTab === 'dashboard', 'hover:bg-slate-700': currentTab !== 'dashboard'}" class="block py-2.5 px-4 rounded transition duration-200">
|
||
<i class="fas fa-chart-line w-6"></i> 识别记录
|
||
</a>
|
||
<a href="#" @click.prevent="currentTab = 'files'" :class="{'bg-blue-600': currentTab === 'files', 'hover:bg-slate-700': currentTab !== 'files'}" class="block py-2.5 px-4 rounded transition duration-200">
|
||
<i class="fas fa-folder-open w-6"></i> 文件管理
|
||
</a>
|
||
<a href="#" @click.prevent="currentTab = 'settings'" :class="{'bg-blue-600': currentTab === 'settings', 'hover:bg-slate-700': currentTab !== 'settings'}" class="block py-2.5 px-4 rounded transition duration-200">
|
||
<i class="fas fa-cogs w-6"></i> 系统设置
|
||
</a>
|
||
</nav>
|
||
<div class="p-4 border-t border-slate-700">
|
||
<button @click="logout" class="w-full bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded transition duration-200">
|
||
<i class="fas fa-sign-out-alt"></i> 退出登录
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- 内容区域 -->
|
||
<main class="flex-1 overflow-y-auto bg-gray-50 p-8">
|
||
<!-- 识别记录 Dashboard -->
|
||
<div v-if="currentTab === 'dashboard'">
|
||
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2">最近识别记录</h2>
|
||
|
||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||
<div class="overflow-x-auto">
|
||
<table class="min-w-full leading-normal">
|
||
<thead>
|
||
<tr>
|
||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">时间</th>
|
||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">类型</th>
|
||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">Prompt / 详情</th>
|
||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">状态</th>
|
||
<th class="px-5 py-3 border-b-2 border-gray-200 bg-gray-100 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(record, index) in history" :key="index" class="hover:bg-gray-50">
|
||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
|
||
{{ formatDate(record.timestamp) }}
|
||
</td>
|
||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
|
||
<span :class="getTypeBadgeClass(record.type)" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">
|
||
{{ record.type }}
|
||
</span>
|
||
</td>
|
||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm max-w-xs truncate" :title="record.details">
|
||
{{ record.details }}
|
||
</td>
|
||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
|
||
<span :class="record.status === 'success' ? 'text-green-900 bg-green-200' : 'text-red-900 bg-red-200'" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">
|
||
{{ record.status }}
|
||
</span>
|
||
</td>
|
||
<td class="px-5 py-5 border-b border-gray-200 bg-white text-sm">
|
||
<button v-if="record.result_path" @click="viewResult(record.result_path)" class="text-blue-600 hover:text-blue-900 mr-3">
|
||
查看
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="history.length === 0">
|
||
<td colspan="5" class="px-5 py-5 border-b border-gray-200 bg-white text-sm text-center text-gray-500">
|
||
暂无记录
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="mt-4 flex justify-end">
|
||
<button @click="fetchHistory" class="bg-blue-500 hover:bg-blue-600 text-white py-1 px-3 rounded text-sm">
|
||
<i class="fas fa-sync-alt"></i> 刷新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 文件管理 Files -->
|
||
<div v-if="currentTab === 'files'">
|
||
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2">文件资源管理 (static/results)</h2>
|
||
|
||
<div class="bg-white rounded-lg shadow p-4 mb-4">
|
||
<div class="flex items-center justify-between mb-4">
|
||
<div class="text-sm text-gray-600">
|
||
<span class="font-bold">当前路径:</span> /static/results/{{ currentPath }}
|
||
<button v-if="currentPath" @click="navigateUp" class="ml-2 text-blue-500 hover:underline text-xs">
|
||
<i class="fas fa-level-up-alt"></i> 返回上一级
|
||
</button>
|
||
</div>
|
||
<button @click="fetchFiles" class="text-blue-500 hover:text-blue-700">
|
||
<i class="fas fa-sync-alt"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||
<div v-for="file in files" :key="file.name" class="border rounded-lg p-2 hover:shadow-md transition cursor-pointer relative group">
|
||
<!-- Folder -->
|
||
<div v-if="file.is_dir" @click="enterDir(file.name)" class="flex flex-col items-center justify-center h-32">
|
||
<i class="fas fa-folder text-yellow-400 text-4xl mb-2"></i>
|
||
<span class="text-xs text-center break-all px-1">{{ file.name }}</span>
|
||
<span class="text-xs text-gray-400">{{ file.count }} 项</span>
|
||
</div>
|
||
<!-- Image File -->
|
||
<div v-else-if="isImage(file.name)" @click="previewImage(file.path)" class="flex flex-col items-center justify-center h-32">
|
||
<img :src="file.url" class="h-20 w-auto object-contain mb-2 rounded" loading="lazy">
|
||
<span class="text-xs text-center break-all px-1 truncate w-full">{{ file.name }}</span>
|
||
</div>
|
||
<!-- Other File -->
|
||
<div v-else class="flex flex-col items-center justify-center h-32">
|
||
<i class="fas fa-file text-gray-400 text-4xl mb-2"></i>
|
||
<span class="text-xs text-center break-all px-1">{{ file.name }}</span>
|
||
</div>
|
||
|
||
<!-- Delete Button (Hover) -->
|
||
<button @click.stop="deleteFile(file.name)" class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition hover:bg-red-700" title="删除">
|
||
<i class="fas fa-times text-xs"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div v-if="files.length === 0" class="text-center py-10 text-gray-500">
|
||
此目录下没有文件
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 系统设置 Settings -->
|
||
<div v-if="currentTab === 'settings'">
|
||
<h2 class="text-2xl font-bold mb-6 text-gray-800 border-b pb-2">系统设置</h2>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<h3 class="text-lg font-bold mb-4 text-gray-700">自动清理配置</h3>
|
||
<div class="space-y-3">
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">状态</span>
|
||
<span class="font-mono bg-green-100 text-green-800 px-2 rounded text-sm">运行中</span>
|
||
</div>
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">文件保留时长</span>
|
||
<span class="font-mono">3600 秒 (1小时)</span>
|
||
</div>
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">检查间隔</span>
|
||
<span class="font-mono">600 秒 (10分钟)</span>
|
||
</div>
|
||
<div class="mt-4">
|
||
<button @click="triggerCleanup" :disabled="cleaning" class="w-full bg-yellow-500 hover:bg-yellow-600 text-white font-bold py-2 px-4 rounded transition">
|
||
<i class="fas fa-broom mr-2"></i> {{ cleaning ? '清理中...' : '立即执行清理' }}
|
||
</button>
|
||
<p class="text-xs text-gray-500 mt-2 text-center">将删除所有超过保留时长的文件</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bg-white rounded-lg shadow p-6">
|
||
<h3 class="text-lg font-bold mb-4 text-gray-700">系统信息</h3>
|
||
<div class="space-y-3">
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">模型</span>
|
||
<span class="font-mono">SAM3</span>
|
||
</div>
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">多模态模型</span>
|
||
<span class="font-mono">Qwen-VL-Max</span>
|
||
</div>
|
||
<div class="flex justify-between border-b pb-2">
|
||
<span class="text-gray-600">设备</span>
|
||
<span class="font-mono">{{ deviceInfo }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- 图片预览模态框 -->
|
||
<div v-if="previewUrl" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-90" @click="previewUrl = null">
|
||
<div class="relative max-w-4xl max-h-screen p-4">
|
||
<img :src="previewUrl" class="max-h-[90vh] max-w-full rounded shadow-lg" @click.stop>
|
||
<button class="absolute top-0 right-0 m-4 text-white text-3xl font-bold hover:text-gray-300" @click="previewUrl = null">×</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const { createApp, ref, onMounted, computed } = Vue;
|
||
|
||
createApp({
|
||
setup() {
|
||
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 deviceInfo = ref('Loading...');
|
||
|
||
// 检查登录状态
|
||
const checkLogin = () => {
|
||
const token = localStorage.getItem('admin_token');
|
||
if (token) {
|
||
isLoggedIn.value = true;
|
||
fetchHistory();
|
||
fetchSystemInfo();
|
||
}
|
||
};
|
||
|
||
const login = async () => {
|
||
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'); // 简单标记,实际由Cookie控制
|
||
isLoggedIn.value = true;
|
||
loginError.value = '';
|
||
fetchHistory();
|
||
fetchSystemInfo();
|
||
}
|
||
} 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 = '';
|
||
};
|
||
|
||
const fetchHistory = async () => {
|
||
try {
|
||
const res = await axios.get('/admin/api/history');
|
||
history.value = res.data.reverse(); // 最新在前
|
||
} 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;
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
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 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);
|
||
}
|
||
};
|
||
|
||
const triggerCleanup = async () => {
|
||
cleaning.value = true;
|
||
try {
|
||
const res = await axios.post('/admin/api/cleanup');
|
||
alert(`清理完成: ${res.data.message}`);
|
||
fetchFiles(); // 刷新文件列表
|
||
} catch (e) {
|
||
alert('清理失败');
|
||
} finally {
|
||
cleaning.value = false;
|
||
}
|
||
};
|
||
|
||
const viewResult = (path) => {
|
||
// path like "results/..."
|
||
// We need to parse this. If it's a directory, go to files tab. If image, preview.
|
||
// For simplicity, let's assume it links to the folder in files tab
|
||
currentTab.value = 'files';
|
||
// Extract folder name from path if possible, or just go to root
|
||
const match = path.match(/results\/([^\/]+)/);
|
||
if (match) {
|
||
currentPath.value = match[1];
|
||
fetchFiles();
|
||
} else {
|
||
currentPath.value = '';
|
||
fetchFiles();
|
||
}
|
||
};
|
||
|
||
const previewImage = (path) => {
|
||
previewUrl.value = path;
|
||
};
|
||
|
||
const isImage = (name) => {
|
||
return /\.(jpg|jpeg|png|gif|webp)$/i.test(name);
|
||
};
|
||
|
||
const formatDate = (ts) => {
|
||
return new Date(ts * 1000).toLocaleString();
|
||
};
|
||
|
||
const getTypeBadgeClass = (type) => {
|
||
const map = {
|
||
'general': 'bg-blue-100 text-blue-800',
|
||
'tarot': 'bg-purple-100 text-purple-800',
|
||
'face': 'bg-pink-100 text-pink-800'
|
||
};
|
||
return map[type] || 'bg-gray-100 text-gray-800';
|
||
};
|
||
|
||
// Watch tab change to fetch data
|
||
Vue.watch(currentTab, (newTab) => {
|
||
if (newTab === 'files') fetchFiles();
|
||
if (newTab === 'dashboard') fetchHistory();
|
||
});
|
||
|
||
onMounted(() => {
|
||
checkLogin();
|
||
});
|
||
|
||
return {
|
||
isLoggedIn, password, loginError, login, logout,
|
||
currentTab, history, files, currentPath,
|
||
enterDir, navigateUp, deleteFile, triggerCleanup,
|
||
viewResult, previewImage, isImage, previewUrl,
|
||
formatDate, getTypeBadgeClass, cleaning, deviceInfo
|
||
};
|
||
}
|
||
}).mount('#app');
|
||
</script>
|
||
</body>
|
||
</html>
|