Files
sam3_local/static/admin.html
2026-02-17 12:03:18 +08:00

400 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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">&times;</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>