stock-ai-agent/frontend/admin.html
2026-03-28 23:28:54 +08:00

872 lines
28 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>后台管理 - Tradus</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Serif+Display&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
<style>
/* ========================================
ADMIN PAGE - ADVANCED STYLING
======================================== */
/* === ATMOSPHERIC BACKGROUND === */
body {
background: var(--bg-primary);
position: relative;
overflow-x: hidden;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
radial-gradient(circle at 10% 30%, rgba(102, 126, 234, 0.12) 0%, transparent 50%),
radial-gradient(circle at 90% 70%, rgba(118, 75, 162, 0.1) 0%, transparent 50%),
radial-gradient(circle at 50% 100%, rgba(0, 240, 255, 0.06) 0%, transparent 40%);
pointer-events: none;
z-index: 0;
animation: backgroundPulse 10s ease-in-out infinite;
}
@keyframes backgroundPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
#app {
position: relative;
z-index: 1;
}
.admin-page {
min-height: 100vh;
background: transparent;
display: flex;
}
/* === GLASSMORPHISM SIDEBAR === */
.admin-sidebar {
width: 260px;
background: rgba(17, 24, 39, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-right: 1px solid rgba(0, 240, 255, 0.2);
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.5);
position: fixed;
height: 100vh;
overflow-y: auto;
z-index: 100;
}
.admin-logo {
padding: 24px 24px 28px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
margin-bottom: 20px;
}
.admin-logo h2 {
font-size: 20px;
font-weight: 300;
font-family: 'DM Serif Display', serif;
background: linear-gradient(135deg, #667EEA 0%, #764BA2 50%, #00F0FF 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 0.5px;
}
.admin-nav {
list-style: none;
padding: 0;
margin: 0;
}
.admin-nav-item {
padding: 16px 24px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.3s ease;
border-left: 3px solid transparent;
position: relative;
overflow: hidden;
font-weight: 500;
}
.admin-nav-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg, rgba(0, 240, 255, 0.1) 0%, transparent 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.admin-nav-item:hover {
background: rgba(26, 31, 58, 0.6);
color: var(--text-primary);
border-left-color: rgba(0, 240, 255, 0.5);
}
.admin-nav-item:hover::before {
opacity: 1;
}
.admin-nav-item.active {
background: rgba(26, 31, 58, 0.9);
color: var(--accent);
border-left-color: var(--accent);
box-shadow: inset 0 0 20px rgba(0, 240, 255, 0.1);
}
.admin-nav-item.active::before {
opacity: 1;
}
/* === MAIN CONTENT === */
.admin-main {
margin-left: 260px;
flex: 1;
padding: 24px;
min-width: 800px;
}
.admin-container {
max-width: 1400px;
margin: 0 auto;
}
.admin-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.admin-title {
font-size: 32px;
font-weight: 300;
font-family: 'DM Serif Display', serif;
background: linear-gradient(135deg, #00F0FF 0%, #00C9FF 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 0.5px;
}
.logout-btn {
padding: 10px 20px;
background: rgba(26, 31, 58, 0.6);
border: 1px solid rgba(255, 0, 64, 0.3);
border-radius: 12px;
color: #ff0040;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.logout-btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(255, 0, 64, 0.3);
transform: translate(-50%, -50%);
transition: width 0.4s ease, height 0.4s ease;
}
.logout-btn:hover {
background: rgba(255, 0, 64, 0.15);
border-color: rgba(255, 0, 64, 0.6);
box-shadow: 0 0 16px rgba(255, 0, 64, 0.3);
transform: translateY(-2px);
}
.logout-btn:active::before {
width: 200%;
height: 200%;
}
/* === STATS GRID === */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: rgba(26, 31, 58, 0.6);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 24px;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, #667EEA 0%, #764BA2 50%, #00F0FF 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover {
background: rgba(26, 31, 58, 0.9);
border-color: rgba(0, 240, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 20px rgba(0, 240, 255, 0.2);
transform: translateY(-4px);
}
.stat-card:hover::before {
opacity: 1;
}
.stat-label {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 36px;
font-weight: 300;
font-family: 'JetBrains Mono', monospace;
background: linear-gradient(135deg, #00F0FF 0%, #00C9FF 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.section-title {
font-size: 20px;
font-weight: 300;
font-family: 'DM Serif Display', serif;
color: var(--text-primary);
margin-bottom: 24px;
}
/* === SEARCH BAR === */
.search-bar {
display: flex;
gap: 16px;
margin-bottom: 24px;
}
.search-input {
flex: 1;
padding: 12px 18px;
background: rgba(26, 31, 58, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: var(--text-primary);
font-size: 14px;
font-family: 'DM Sans', sans-serif;
transition: all 0.3s ease;
}
.search-input:focus {
outline: none;
background: rgba(26, 31, 58, 0.9);
border-color: rgba(0, 240, 255, 0.4);
box-shadow: 0 0 24px rgba(0, 240, 255, 0.2);
}
.search-input::placeholder {
color: var(--text-tertiary);
opacity: 0.6;
}
.search-btn {
padding: 12px 28px;
background: linear-gradient(135deg, #00F0FF 0%, #00C9FF 100%);
border: none;
border-radius: 12px;
color: var(--bg-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 16px rgba(0, 240, 255, 0.3);
}
.search-btn:hover {
box-shadow: 0 6px 24px rgba(0, 240, 255, 0.5);
transform: translateY(-2px);
}
.search-btn:active {
transform: translateY(0);
}
/* === USERS TABLE === */
.users-table {
width: 100%;
background: rgba(26, 31, 58, 0.6);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.users-table table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
background: rgba(10, 14, 39, 0.6);
padding: 16px 20px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.users-table td {
padding: 16px 20px;
font-size: 14px;
color: var(--text-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.2s ease;
}
.users-table tr:last-child td {
border-bottom: none;
}
.users-table tr {
transition: all 0.2s ease;
}
.users-table tr:hover {
background: rgba(0, 240, 255, 0.05);
}
.users-table tr:hover td {
color: var(--text-primary);
}
/* === STATUS BADGES === */
.status-badge {
display: inline-block;
padding: 6px 16px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.5px;
}
.status-active {
background: linear-gradient(135deg, rgba(0, 200, 81, 0.2) 0%, rgba(0, 230, 118, 0.2) 100%);
color: #00C851;
border: 1px solid rgba(0, 200, 81, 0.4);
box-shadow: 0 0 12px rgba(0, 200, 81, 0.2);
}
.status-inactive {
background: linear-gradient(135deg, rgba(255, 0, 64, 0.2) 0%, rgba(255, 107, 107, 0.2) 100%);
color: #FF0040;
border: 1px solid rgba(255, 0, 64, 0.4);
box-shadow: 0 0 12px rgba(255, 0, 64, 0.2);
}
/* === PAGINATION === */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
margin-top: 24px;
}
.pagination button {
padding: 10px 20px;
background: rgba(26, 31, 58, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.pagination button:hover:not(:disabled) {
background: rgba(0, 240, 255, 0.15);
border-color: rgba(0, 240, 255, 0.4);
box-shadow: 0 0 16px rgba(0, 240, 255, 0.2);
transform: translateY(-2px);
}
.pagination button:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.pagination .page-info {
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
}
/* === LOGIN OVERLAY === */
.login-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(10, 14, 39, 0.95);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.login-box {
width: 100%;
max-width: 440px;
padding: 48px;
background: rgba(26, 31, 58, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
box-shadow: 0 16px 64px rgba(0, 0, 0, 0.6), 0 0 40px rgba(0, 240, 255, 0.2);
animation: scaleIn 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.9) translateY(20px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.login-box h2 {
font-size: 32px;
font-weight: 300;
font-family: 'DM Serif Display', serif;
background: linear-gradient(135deg, #00F0FF 0%, #00C9FF 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 32px;
text-align: center;
}
.login-box input {
width: 100%;
padding: 14px 18px;
background: rgba(10, 14, 39, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: var(--text-primary);
font-size: 14px;
font-family: 'DM Sans', sans-serif;
margin-bottom: 20px;
transition: all 0.3s ease;
}
.login-box input:focus {
outline: none;
background: rgba(10, 14, 39, 0.9);
border-color: rgba(0, 240, 255, 0.4);
box-shadow: 0 0 24px rgba(0, 240, 255, 0.2);
}
.login-box input::placeholder {
color: var(--text-tertiary);
opacity: 0.6;
}
.login-box button {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #00F0FF 0%, #00C9FF 100%);
border: none;
border-radius: 12px;
color: var(--bg-primary);
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 16px rgba(0, 240, 255, 0.3);
}
.login-box button:hover {
box-shadow: 0 6px 24px rgba(0, 240, 255, 0.5);
transform: translateY(-2px);
}
.login-box button:active {
transform: translateY(0);
}
.error-message {
padding: 14px;
background: rgba(255, 0, 64, 0.15);
border: 1px solid rgba(255, 0, 64, 0.4);
border-radius: 12px;
color: #ff0040;
font-size: 13px;
text-align: center;
margin-bottom: 20px;
box-shadow: 0 0 16px rgba(255, 0, 64, 0.2);
}
/* === RESPONSIVE === */
@media (max-width: 768px) {
.admin-sidebar {
width: 200px;
}
.admin-main {
margin-left: 200px;
min-width: 600px;
padding: 16px;
}
.admin-title {
font-size: 24px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat-value {
font-size: 28px;
}
}
</style>
</head>
<body>
<div id="app">
<!-- 登录遮罩 -->
<div v-if="!authenticated" class="login-overlay">
<div class="login-box">
<h2>后台管理</h2>
<div v-if="errorMessage" class="error-message">
{{ errorMessage }}
</div>
<input
type="password"
v-model="password"
placeholder="请输入管理密码"
@keyup.enter="login"
>
<button @click="login">登录</button>
</div>
</div>
<!-- 管理页面 -->
<div v-if="authenticated" class="admin-page">
<!-- 侧边栏 -->
<div class="admin-sidebar">
<div class="admin-logo">
<h2>Tradus 管理后台</h2>
</div>
<ul class="admin-nav">
<li class="admin-nav-item" :class="{active: currentView === 'dashboard'}" @click="currentView = 'dashboard'">
数据统计
</li>
<li class="admin-nav-item" :class="{active: currentView === 'users'}" @click="currentView = 'users'">
用户管理
</li>
</ul>
</div>
<!-- 主内容区 -->
<div class="admin-main">
<div class="admin-container">
<div class="admin-header">
<h1 class="admin-title">{{ currentViewTitle }}</h1>
<button class="logout-btn" @click="logout">退出</button>
</div>
<!-- 数据统计视图 -->
<div v-if="currentView === 'dashboard'">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">用户总数</div>
<div class="stat-value">{{ stats.total_users }}</div>
</div>
<div class="stat-card">
<div class="stat-label">活跃用户7天</div>
<div class="stat-value">{{ stats.active_users }}</div>
</div>
<div class="stat-card">
<div class="stat-label">对话总数</div>
<div class="stat-value">{{ stats.total_conversations }}</div>
</div>
<div class="stat-card">
<div class="stat-label">消息总数</div>
<div class="stat-value">{{ stats.total_messages }}</div>
</div>
</div>
</div>
<!-- 用户管理视图 -->
<div v-if="currentView === 'users'">
<h3 class="section-title">用户列表</h3>
<div class="search-bar">
<input
type="text"
class="search-input"
v-model="searchKeyword"
placeholder="搜索手机号..."
@keyup.enter="searchUsers"
>
<button class="search-btn" @click="searchUsers">搜索</button>
</div>
<div class="users-table">
<table>
<thead>
<tr>
<th>ID</th>
<th>手机号</th>
<th>注册时间</th>
<th>最后登录</th>
<th>状态</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.id }}</td>
<td>{{ user.phone }}</td>
<td>{{ formatDate(user.created_at) }}</td>
<td>{{ formatDate(user.last_login_at) }}</td>
<td>
<span :class="['status-badge', user.is_active ? 'status-active' : 'status-inactive']">
{{ user.is_active ? '正常' : '禁用' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button @click="prevPage" :disabled="currentPage <= 1">上一页</button>
<span class="page-info">第 {{ currentPage }} / {{ totalPages }} 页</span>
<button @click="nextPage" :disabled="currentPage >= totalPages">下一页</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@3/dist/vue.global.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
authenticated: false,
password: '',
errorMessage: '',
adminPassword: '',
currentView: 'users', // 当前视图dashboard 或 users
stats: {
total_users: 0,
active_users: 0,
total_conversations: 0,
total_messages: 0
},
users: [],
searchKeyword: '',
currentPage: 1,
pageSize: 20,
totalPages: 1,
total: 0
};
},
computed: {
currentViewTitle() {
const titles = {
'dashboard': '数据统计',
'users': '用户管理'
};
return titles[this.currentView] || '后台管理';
}
},
mounted() {
// 检查是否已登录
const savedPassword = sessionStorage.getItem('admin_password');
if (savedPassword) {
this.adminPassword = savedPassword;
this.authenticated = true;
this.loadData();
}
},
methods: {
async login() {
if (!this.password) {
this.errorMessage = '请输入密码';
return;
}
try {
const response = await fetch(`/api/admin/verify?password=${this.password}`, {
method: 'POST'
});
const data = await response.json();
if (data.success) {
this.adminPassword = this.password;
sessionStorage.setItem('admin_password', this.password);
this.authenticated = true;
this.errorMessage = '';
this.loadData();
} else {
this.errorMessage = '密码错误';
}
} catch (error) {
console.error('登录失败:', error);
this.errorMessage = '登录失败,请稍后重试';
}
},
logout() {
sessionStorage.removeItem('admin_password');
this.authenticated = false;
this.adminPassword = '';
this.password = '';
},
async loadData() {
await Promise.all([
this.loadStats(),
this.loadUsers()
]);
},
async loadStats() {
try {
const response = await fetch(`/api/admin/stats?password=${this.adminPassword}`);
const data = await response.json();
if (data.success) {
this.stats = data.data;
}
} catch (error) {
console.error('加载统计数据失败:', error);
}
},
async loadUsers() {
try {
let url = `/api/admin/users?password=${this.adminPassword}&page=${this.currentPage}&page_size=${this.pageSize}`;
if (this.searchKeyword) {
url += `&search=${encodeURIComponent(this.searchKeyword)}`;
}
const response = await fetch(url);
const data = await response.json();
if (data.success) {
this.users = data.data.users;
this.total = data.data.total;
this.totalPages = data.data.total_pages;
}
} catch (error) {
console.error('加载用户列表失败:', error);
}
},
searchUsers() {
this.currentPage = 1;
this.loadUsers();
},
prevPage() {
if (this.currentPage > 1) {
this.currentPage--;
this.loadUsers();
}
},
nextPage() {
if (this.currentPage < this.totalPages) {
this.currentPage++;
this.loadUsers();
}
},
formatDate(dateString) {
if (!dateString) return '-';
// 直接解析 ISO 格式的时间字符串,不做时区转换
// 假设服务器返回的是 UTC 时间,我们将其视为本地时间显示
const date = new Date(dateString + 'Z'); // 添加 Z 表示 UTC
// 获取各个时间部分
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
}
}).mount('#app');
</script>
</body>
</html>