This commit is contained in:
aaron 2026-03-29 11:12:37 +08:00
parent 599a3498ba
commit e5fa1f2024
6 changed files with 1819 additions and 5159 deletions

View File

@ -8,145 +8,102 @@
<!-- 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 href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
<style>
/* ========================================
ADMIN PAGE - ADVANCED STYLING
======================================== */
:root {
--primary: #0066FF;
--primary-light: #E8F0FF;
--bg-primary: #FFFFFF;
--bg-secondary: #F8FAFB;
--bg-tertiary: #F1F5F9;
--text-primary: #1E293B;
--text-secondary: #64748B;
--text-tertiary: #94A3B8;
--border: #E2E8F0;
--success: #10B981;
--success-light: #D1FAE5;
--error: #DC2626;
--error-light: #FEE2E2;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* === 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; }
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
line-height: 1.6;
}
#app {
position: relative;
z-index: 1;
}
.admin-page {
min-height: 100vh;
background: transparent;
display: flex;
}
/* === GLASSMORPHISM SIDEBAR === */
/* Admin Layout */
.admin-page {
display: flex;
min-height: 100vh;
}
/* 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);
width: 240px;
background: var(--bg-primary);
border-right: 1px solid var(--border);
padding: 24px 0;
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;
padding: 0 24px 24px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.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;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.admin-nav {
list-style: none;
padding: 0;
margin: 0;
}
.admin-nav-item {
padding: 16px 24px;
padding: 12px 24px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.3s ease;
transition: all 0.2s;
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);
background: var(--bg-secondary);
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);
background: var(--primary-light);
color: var(--primary);
border-left-color: var(--primary);
font-weight: 500;
}
.admin-nav-item.active::before {
opacity: 1;
}
/* === MAIN CONTENT === */
/* Main Content */
.admin-main {
margin-left: 260px;
margin-left: 240px;
flex: 1;
padding: 24px;
min-width: 800px;
}
.admin-container {
max-width: 1400px;
margin: 0 auto;
padding: 32px;
}
.admin-header {
@ -154,61 +111,33 @@
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;
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
}
.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;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-secondary);
font-size: 14px;
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;
transition: all 0.2s;
}
.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);
background: var(--error-light);
border-color: var(--error);
color: var(--error);
}
.logout-btn:active::before {
width: 200%;
height: 200%;
}
/* === STATS GRID === */
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
@ -217,38 +146,10 @@
}
.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;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
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 {
@ -260,179 +161,137 @@
}
.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;
font-size: 32px;
font-weight: 700;
color: var(--primary);
}
.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 */
.search-bar {
display: flex;
gap: 16px;
gap: 12px;
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);
padding: 12px 16px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-family: 'DM Sans', sans-serif;
transition: all 0.3s ease;
color: var(--text-primary);
transition: all 0.2s;
}
.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);
border-color: var(--primary);
}
.search-input::placeholder {
color: var(--text-tertiary);
opacity: 0.6;
}
.search-btn {
padding: 12px 28px;
background: linear-gradient(135deg, #00F0FF 0%, #00C9FF 100%);
padding: 12px 24px;
background: var(--primary);
border: none;
border-radius: 12px;
color: var(--bg-primary);
border-radius: 8px;
color: white;
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);
transition: all 0.2s;
}
.search-btn:hover {
box-shadow: 0 6px 24px rgba(0, 240, 255, 0.5);
transform: translateY(-2px);
background: #0052CC;
}
.search-btn:active {
transform: translateY(0);
}
/* === USERS TABLE === */
/* 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;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
}
.users-table table {
table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
background: rgba(10, 14, 39, 0.6);
padding: 16px 20px;
th {
background: var(--bg-tertiary);
padding: 14px 16px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid var(--border);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.users-table td {
padding: 16px 20px;
td {
padding: 14px 16px;
font-size: 14px;
color: var(--text-primary);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
transition: all 0.2s ease;
border-bottom: 1px solid var(--border);
}
.users-table tr:last-child td {
tr:last-child td {
border-bottom: none;
}
.users-table tr {
transition: all 0.2s ease;
tr:hover {
background: var(--bg-secondary);
}
.users-table tr:hover {
background: rgba(0, 240, 255, 0.05);
}
.users-table tr:hover td {
color: var(--text-primary);
}
/* === STATUS BADGES === */
/* Status Badge */
.status-badge {
display: inline-block;
padding: 6px 16px;
padding: 4px 12px;
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);
background: var(--success-light);
color: var(--success);
}
.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);
background: var(--error-light);
color: var(--error);
}
/* === PAGINATION === */
/* Pagination */
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 16px;
gap: 12px;
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;
padding: 8px 16px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
transition: all 0.2s;
}
.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);
background: var(--primary-light);
border-color: var(--primary);
color: var(--primary);
}
.pagination button:disabled {
@ -440,131 +299,92 @@
cursor: not-allowed;
}
.pagination .page-info {
.page-info {
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
}
/* === LOGIN OVERLAY === */
/* 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);
width: 100%;
height: 100%;
background: var(--bg-secondary);
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;
max-width: 400px;
background: var(--bg-primary);
border-radius: 12px;
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);
}
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.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;
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
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);
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 14px;
font-family: 'DM Sans', sans-serif;
margin-bottom: 20px;
transition: all 0.3s ease;
color: var(--text-primary);
margin-bottom: 16px;
}
.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);
background: var(--bg-primary);
border-color: var(--primary);
}
.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%);
padding: 14px;
background: var(--primary);
border: none;
border-radius: 12px;
color: var(--bg-primary);
border-radius: 8px;
color: white;
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);
transition: all 0.2s;
}
.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);
background: #0052CC;
}
.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;
padding: 12px 16px;
background: var(--error-light);
border: 1px solid var(--error);
border-radius: 8px;
color: var(--error);
font-size: 13px;
text-align: center;
margin-bottom: 20px;
box-shadow: 0 0 16px rgba(255, 0, 64, 0.2);
margin-bottom: 16px;
}
/* === RESPONSIVE === */
/* Responsive */
@media (max-width: 768px) {
.admin-sidebar {
width: 200px;
@ -572,27 +392,18 @@
.admin-main {
margin-left: 200px;
min-width: 600px;
padding: 16px;
padding: 20px;
}
.admin-title {
font-size: 24px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat-value {
font-size: 28px;
}
}
</style>
</head>
<body>
<div id="app">
<!-- 登录遮罩 -->
<!-- Login Overlay -->
<div v-if="!authenticated" class="login-overlay">
<div class="login-box">
<h2>后台管理</h2>
@ -609,99 +420,100 @@
</div>
</div>
<!-- 管理页面 -->
<!-- Admin Page -->
<div v-if="authenticated" class="admin-page">
<!-- 侧边栏 -->
<!-- Sidebar -->
<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 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 class="admin-nav-item"
:class="{active: currentView === 'users'}"
@click="currentView = 'users'">
用户管理
</li>
</ul>
</div>
<!-- 主内容区 -->
<!-- Main Content -->
<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 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>
<!-- Dashboard View -->
<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>
<!-- Users View -->
<div v-if="currentView === 'users'">
<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="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 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>
@ -719,7 +531,7 @@
password: '',
errorMessage: '',
adminPassword: '',
currentView: 'users', // 当前视图dashboard 或 users
currentView: 'users',
stats: {
total_users: 0,
active_users: 0,
@ -744,7 +556,6 @@
}
},
mounted() {
// 检查是否已登录
const savedPassword = sessionStorage.getItem('admin_password');
if (savedPassword) {
this.adminPassword = savedPassword;
@ -851,11 +662,8 @@
formatDate(dateString) {
if (!dateString) return '-';
// 直接解析 ISO 格式的时间字符串,不做时区转换
// 假设服务器返回的是 UTC 时间,我们将其视为本地时间显示
const date = new Date(dateString + 'Z'); // 添加 Z 表示 UTC
const date = new Date(dateString + 'Z');
// 获取各个时间部分
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');

File diff suppressed because it is too large Load Diff

View File

@ -3,182 +3,115 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - TradusAI 金融智能体</title>
<title>登录 - Tradus AI</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 href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
<style>
/* ========================================
LOGIN PAGE - ADVANCED STYLING
======================================== */
:root {
--primary: #0066FF;
--primary-light: #E8F0FF;
--bg-primary: #FFFFFF;
--bg-secondary: #F8FAFB;
--bg-tertiary: #F1F5F9;
--text-primary: #1E293B;
--text-secondary: #64748B;
--text-tertiary: #94A3B8;
--border: #E2E8F0;
--error: #DC2626;
--error-light: #FEE2E2;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* === ATMOSPHERIC BACKGROUND === */
body {
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
radial-gradient(circle at 20% 30%, rgba(102, 126, 234, 0.18) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, rgba(118, 75, 162, 0.15) 0%, transparent 50%),
radial-gradient(circle at 50% 100%, rgba(0, 240, 255, 0.1) 0%, transparent 40%);
pointer-events: none;
z-index: 0;
animation: loginBackgroundPulse 8s ease-in-out infinite;
}
@keyframes loginBackgroundPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
#app {
position: relative;
z-index: 1;
}
.login-page {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
padding: 20px;
}
/* === GLASSMORPHISM LOGIN CONTAINER === */
.login-container {
width: 100%;
max-width: 480px;
padding: 56px 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: 20px;
box-shadow:
0 16px 64px rgba(0, 0, 0, 0.6),
0 0 40px rgba(0, 240, 255, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
animation: loginSlideIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
max-width: 420px;
background: var(--bg-primary);
border-radius: 12px;
padding: 48px 40px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}
.login-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 3px;
background: linear-gradient(90deg, #667EEA 0%, #764BA2 50%, #00F0FF 100%);
opacity: 0.8;
}
@keyframes loginSlideIn {
from {
opacity: 0;
transform: translateY(40px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* === HEADER === */
.login-header {
text-align: center;
margin-bottom: 48px;
margin-bottom: 40px;
}
.login-logo {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-bottom: 20px;
margin-bottom: 24px;
}
.login-logo svg {
color: var(--accent);
filter: drop-shadow(0 0 12px rgba(0, 240, 255, 0.6));
animation: logoFloat 4s ease-in-out infinite;
}
@keyframes logoFloat {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-8px) rotate(5deg); }
width: 48px;
height: 48px;
color: var(--primary);
}
.login-title {
font-size: 32px;
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: 1px;
line-height: 1.4;
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
}
/* === FORM === */
.login-form {
display: flex;
flex-direction: column;
gap: 24px;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 10px;
gap: 8px;
}
.form-label {
font-size: 13px;
color: var(--text-secondary);
letter-spacing: 0.5px;
font-weight: 500;
text-transform: uppercase;
color: var(--text-secondary);
}
.form-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);
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 15px;
font-family: 'DM Sans', sans-serif;
transition: all 0.3s ease;
font-family: inherit;
color: var(--text-primary);
transition: all 0.2s;
}
.form-input:focus {
outline: none;
background: rgba(10, 14, 39, 0.9);
border-color: rgba(0, 240, 255, 0.5);
box-shadow: 0 0 24px rgba(0, 240, 255, 0.25);
background: var(--bg-primary);
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-light);
}
.form-input::placeholder {
color: var(--text-tertiary);
opacity: 0.6;
}
.code-input-group {
@ -191,43 +124,20 @@
}
.send-code-btn {
padding: 14px 24px;
background: rgba(26, 31, 58, 0.6);
border: 1px solid rgba(0, 240, 255, 0.3);
border-radius: 12px;
color: var(--accent);
font-size: 13px;
font-weight: 600;
padding: 12px 20px;
background: var(--bg-primary);
border: 1px solid var(--primary);
border-radius: 8px;
color: var(--primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.send-code-btn::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(0, 240, 255, 0.3);
transform: translate(-50%, -50%);
transition: width 0.4s ease, height 0.4s ease;
transition: all 0.2s;
}
.send-code-btn:hover:not(:disabled) {
background: rgba(0, 240, 255, 0.15);
border-color: rgba(0, 240, 255, 0.6);
box-shadow: 0 0 20px rgba(0, 240, 255, 0.3);
transform: translateY(-2px);
}
.send-code-btn:active:not(:disabled)::before {
width: 200%;
height: 200%;
background: var(--primary-light);
}
.send-code-btn:disabled {
@ -237,83 +147,50 @@
.login-btn {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #00F0FF 0%, #00C9FF 100%);
padding: 14px;
background: var(--primary);
border: none;
border-radius: 12px;
color: var(--bg-primary);
font-size: 16px;
border-radius: 8px;
color: white;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 20px rgba(0, 240, 255, 0.4);
position: relative;
overflow: hidden;
}
.login-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.3) 50%, transparent 100%);
transition: left 0.5s ease;
transition: all 0.2s;
}
.login-btn:hover:not(:disabled) {
box-shadow: 0 6px 32px rgba(0, 240, 255, 0.6);
transform: translateY(-2px);
}
.login-btn:hover:not(:disabled)::before {
left: 100%;
}
.login-btn:active:not(:disabled) {
transform: translateY(0);
background: #0052CC;
}
.login-btn:disabled {
opacity: 0.5;
opacity: 0.4;
cursor: not-allowed;
}
.error-message {
padding: 14px 18px;
background: rgba(255, 0, 64, 0.15);
border: 1px solid rgba(255, 0, 64, 0.4);
border-radius: 12px;
color: #ff0040;
padding: 12px 16px;
background: var(--error-light);
border: 1px solid var(--error);
border-radius: 8px;
color: var(--error);
font-size: 13px;
text-align: center;
box-shadow: 0 0 20px rgba(255, 0, 64, 0.2);
animation: errorShake 0.4s ease-out;
}
@keyframes errorShake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-8px); }
75% { transform: translateX(8px); }
}
.login-footer {
margin-top: 32px;
margin-top: 24px;
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
opacity: 0.7;
}
/* === RESPONSIVE === */
@media (max-width: 768px) {
@media (max-width: 480px) {
.login-container {
padding: 40px 32px;
padding: 32px 24px;
}
.login-title {
font-size: 26px;
font-size: 24px;
}
.code-input-group {
@ -324,26 +201,6 @@
width: 100%;
}
}
@media (max-width: 480px) {
.login-container {
padding: 32px 24px;
}
.login-title {
font-size: 22px;
}
.form-input {
padding: 12px 16px;
font-size: 14px;
}
.login-btn {
padding: 14px;
font-size: 15px;
}
}
</style>
</head>
<body>
@ -351,7 +208,7 @@
<div class="login-container">
<div class="login-header">
<div class="login-logo">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none">
<svg viewBox="0 0 24 24" fill="none">
<path d="M3 13h8V3H3v10zm0 8h8v-6H3v6zm10 0h8V11h-8v10zm0-18v6h8V3h-8z" fill="currentColor"/>
</svg>
</div>
@ -500,9 +357,7 @@
const data = await response.json();
if (data.success && data.token) {
// 保存token
localStorage.setItem('token', data.token);
// 跳转到主页
window.location.href = '/app';
} else {
this.errorMessage = data.message || '登录失败';

File diff suppressed because it is too large Load Diff

View File

@ -2,130 +2,104 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>交易信号 - Stock Agent</title>
<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 href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/css/style.css">
<style>
/* ===== 页面基础 ===== */
html, body {
overflow-x: hidden;
max-width: 100vw;
background: linear-gradient(180deg, #0A0E27 0%, #000000 100%);
:root {
--primary: #0066FF;
--primary-light: #E8F0FF;
--bg-primary: #FFFFFF;
--bg-secondary: #F8FAFB;
--bg-tertiary: #F1F5F9;
--text-primary: #1E293B;
--text-secondary: #64748B;
--text-tertiary: #94A3B8;
--border: #E2E8F0;
--success: #10B981;
--success-light: #D1FAE5;
--error: #EF4444;
--error-light: #FEE2E2;
--warning: #F59E0B;
--warning-light: #FEF3C7;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
line-height: 1.6;
}
#app {
height: auto;
display: block;
align-items: initial;
justify-content: initial;
padding: 0;
overflow-x: hidden;
min-height: 100vh;
}
.signals-page {
min-height: 100vh;
background: transparent;
padding: 0;
position: relative;
}
/* 背景装饰 */
.signals-page::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
radial-gradient(circle at 30% 30%, rgba(102, 126, 234, 0.12) 0%, transparent 50%),
radial-gradient(circle at 70% 70%, rgba(0, 240, 255, 0.08) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
.signals-container {
max-width: 1600px;
max-width: 1400px;
margin: 0 auto;
padding: 24px;
position: relative;
z-index: 1;
}
/* ===== 顶部导航 ===== */
.sticky-header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(10, 14, 39, 0.8);
backdrop-filter: blur(20px);
padding: 16px 24px;
margin: -24px -24px 24px -24px;
border-bottom: 1px solid rgba(0, 240, 255, 0.2);
padding: 32px 24px;
}
/* Header */
.signals-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 32px;
}
.signals-title {
font-size: 28px;
font-weight: 500;
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;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
}
.signals-title::before {
content: '';
.live-dot {
width: 8px;
height: 8px;
background: #00F0FF;
background: var(--success);
border-radius: 50%;
box-shadow: 0 0 12px #00F0FF, 0 0 24px #00F0FF;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.refresh-btn {
padding: 10px 20px;
background: rgba(0, 240, 255, 0.1);
border: 1px solid rgba(0, 240, 255, 0.3);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
color: #00F0FF;
color: var(--primary);
font-size: 14px;
font-weight: 500;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
font-family: 'DM Sans', sans-serif;
transition: all 0.2s;
}
.refresh-btn:hover {
background: rgba(0, 240, 255, 0.2);
border-color: #00F0FF;
box-shadow: 0 0 20px rgba(0, 240, 255, 0.4);
transform: translateY(-2px);
background: var(--primary-light);
border-color: var(--primary);
}
/* ===== 统计卡片网格 ===== */
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@ -134,109 +108,102 @@
}
.stat-card {
position: relative;
background: rgba(26, 31, 58, 0.6);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
overflow: hidden;
transition: all 0.3s ease;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, #667EEA 0%, #764BA2 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover {
transform: translateY(-4px);
border-color: rgba(0, 240, 255, 0.3);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3), 0 0 20px rgba(0, 240, 255, 0.2);
}
.stat-card:hover::before {
opacity: 1;
}
.stat-label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 1px;
letter-spacing: 0.5px;
margin-bottom: 8px;
font-weight: 500;
}
.stat-value {
font-size: 28px;
font-weight: 700;
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;
color: var(--primary);
}
.stat-sub {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
.stat-value.positive {
background: linear-gradient(135deg, #00C851 0%, #00E676 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: var(--success);
}
.stat-value.negative {
background: linear-gradient(135deg, #FF4444 0%, #FF6B6B 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: var(--error);
}
/* ===== 标签页导航 ===== */
/* Grade Stats */
.grade-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.grade-stat-card {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
.grade-stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.grade-stat-title {
font-size: 14px;
color: var(--text-primary);
}
.grade-stat-count {
font-size: 20px;
font-weight: 700;
color: var(--primary);
}
/* Tabs */
.tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
margin-bottom: 24px;
background: rgba(26, 31, 58, 0.4);
backdrop-filter: blur(10px);
border-radius: 12px;
padding: 6px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.tab {
flex: 1;
padding: 14px 24px;
background: transparent;
border: none;
border-radius: 8px;
border-bottom: 2px solid transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
transition: all 0.2s;
}
.tab:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.05);
}
.tab.active {
background: linear-gradient(135deg, rgba(0, 240, 255, 0.15) 0%, rgba(102, 126, 234, 0.15) 100%);
color: #00F0FF;
box-shadow: 0 0 20px rgba(0, 240, 255, 0.2);
color: var(--primary);
border-bottom-color: var(--primary);
}
/* 信号卡片网格 */
/* Signals Grid */
.signals-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
@ -244,16 +211,16 @@
}
.signal-card {
background: var(--bg-secondary);
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 4px;
border-radius: 8px;
padding: 20px;
transition: all 0.2s;
}
.signal-card:hover {
border-color: var(--accent);
box-shadow: 0 4px 12px rgba(0, 200, 150, 0.1);
border-color: var(--primary);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
.signal-header {
@ -270,8 +237,8 @@
}
.signal-symbol {
font-size: 20px;
font-weight: 500;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
@ -280,7 +247,7 @@
padding: 3px 8px;
background: var(--bg-tertiary);
color: var(--text-secondary);
border-radius: 2px;
border-radius: 4px;
}
.signal-action-group {
@ -291,55 +258,55 @@
.signal-action {
padding: 6px 16px;
border-radius: 2px;
border-radius: 4px;
font-size: 14px;
font-weight: 500;
font-weight: 600;
}
.signal-action.buy {
background: rgba(0, 255, 65, 0.1);
color: #00ff41;
background: var(--success-light);
color: var(--success);
}
.signal-action.sell {
background: rgba(255, 68, 68, 0.1);
color: #ff4444;
background: var(--error-light);
color: var(--error);
}
.signal-action.hold {
background: rgba(255, 255, 255, 0.1);
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.signal-grade {
display: inline-block;
padding: 4px 10px;
border-radius: 2px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
font-weight: 600;
}
.signal-grade.A {
background: linear-gradient(135deg, rgba(255, 215, 0, 0.2), rgba(255, 179, 0, 0.2));
color: gold;
background: var(--warning-light);
color: var(--warning);
}
.signal-grade.B {
background: linear-gradient(135deg, rgba(192, 192, 192, 0.2), rgba(160, 160, 160, 0.2));
color: silver;
background: #E5E7EB;
color: #6B7280;
}
.signal-grade.C {
background: linear-gradient(135deg, rgba(205, 127, 50, 0.2), rgba(160, 82, 45, 0.2));
color: #cd7f32;
background: #FED7AA;
color: #EA580C;
}
.signal-grade.D {
background: rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
background: var(--error-light);
color: var(--error);
}
/* 置信度条 */
/* Confidence */
.confidence-section {
margin-bottom: 16px;
}
@ -354,8 +321,8 @@
}
.confidence-value {
color: var(--accent);
font-weight: 500;
color: var(--primary);
font-weight: 600;
}
.confidence-bar {
@ -367,11 +334,11 @@
.confidence-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent) 0%, #00ff41 100%);
background: var(--primary);
transition: width 0.3s;
}
/* 价格信息 */
/* Price Section */
.price-section {
display: grid;
grid-template-columns: repeat(3, 1fr);
@ -395,10 +362,10 @@
.price-value {
font-size: 14px;
color: var(--text-primary);
font-family: monospace;
font-family: 'Courier New', monospace;
}
/* 信号详情 */
/* Signal Details */
.signal-details {
margin-bottom: 12px;
}
@ -419,7 +386,7 @@
flex: 1;
}
/* 分析理由 */
/* Reason */
.signal-reason {
background: var(--bg-tertiary);
border-radius: 4px;
@ -439,13 +406,13 @@
line-height: 1.5;
}
/* 时间戳 */
/* Time */
.signal-time {
font-size: 11px;
color: var(--text-secondary);
color: var(--text-tertiary);
}
/* 空状态 */
/* Empty State */
.empty-state {
text-align: center;
padding: 60px 20px;
@ -456,32 +423,20 @@
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.5;
opacity: 0.3;
}
/* 加载状态 */
/* Loading */
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
/* 响应式设计 */
/* Responsive */
@media (max-width: 768px) {
.signals-page {
padding: 10px;
overflow-x: hidden;
}
.signals-container {
min-width: auto;
max-width: 100%;
overflow-x: hidden;
}
/* 取消顶部固定 */
.sticky-header {
position: static;
padding: 20px 16px;
}
.signals-header {
@ -491,12 +446,7 @@
}
.signals-title {
font-size: 18px;
}
.signals-title span {
display: block;
font-size: 14px;
font-size: 22px;
}
.stats-grid {
@ -505,18 +455,15 @@
}
.stat-card {
padding: 12px;
min-width: 0;
padding: 14px;
}
.stat-value {
font-size: 16px;
word-break: break-all;
font-size: 22px;
}
.tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.tab {
@ -534,218 +481,156 @@
gap: 8px;
}
}
@media (max-width: 480px) {
.stats-grid {
grid-template-columns: 1fr;
}
.signals-title {
font-size: 16px;
}
.stat-value {
font-size: 16px;
}
}
/* 等级统计 */
.grade-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.grade-stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 4px;
padding: 16px;
}
.grade-stat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.grade-stat-title {
font-size: 14px;
color: var(--text-primary);
}
.grade-stat-count {
font-size: 20px;
font-weight: 300;
color: var(--accent);
}
.grade-stat-details {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 12px;
}
.grade-stat-row {
display: flex;
justify-content: space-between;
color: var(--text-secondary);
}
</style>
</head>
<body>
<div id="app">
<div class="signals-page">
<div class="signals-container">
<!-- 固定顶部区域 -->
<div class="sticky-header">
<!-- 头部 -->
<div class="signals-header">
<h1 class="signals-title">交易信号中心 <span>| Trading Signals</span></h1>
<button class="refresh-btn" @click="loadSignals">刷新</button>
</div>
<!-- Header -->
<div class="signals-header">
<h1 class="signals-title">
交易信号中心
<span class="live-dot"></span>
</h1>
<button class="refresh-btn" @click="loadSignals">刷新</button>
</div>
<!-- 统计卡片 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">加密货币信号</div>
<div class="stat-value">{{ stats.crypto.total }}</div>
<div class="stat-label" style="margin-top: 6px;">最近24小时: {{ stats.crypto.recent_24h }}</div>
<!-- Stats Grid -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">加密货币信号</div>
<div class="stat-value">{{ stats.crypto.total }}</div>
<div class="stat-sub">24小时: {{ stats.crypto.recent_24h }}</div>
</div>
<div class="stat-card">
<div class="stat-label">美股信号</div>
<div class="stat-value">{{ stats.stock.total }}</div>
<div class="stat-sub">24小时: {{ stats.stock.recent_24h }}</div>
</div>
<div class="stat-card">
<div class="stat-label">总信号数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<div class="stat-card">
<div class="stat-label">买入信号</div>
<div class="stat-value positive">{{ stats.crypto.buy + stats.stock.buy }}</div>
</div>
<div class="stat-card">
<div class="stat-label">卖出信号</div>
<div class="stat-value negative">{{ stats.crypto.sell + stats.stock.sell }}</div>
</div>
</div>
<!-- Grade Stats -->
<div class="grade-stats" v-if="Object.keys(stats.grades).length > 0">
<div class="grade-stat-card" v-for="(count, grade) in stats.grades" :key="grade">
<div class="grade-stat-header">
<span class="grade-stat-title">
<span class="signal-grade" :class="grade">{{ grade }}</span> 级信号
</span>
<span class="grade-stat-count">{{ count }}</span>
</div>
</div>
</div>
<!-- Tabs -->
<div class="tabs">
<button class="tab" :class="{ active: currentTab === 'crypto' }" @click="switchTab('crypto')">
加密货币 ({{ cryptoSignals.length }})
</button>
<button class="tab" :class="{ active: currentTab === 'stock' }" @click="switchTab('stock')">
美股 ({{ stockSignals.length }})
</button>
<button class="tab" :class="{ active: currentTab === 'all' }" @click="switchTab('all')">
全部信号 ({{ allSignals.length }})
</button>
</div>
<!-- Signals List -->
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="currentSignals.length === 0" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
<p>暂无信号</p>
</div>
<div v-else class="signals-grid">
<div v-for="signal in currentSignals" :key="signal.id" class="signal-card">
<!-- Header -->
<div class="signal-header">
<div class="signal-symbol-group">
<span class="signal-symbol">{{ signal.symbol }}</span>
<span class="signal-type-badge">{{ signal.signal_type === 'crypto' ? '加密货币' : '美股' }}</span>
</div>
<div class="stat-card">
<div class="stat-label">美股信号</div>
<div class="stat-value">{{ stats.stock.total }}</div>
<div class="stat-label" style="margin-top: 6px;">最近24小时: {{ stats.stock.recent_24h }}</div>
</div>
<div class="stat-card">
<div class="stat-label">总信号数</div>
<div class="stat-value">{{ stats.total }}</div>
</div>
<div class="stat-card">
<div class="stat-label">买入信号</div>
<div class="stat-value positive">{{ stats.crypto.buy + stats.stock.buy }}</div>
</div>
<div class="stat-card">
<div class="stat-label">卖出信号</div>
<div class="stat-value negative">{{ stats.crypto.sell + stats.stock.sell }}</div>
<div class="signal-action-group">
<span class="signal-action" :class="signal.action">
{{ signal.action === 'buy' ? '做多' : signal.action === 'sell' ? '做空' : '持有' }}
</span>
<span class="signal-grade" :class="signal.grade">{{ signal.grade }}级</span>
</div>
</div>
<!-- 等级统计 -->
<div class="grade-stats" v-if="Object.keys(stats.grades).length > 0">
<div class="grade-stat-card" v-for="(count, grade) in stats.grades" :key="grade">
<div class="grade-stat-header">
<span class="grade-stat-title">
<span class="signal-grade" :class="grade">{{ grade }}</span> 级信号
</span>
<span class="grade-stat-count">{{ count }}</span>
</div>
<!-- Confidence -->
<div class="confidence-section">
<div class="confidence-label">
<span>置信度</span>
<span class="confidence-value">{{ signal.confidence }}%</span>
</div>
<div class="confidence-bar">
<div class="confidence-fill" :style="{ width: signal.confidence + '%' }"></div>
</div>
</div>
</div>
<!-- 标签页 -->
<div class="tabs">
<button class="tab" :class="{ active: currentTab === 'crypto' }" @click="switchTab('crypto')">
加密货币 ({{ cryptoSignals.length }})
</button>
<button class="tab" :class="{ active: currentTab === 'stock' }" @click="switchTab('stock')">
美股 ({{ stockSignals.length }})
</button>
<button class="tab" :class="{ active: currentTab === 'all' }" @click="switchTab('all')">
全部信号 ({{ allSignals.length }})
</button>
</div>
<!-- 信号列表 -->
<div v-if="loading" class="loading">加载中...</div>
<div v-else-if="currentSignals.length === 0" class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
<p>暂无信号</p>
</div>
<div v-else class="signals-grid">
<div v-for="signal in currentSignals" :key="signal.id" class="signal-card" :class="signal.action">
<!-- 信号头部 -->
<div class="signal-header">
<div class="signal-symbol-group">
<span class="signal-symbol">{{ signal.symbol }}</span>
<span class="signal-type-badge">{{ signal.signal_type === 'crypto' ? '加密货币' : '美股' }}</span>
</div>
<div class="signal-action-group">
<span class="signal-action" :class="signal.action">
{{ signal.action === 'buy' ? '做多' : signal.action === 'sell' ? '做空' : '持有' }}
</span>
<span class="signal-grade" :class="signal.grade">{{ signal.grade }}级</span>
</div>
<!-- Price Section -->
<div class="price-section" v-if="getEntryPrice(signal) || signal.stop_loss || signal.take_profit">
<div class="price-item" v-if="getEntryPrice(signal)">
<div class="price-label">{{ getEntryPriceLabel(signal) }}</div>
<div class="price-value">${{ getEntryPrice(signal).toFixed(2) }}</div>
</div>
<!-- 置信度 -->
<div class="confidence-section">
<div class="confidence-label">
<span>置信度</span>
<span class="confidence-value">{{ signal.confidence }}%</span>
</div>
<div class="confidence-bar">
<div class="confidence-fill" :style="{ width: signal.confidence + '%' }"></div>
</div>
<div class="price-item" v-if="signal.stop_loss">
<div class="price-label">止损</div>
<div class="price-value">${{ signal.stop_loss?.toFixed(2) }}</div>
</div>
<!-- 价格信息 -->
<div class="price-section" v-if="getEntryPrice(signal) || signal.stop_loss || signal.take_profit">
<div class="price-item" v-if="getEntryPrice(signal)">
<div class="price-label">{{ getEntryPriceLabel(signal) }}</div>
<div class="price-value">${{ getEntryPrice(signal).toFixed(2) }}</div>
</div>
<div class="price-item" v-if="signal.stop_loss">
<div class="price-label">止损</div>
<div class="price-value">${{ signal.stop_loss?.toFixed(2) }}</div>
</div>
<div class="price-item" v-if="signal.take_profit">
<div class="price-label">止盈</div>
<div class="price-value">${{ signal.take_profit?.toFixed(2) }}</div>
</div>
<div class="price-item" v-if="signal.take_profit">
<div class="price-label">止盈</div>
<div class="price-value">${{ signal.take_profit?.toFixed(2) }}</div>
</div>
</div>
<!-- 信号详情 -->
<div class="signal-details" v-if="signal.signal_type_detail || signal.entry_type || signal.position_size || signal.current_price">
<div class="detail-row" v-if="signal.current_price">
<span class="detail-label">当前价:</span>
<span class="detail-value">${{ signal.current_price?.toFixed(2) }}</span>
</div>
<div class="detail-row" v-if="signal.signal_type_detail">
<span class="detail-label">周期:</span>
<span class="detail-value">{{ getSignalTypeText(signal.signal_type_detail) }}</span>
</div>
<div class="detail-row" v-if="signal.entry_type">
<span class="detail-label">入场:</span>
<span class="detail-value">{{ getEntryTypeText(signal.entry_type) }}</span>
</div>
<div class="detail-row" v-if="signal.position_size">
<span class="detail-label">仓位:</span>
<span class="detail-value">{{ getPositionSizeText(signal.position_size) }}</span>
</div>
<div class="detail-row" v-if="signal.news_sentiment">
<span class="detail-label">情绪:</span>
<span class="detail-value">{{ getNewsSentimentText(signal.news_sentiment) }}</span>
</div>
<!-- Details -->
<div class="signal-details" v-if="signal.signal_type_detail || signal.entry_type || signal.position_size || signal.current_price">
<div class="detail-row" v-if="signal.current_price">
<span class="detail-label">当前价:</span>
<span class="detail-value">${{ signal.current_price?.toFixed(2) }}</span>
</div>
<div class="detail-row" v-if="signal.signal_type_detail">
<span class="detail-label">周期:</span>
<span class="detail-value">{{ getSignalTypeText(signal.signal_type_detail) }}</span>
</div>
<div class="detail-row" v-if="signal.entry_type">
<span class="detail-label">入场:</span>
<span class="detail-value">{{ getEntryTypeText(signal.entry_type) }}</span>
</div>
<div class="detail-row" v-if="signal.position_size">
<span class="detail-label">仓位:</span>
<span class="detail-value">{{ getPositionSizeText(signal.position_size) }}</span>
</div>
<div class="detail-row" v-if="signal.news_sentiment">
<span class="detail-label">情绪:</span>
<span class="detail-value">{{ getNewsSentimentText(signal.news_sentiment) }}</span>
</div>
</div>
<!-- 分析理由(合并 reason 和 analysis_summary -->
<div class="signal-reason" v-if="getCombinedReason(signal)">
<div class="reason-label">分析理由</div>
<div class="reason-text">{{ getCombinedReason(signal) }}</div>
</div>
<!-- Reason -->
<div class="signal-reason" v-if="getCombinedReason(signal)">
<div class="reason-label">分析理由</div>
<div class="reason-text">{{ getCombinedReason(signal) }}</div>
</div>
<!-- 时间戳 -->
<div class="signal-time">
{{ formatTime(signal.timestamp || signal.created_at) }}
</div>
<!-- Time -->
<div class="signal-time">
{{ formatTime(signal.timestamp || signal.created_at) }}
</div>
</div>
</div>
@ -866,17 +751,13 @@
return map[size] || size;
},
// 获取入场价格(限价单显示挂单价格,市价单显示入场价)
getEntryPrice(signal) {
// 如果是限价单且有挂单价格,优先显示挂单价格
if (signal.entry_type === 'limit' && signal.entry_zone) {
return signal.entry_zone;
}
// 否则显示普通入场价
return signal.entry_price;
},
// 获取入场价格标签
getEntryPriceLabel(signal) {
if (signal.entry_type === 'limit' && signal.entry_zone) {
return '挂单价';
@ -884,7 +765,6 @@
return '入场价';
},
// 获取新闻情绪文本
getNewsSentimentText(sentiment) {
const map = {
'bullish': '看涨',
@ -896,27 +776,12 @@
return map[sentiment] || sentiment;
},
// 格式化价位数据
formatLevels(levels) {
if (Array.isArray(levels)) {
return levels.map(l => '$' + l.toFixed(2)).join(', ');
} else if (typeof levels === 'number') {
return '$' + levels.toFixed(2);
} else if (typeof levels === 'string') {
return levels;
}
return '';
},
// 合并理由和分析摘要
getCombinedReason(signal) {
// 优先使用 analysis_summary如果不存在则使用 reason
return signal.analysis_summary || signal.reason || '';
}
},
mounted() {
this.loadSignals();
// 每30秒自动刷新
this.refreshInterval = setInterval(() => {
this.loadSignals();
}, 30000);

File diff suppressed because it is too large Load Diff