Initial commit: 可转债轮动分析系统
- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换) - 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制) - 增量同步:支持 -incr/-since 参数按存档日期增量入库 - 策略回测:净值曲线/10指标/基准对比,含基本面过滤 - 用户认证:用户名密码 + 邮箱验证码双登录方式 - 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
626
web/auth.html
Normal file
626
web/auth.html
Normal file
@@ -0,0 +1,626 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 / 注册 - 可转债轮动分析系统</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<style>
|
||||
.auth-bg {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: linear-gradient(135deg, var(--navy) 0%, #0F2240 50%, var(--navy-light) 100%);
|
||||
z-index: -1;
|
||||
}
|
||||
.auth-bg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%; left: -50%;
|
||||
width: 200%; height: 200%;
|
||||
background: radial-gradient(ellipse at 30% 20%, rgba(30,95,187,0.15) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 70% 80%, rgba(0,122,94,0.08) 0%, transparent 50%);
|
||||
animation: bgShift 30s ease-in-out infinite alternate;
|
||||
}
|
||||
@keyframes bgShift {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(-5%, -5%); }
|
||||
}
|
||||
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--white);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 40px rgba(13,27,42,0.20);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 36px 32px 28px;
|
||||
animation: cardIn 0.4s ease-out;
|
||||
}
|
||||
@keyframes cardIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.auth-header .logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, var(--blue), var(--navy));
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 14px;
|
||||
font-size: 22px;
|
||||
color: var(--white);
|
||||
font-weight: 700;
|
||||
}
|
||||
.auth-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--navy);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.auth-header p {
|
||||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: flex;
|
||||
background: var(--gray-50);
|
||||
border-radius: 10px;
|
||||
padding: 4px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.auth-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
color: var(--gray-500);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
transition: all var(--transition);
|
||||
user-select: none;
|
||||
}
|
||||
.auth-tab.active {
|
||||
background: var(--white);
|
||||
color: var(--blue);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 4px rgba(13,27,42,0.08);
|
||||
}
|
||||
|
||||
.login-method-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--gray-100);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.login-method-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 9px 0;
|
||||
cursor: pointer;
|
||||
color: var(--gray-500);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition);
|
||||
user-select: none;
|
||||
background: var(--gray-50);
|
||||
}
|
||||
.login-method-tab:first-child {
|
||||
border-right: 1px solid var(--gray-100);
|
||||
}
|
||||
.login-method-tab.active {
|
||||
background: var(--white);
|
||||
color: var(--blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.auth-form .form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 16px;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
.auth-form .form-item[style*="display:none"],
|
||||
.auth-form .form-item[style*="display: none"] {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.auth-form label {
|
||||
font-size: 12px;
|
||||
color: var(--gray-700);
|
||||
font-weight: 500;
|
||||
}
|
||||
.auth-form .input-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.auth-form input {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: var(--gray-900);
|
||||
background: var(--gray-50);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.auth-form input:focus {
|
||||
border-color: var(--blue);
|
||||
background: var(--white);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--blue-light);
|
||||
}
|
||||
.auth-form input::placeholder {
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.code-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.code-input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
.code-input-group button {
|
||||
white-space: nowrap;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--gray-500);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.password-toggle:hover {
|
||||
color: var(--gray-700);
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 20px;
|
||||
}
|
||||
.auth-hint a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
.auth-hint a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--blue), #1A52A3);
|
||||
color: var(--white);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
box-shadow: 0 2px 8px rgba(30,95,187,0.3);
|
||||
position: relative;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px rgba(30,95,187,0.4);
|
||||
}
|
||||
.btn-primary:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary .spinner {
|
||||
display: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-top-color: var(--white);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.btn-primary.loading .btn-text { display: none; }
|
||||
.btn-primary.loading .spinner { display: block; }
|
||||
|
||||
.btn-default {
|
||||
padding: 9px 16px;
|
||||
border: 1.5px solid var(--gray-200);
|
||||
border-radius: 8px;
|
||||
background: var(--white);
|
||||
color: var(--gray-700);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.btn-default:hover:not(:disabled) {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue);
|
||||
background: var(--blue-light);
|
||||
}
|
||||
.btn-default:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--red);
|
||||
background: var(--red-light);
|
||||
border: 1px solid #F5A3B0;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
display: none;
|
||||
animation: shake 0.3s ease-out;
|
||||
}
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-4px); }
|
||||
75% { transform: translateX(4px); }
|
||||
}
|
||||
|
||||
/* 验证码成功提示 */
|
||||
.error.success {
|
||||
background: var(--green-light, #E6F4F0) !important;
|
||||
border-color: #A3D9B1 !important;
|
||||
color: var(--green, #007A5E) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.auth-card { padding: 28px 20px 24px; }
|
||||
.auth-header .logo { width: 40px; height: 40px; font-size: 18px; }
|
||||
.auth-header h2 { font-size: 17px; }
|
||||
.code-input-group { flex-direction: column; }
|
||||
.code-input-group button { min-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-bg"></div>
|
||||
<div class="auth-page">
|
||||
<div class="auth-card">
|
||||
<div class="auth-header">
|
||||
<div class="logo">KZ</div>
|
||||
<h2>可转债轮动分析系统</h2>
|
||||
<p>登录 / 注册</p>
|
||||
</div>
|
||||
|
||||
<div class="auth-tabs">
|
||||
<div class="auth-tab active" data-mode="login">登录</div>
|
||||
<div class="auth-tab" data-mode="register">注册</div>
|
||||
</div>
|
||||
|
||||
<form id="auth-form" class="auth-form">
|
||||
<div class="form-item" id="username-row" style="display:none">
|
||||
<label>用户名</label>
|
||||
<input type="text" id="username" placeholder="输入用户名">
|
||||
</div>
|
||||
|
||||
<div class="form-item" id="email-row">
|
||||
<label>邮箱</label>
|
||||
<input type="email" id="email" placeholder="name@example.com">
|
||||
</div>
|
||||
|
||||
<!-- 登录方式切换:仅登录模式显示 -->
|
||||
<div class="login-method-tabs" id="login-method-tabs" style="display:none">
|
||||
<div class="login-method-tab active" data-method="username">密码登录</div>
|
||||
<div class="login-method-tab" data-method="email">验证码登录</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item code-row" id="code-row" style="display:none">
|
||||
<label>验证码</label>
|
||||
<div class="code-input-group">
|
||||
<input type="text" id="code" placeholder="6位验证码" maxlength="6">
|
||||
<button type="button" class="btn-default" id="send-code">获取验证码</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item password-row" id="password-row" style="display:none">
|
||||
<label>密码</label>
|
||||
<div class="input-wrap">
|
||||
<input type="password" id="password" placeholder="不少于6位">
|
||||
<button type="button" class="password-toggle" id="pwd-toggle" onclick="togglePassword()">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item" id="login-hint" style="display:none">
|
||||
<span style="font-size:12px;color:var(--gray-500)">管理员账号:admin / 123456</span>
|
||||
</div>
|
||||
|
||||
<div id="auth-error" class="error" style="display:none"></div>
|
||||
|
||||
<button type="button" class="btn-primary" id="submit-btn" onclick="handleAuthSubmit()">
|
||||
<span class="btn-text">登录</span>
|
||||
<div class="spinner"></div>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-hint">
|
||||
登录即表示同意 <a href="#">服务条款</a> 与 <a href="#">隐私政策</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
console.log('[auth] page script loaded');
|
||||
const modeTabs = document.querySelectorAll('.auth-tab');
|
||||
const methodTabs = document.querySelectorAll('.login-method-tab');
|
||||
const methodTabsWrap = document.getElementById('login-method-tabs');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const usernameRow = document.getElementById('username-row');
|
||||
const emailRow = document.getElementById('email-row');
|
||||
const codeRow = document.getElementById('code-row');
|
||||
const passwordRow = document.getElementById('password-row');
|
||||
const sendBtn = document.getElementById('send-code');
|
||||
const form = document.getElementById('auth-form');
|
||||
const errorEl = document.getElementById('auth-error');
|
||||
const usernameInput = document.getElementById('username');
|
||||
const emailInput = document.getElementById('email');
|
||||
const codeInput = document.getElementById('code');
|
||||
const passwordInput = document.getElementById('password');
|
||||
const loginHint = document.getElementById('login-hint');
|
||||
|
||||
let mode = 'login';
|
||||
let loginMethod = 'username'; // 默认密码登录
|
||||
|
||||
function togglePassword() {
|
||||
const pwd = document.getElementById('password');
|
||||
const btn = document.getElementById('pwd-toggle');
|
||||
if (pwd.type === 'password') {
|
||||
pwd.type = 'text';
|
||||
btn.textContent = '🙈';
|
||||
} else {
|
||||
pwd.type = 'password';
|
||||
btn.textContent = '👁';
|
||||
}
|
||||
}
|
||||
|
||||
function applyMode() {
|
||||
const btnText = submitBtn.querySelector('.btn-text');
|
||||
btnText.textContent = mode === 'login' ? '登录' : '注册';
|
||||
const isLogin = mode === 'login';
|
||||
const isEmailLogin = isLogin && loginMethod === 'email';
|
||||
const isUsernameLogin = isLogin && loginMethod === 'username';
|
||||
|
||||
// 用户名行:用户名登录模式 或 注册模式显示
|
||||
usernameRow.style.display = isUsernameLogin || mode === 'register' ? 'flex' : 'none';
|
||||
usernameInput.required = isUsernameLogin || mode === 'register';
|
||||
|
||||
// 邮箱行:邮箱登录 或 注册模式显示
|
||||
emailRow.style.display = isEmailLogin || mode === 'register' ? 'flex' : 'none';
|
||||
emailInput.required = isEmailLogin || mode === 'register';
|
||||
|
||||
// 验证码行:邮箱登录 或 注册模式显示
|
||||
codeRow.style.display = isEmailLogin || mode === 'register' ? 'flex' : 'none';
|
||||
codeInput.required = isEmailLogin || mode === 'register';
|
||||
|
||||
// 密码行:用户名登录 或 注册模式显示
|
||||
passwordRow.style.display = isUsernameLogin || mode === 'register' ? 'flex' : 'none';
|
||||
passwordInput.required = isUsernameLogin || mode === 'register';
|
||||
|
||||
// 登录方式切换仅登录模式显示
|
||||
methodTabsWrap.style.display = isLogin ? 'flex' : 'none';
|
||||
// 提示信息
|
||||
loginHint.style.display = isUsernameLogin ? 'flex' : 'none';
|
||||
|
||||
errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
modeTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
modeTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
mode = tab.dataset.mode;
|
||||
applyMode();
|
||||
});
|
||||
});
|
||||
|
||||
methodTabs.forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
methodTabs.forEach(t => t.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
loginMethod = tab.dataset.method;
|
||||
applyMode();
|
||||
});
|
||||
});
|
||||
|
||||
function showError(msg, type) {
|
||||
errorEl.textContent = msg;
|
||||
errorEl.style.display = 'block';
|
||||
if (type === 'success') {
|
||||
errorEl.style.background = 'var(--green-light)';
|
||||
errorEl.style.borderColor = '#A3D9B1';
|
||||
errorEl.style.color = 'var(--green)';
|
||||
} else {
|
||||
errorEl.style.background = 'var(--red-light)';
|
||||
errorEl.style.borderColor = '#F5A3B0';
|
||||
errorEl.style.color = 'var(--red)';
|
||||
}
|
||||
}
|
||||
|
||||
function clearError() {
|
||||
errorEl.style.display = 'none';
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
clearError();
|
||||
const email = emailInput.value.trim();
|
||||
if (!email) {
|
||||
showError('请输入邮箱');
|
||||
emailInput.focus();
|
||||
return;
|
||||
}
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = '发送中...';
|
||||
try {
|
||||
const resp = await fetch('/api/auth/send-code', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, purpose: mode }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || '发送失败');
|
||||
|
||||
showError('验证码已发送到邮箱,请查看服务器日志', 'success');
|
||||
let sec = 60;
|
||||
sendBtn.textContent = sec + 's';
|
||||
const timer = setInterval(() => {
|
||||
sec--;
|
||||
if (sec <= 0) {
|
||||
clearInterval(timer);
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = '获取验证码';
|
||||
} else {
|
||||
sendBtn.textContent = sec + 's';
|
||||
}
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
showError(e.message);
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = '获取验证码';
|
||||
}
|
||||
});
|
||||
|
||||
async function handleAuthSubmit() {
|
||||
clearError();
|
||||
const username = usernameInput.value.trim();
|
||||
const email = emailInput.value.trim();
|
||||
const code = codeInput.value.trim();
|
||||
const password = passwordInput.value;
|
||||
|
||||
let endpoint, body;
|
||||
if (mode === 'register') {
|
||||
if (!username || !email || !code || !password) {
|
||||
showError('请填写所有字段');
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
showError('密码长度不能少于6位');
|
||||
return;
|
||||
}
|
||||
endpoint = '/api/auth/register';
|
||||
body = { username, email, code, password };
|
||||
} else if (loginMethod === 'username') {
|
||||
if (!username) {
|
||||
showError('请输入用户名');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
showError('请输入密码');
|
||||
return;
|
||||
}
|
||||
endpoint = '/api/auth/login';
|
||||
body = { username, password };
|
||||
} else {
|
||||
// 邮箱登录
|
||||
if (!email) {
|
||||
showError('请输入邮箱');
|
||||
return;
|
||||
}
|
||||
if (!code) {
|
||||
showError('请输入验证码');
|
||||
return;
|
||||
}
|
||||
endpoint = '/api/auth/login';
|
||||
body = { email, code };
|
||||
}
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.classList.add('loading');
|
||||
try {
|
||||
console.log('[auth] sending', endpoint, JSON.stringify(body));
|
||||
const resp = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
console.log('[auth] response status', resp.status);
|
||||
const data = await resp.json();
|
||||
console.log('[auth] response data', data);
|
||||
if (!resp.ok) {
|
||||
throw new Error(data.error || ('请求失败: ' + resp.status));
|
||||
}
|
||||
if (!data.token) {
|
||||
throw new Error('响应中缺少 token');
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_token', data.token);
|
||||
localStorage.setItem('auth_username', data.username || '');
|
||||
localStorage.setItem('auth_email', data.email || email);
|
||||
// 设置 cookie 供服务端 requireAuth 中间件校验
|
||||
document.cookie = 'auth_token=' + data.token + '; path=/; max-age=86400';
|
||||
console.log('[auth] redirecting to /');
|
||||
window.location.href = '/';
|
||||
} catch (e) {
|
||||
console.error('[auth] error', e);
|
||||
showError(e.message || '请求失败,请稍后重试');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容回车提交
|
||||
form.addEventListener('submit', e => {
|
||||
e.preventDefault();
|
||||
handleAuthSubmit();
|
||||
});
|
||||
|
||||
// 自动聚焦第一个可见输入框
|
||||
function autoFocus() {
|
||||
const visibleInputs = Array.from(form.querySelectorAll('input:not([type="hidden"])'))
|
||||
.filter(inp => inp.offsetParent !== null);
|
||||
if (visibleInputs.length > 0) {
|
||||
setTimeout(() => visibleInputs[0].focus(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
// 输入框切换时自动聚焦
|
||||
const observer = new MutationObserver(() => autoFocus());
|
||||
observer.observe(document.getElementById('username-row'), { attributes: true, attributeFilter: ['style'] });
|
||||
observer.observe(document.getElementById('email-row'), { attributes: true, attributeFilter: ['style'] });
|
||||
observer.observe(document.getElementById('code-row'), { attributes: true, attributeFilter: ['style'] });
|
||||
observer.observe(document.getElementById('password-row'), { attributes: true, attributeFilter: ['style'] });
|
||||
|
||||
applyMode();
|
||||
autoFocus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
963
web/index.html
Normal file
963
web/index.html
Normal file
@@ -0,0 +1,963 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>可转债轮动分析系统</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
/* ============ 四大事务所风格全局样式 ============ */
|
||||
/* ============ Header ============ */
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--navy) 0%, var(--navy-light) 100%);
|
||||
color: var(--white);
|
||||
padding: 16px 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2px 12px rgba(13,27,42,0.15);
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 16px; }
|
||||
.header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.header .subtitle {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* ============ Tabs ============ */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: var(--white);
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
padding: 0 32px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
.tab {
|
||||
padding: 14px 28px;
|
||||
cursor: pointer;
|
||||
color: var(--gray-500);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
transition: color var(--transition);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.tab::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 3px;
|
||||
background: var(--blue);
|
||||
border-radius: 3px 3px 0 0;
|
||||
transition: width var(--transition);
|
||||
}
|
||||
.tab:hover { color: var(--navy); }
|
||||
.tab.active {
|
||||
color: var(--blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tab.active::after { width: 60%; }
|
||||
|
||||
/* ============ Content ============ */
|
||||
.content { padding: 24px 32px; max-width: 1440px; margin: 0 auto; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* ============ Toolbar ============ */
|
||||
.toolbar { margin-bottom: 16px; display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.toolbar input, .toolbar select {
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--gray-900);
|
||||
background: var(--white);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.toolbar input:focus, .toolbar select:focus {
|
||||
border-color: var(--blue);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--blue-light);
|
||||
}
|
||||
.toolbar .spacer { flex: 1; }
|
||||
#status { color: var(--gray-500); font-size: 13px; }
|
||||
|
||||
/* ============ Table ============ */
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
background: var(--white);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-100);
|
||||
}
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
||||
th, td {
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
padding: 10px 14px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
th {
|
||||
background: var(--gray-50);
|
||||
color: var(--gray-700);
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
th:hover { background: #E8ECF2; }
|
||||
th .sort-arrow { margin-left: 4px; font-size: 10px; color: var(--gray-500); }
|
||||
td:first-child, td:nth-child(2), th:first-child, th:nth-child(2) { text-align: left; }
|
||||
tr:hover { background: var(--blue-light); }
|
||||
.pos { color: var(--red); font-weight: 600; }
|
||||
.neg { color: var(--green); font-weight: 600; }
|
||||
|
||||
/* ============ Form Grid ============ */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 14px;
|
||||
background: var(--white);
|
||||
padding: 20px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-100);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.form-item { display: flex; flex-direction: column; gap: 5px; }
|
||||
.form-item label {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.form-item input, .form-item select {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 13px;
|
||||
color: var(--gray-900);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.form-item input:focus, .form-item select:focus {
|
||||
border-color: var(--blue);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--blue-light);
|
||||
}
|
||||
|
||||
/* ============ Metrics Cards ============ */
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.metric-card {
|
||||
background: var(--white);
|
||||
padding: 18px 16px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-100);
|
||||
text-align: center;
|
||||
transition: box-shadow var(--transition), transform var(--transition);
|
||||
}
|
||||
.metric-card:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.metric-card .label {
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.metric-card .value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.metric-card .value.pos { color: var(--red); }
|
||||
.metric-card .value.neg { color: var(--green); }
|
||||
|
||||
/* ============ Chart ============ */
|
||||
.chart-wrap {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-100);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.chart-wrap canvas { max-height: 420px; }
|
||||
|
||||
/* ============ Data Status ============ */
|
||||
.status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.status-card {
|
||||
background: var(--white);
|
||||
padding: 20px;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-100);
|
||||
}
|
||||
.status-card .label {
|
||||
font-size: 11px;
|
||||
color: var(--gray-500);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.status-card .value {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--navy);
|
||||
}
|
||||
|
||||
/* ============ Collapsible Sections ============ */
|
||||
.section-card {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--gray-100);
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 20px;
|
||||
background: var(--gray-50);
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.section-header:hover { background: #E8ECF2; }
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--navy);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.section-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--gray-500);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.section-toggle:hover { background: var(--gray-200); color: var(--navy); }
|
||||
.section-body {
|
||||
padding: 20px;
|
||||
transition: max-height 0.25s ease, padding 0.25s ease, opacity 0.2s ease;
|
||||
max-height: 2000px;
|
||||
opacity: 1;
|
||||
}
|
||||
.section-card.collapsed .section-body {
|
||||
max-height: 0;
|
||||
padding: 0 20px;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.section-card.collapsed .section-toggle { transform: rotate(0deg); }
|
||||
.section-toggle { transition: transform 0.2s ease; transform: rotate(0deg); }
|
||||
|
||||
/* ============ Feedback ============ */
|
||||
.loading {
|
||||
color: var(--gray-500);
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.error {
|
||||
color: var(--red);
|
||||
background: var(--red-light);
|
||||
border: 1px solid #F5A3B0;
|
||||
padding: 12px 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ============ Responsive ============ */
|
||||
@media (max-width: 768px) {
|
||||
.header { padding: 16px 20px; }
|
||||
.content { padding: 12px 16px; }
|
||||
.tabs { padding: 0 16px; }
|
||||
.form-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.metrics { grid-template-columns: repeat(2, 1fr); }
|
||||
.tab { padding: 12px 16px; font-size: 13px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>可转债轮动分析系统</h1>
|
||||
<div class="subtitle">实时行情 · 策略回测 · 数据管理</div>
|
||||
</div>
|
||||
<div class="header-user" id="header-user">
|
||||
<a href="/auth.html" id="login-link">登录 / 注册</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="market">实时行情</div>
|
||||
<div class="tab" data-tab="backtest">策略回测</div>
|
||||
<div class="tab" data-tab="data">数据管理</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时行情 -->
|
||||
<div class="content">
|
||||
<div id="tab-market" class="tab-content active">
|
||||
<div class="toolbar">
|
||||
<input type="text" id="search" placeholder="搜索代码/名称/正股...">
|
||||
<select id="sortBy">
|
||||
<option value="PremiumRate">按溢价率</option>
|
||||
<option value="Price">按价格</option>
|
||||
<option value="ChangePct">按涨跌</option>
|
||||
<option value="ConvertValue">按转股价值</option>
|
||||
</select>
|
||||
<select id="sortDir">
|
||||
<option value="asc">升序</option>
|
||||
<option value="desc">降序</option>
|
||||
</select>
|
||||
<button class="btn-primary" id="refresh">刷新数据</button>
|
||||
<span id="status"></span>
|
||||
</div>
|
||||
<div id="market-error" style="display:none"></div>
|
||||
<div class="table-wrap">
|
||||
<table id="bond-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="Code">代码</th>
|
||||
<th data-sort="Name">名称</th>
|
||||
<th data-sort="Price">价格</th>
|
||||
<th data-sort="ChangePct">涨跌%</th>
|
||||
<th data-sort="PremiumRate">溢价率%</th>
|
||||
<th data-sort="ConvertValue">转股价值</th>
|
||||
<th data-sort="ConvertPrice">转股价</th>
|
||||
<th data-sort="PureBondVal">纯债价值</th>
|
||||
<th data-sort="StockName">正股</th>
|
||||
<th data-sort="StockChangePct">正股涨跌%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 策略回测 -->
|
||||
<div id="tab-backtest" class="tab-content">
|
||||
<div class="section-card" data-section="params">
|
||||
<div class="section-header">
|
||||
<span class="section-title">策略参数</span>
|
||||
<button type="button" class="section-toggle" aria-label="折叠">−</button>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="form-grid">
|
||||
<div class="form-item">
|
||||
<label>策略</label>
|
||||
<select id="bt-strategy">
|
||||
<option value="lowprice">低价轮动</option>
|
||||
<option value="doublelow">双低轮动</option>
|
||||
<option value="premium">溢价率轮动</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>起始日期</label>
|
||||
<input type="date" id="bt-start" value="2020-01-01">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>结束日期</label>
|
||||
<input type="date" id="bt-end" value="2025-12-31">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>初始资金</label>
|
||||
<input type="number" id="bt-cash" value="100000" step="10000">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>持仓数量</label>
|
||||
<input type="number" id="bt-topn" value="10" min="1" max="50">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>调仓周期(交易日)</label>
|
||||
<input type="number" id="bt-rebalance" value="21" min="1" max="252">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>价格下限</label>
|
||||
<input type="number" id="bt-minprice" value="80" step="1">
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>价格上限</label>
|
||||
<input type="number" id="bt-maxprice" value="130" step="1">
|
||||
</div>
|
||||
<div class="form-item" id="bt-premium-group">
|
||||
<label>溢价率上限</label>
|
||||
<input type="number" id="bt-maxpremium" value="30" step="1">
|
||||
</div>
|
||||
<div class="form-item" style="justify-content:flex-end">
|
||||
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--gray-900);text-transform:none;letter-spacing:0">
|
||||
<input type="checkbox" id="bt-fundamental" style="width:16px;height:16px;cursor:pointer">
|
||||
基本面过滤(排除ST/高负债/亏损)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" data-section="bonds">
|
||||
<div class="section-header">
|
||||
<span class="section-title">转债选择</span>
|
||||
<button type="button" class="section-toggle" aria-label="折叠">−</button>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="toolbar" style="margin-top:0">
|
||||
<button class="btn-default" id="bt-select-all">全选</button>
|
||||
<button class="btn-default" id="bt-deselect-all">全不选</button>
|
||||
<input type="text" id="bt-bond-search" placeholder="搜索转债代码..." style="width:160px">
|
||||
<span id="bt-bond-count" style="color:#888;font-size:13px"></span>
|
||||
</div>
|
||||
<div class="table-wrap" style="max-height:200px;overflow-y:auto;margin-bottom:0">
|
||||
<table id="bt-bond-table">
|
||||
<thead><tr><th style="width:40px"><input type="checkbox" id="bt-check-all"></th><th>代码</th><th>名称</th><th>K线数</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar" style="margin-top:16px">
|
||||
<button class="btn-primary" id="bt-run">开始回测</button>
|
||||
<span id="bt-status"></span>
|
||||
</div>
|
||||
<div id="bt-error" style="display:none"></div>
|
||||
|
||||
<div class="section-card" id="bt-result-section" data-section="result" style="display:none">
|
||||
<div class="section-header">
|
||||
<span class="section-title">回测结果</span>
|
||||
<button type="button" class="section-toggle" aria-label="折叠">−</button>
|
||||
</div>
|
||||
<div class="section-body">
|
||||
<div class="metrics" id="bt-metrics"></div>
|
||||
<div class="chart-wrap" style="margin-bottom:0">
|
||||
<canvas id="bt-chart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据管理 -->
|
||||
<div id="tab-data" class="tab-content">
|
||||
<div class="toolbar">
|
||||
<button class="btn-primary" id="data-refresh">刷新状态</button>
|
||||
<span id="data-status-text"></span>
|
||||
</div>
|
||||
<div id="data-error" style="display:none"></div>
|
||||
<div class="status-grid" id="data-cards"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script>
|
||||
// ============ Toast 通知 ============
|
||||
function showToast(msg, type = 'info', duration = 3000) {
|
||||
const existing = document.querySelector('.toast');
|
||||
if (existing) existing.remove();
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast ' + type;
|
||||
const icons = { success: '✓', error: '✕', info: 'i' };
|
||||
toast.innerHTML = `<span style="font-weight:700;font-size:15px">${icons[type] || ''}</span><span>${msg}</span>`;
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => {
|
||||
toast.style.animation = 'toastOut 0.25s ease-in forwards';
|
||||
setTimeout(() => toast.remove(), 250);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
// ============ 登录状态 ============
|
||||
(function initAuth() {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const username = localStorage.getItem('auth_username');
|
||||
const email = localStorage.getItem('auth_email');
|
||||
if (!token) {
|
||||
window.location.href = '/auth.html';
|
||||
return;
|
||||
}
|
||||
const container = document.getElementById('header-user');
|
||||
const displayName = username || email || '用户';
|
||||
const initial = displayName.charAt(0).toUpperCase();
|
||||
container.innerHTML = `<div class="avatar">${initial}</div><span class="user-name">${displayName}</span><a href="#" id="logout-link">退出</a>`;
|
||||
document.getElementById('logout-link').addEventListener('click', async e => {
|
||||
e.preventDefault();
|
||||
try { await fetch('/api/auth/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); } catch (_) {}
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_username');
|
||||
localStorage.removeItem('auth_email');
|
||||
document.cookie = 'auth_token=; path=/; max-age=0';
|
||||
showToast('已退出登录', 'info');
|
||||
setTimeout(() => { window.location.href = '/auth.html'; }, 300);
|
||||
});
|
||||
})();
|
||||
|
||||
// ============ 统一 API 请求封装:自动注入 token,401 自动跳转登录 ============
|
||||
async function apiFetch(url, options = {}) {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
if (!token) {
|
||||
window.location.href = '/auth.html';
|
||||
throw new Error('未登录');
|
||||
}
|
||||
const headers = { ...(options.headers || {}) };
|
||||
headers['Authorization'] = 'Bearer ' + token;
|
||||
const resp = await fetch(url, { ...options, headers });
|
||||
if (resp.status === 401) {
|
||||
// token 失效,清空并跳转登录
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('auth_username');
|
||||
localStorage.removeItem('auth_email');
|
||||
document.cookie = 'auth_token=; path=/; max-age=0';
|
||||
showToast('登录已失效,请重新登录', 'error');
|
||||
setTimeout(() => { window.location.href = '/auth.html'; }, 500);
|
||||
throw new Error('登录已失效');
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
// ============ 标签页切换 ============
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
||||
if (tab.dataset.tab === 'data') loadDataStatus();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 实时行情 ============
|
||||
let allBonds = [];
|
||||
const statusEl = document.getElementById('status');
|
||||
const tbody = document.querySelector('#bond-table tbody');
|
||||
|
||||
function fmt(v, digits = 2) {
|
||||
return v === null || v === undefined ? '-' : Number(v).toFixed(digits);
|
||||
}
|
||||
function pctClass(v) {
|
||||
return v > 0 ? 'pos' : (v < 0 ? 'neg' : '');
|
||||
}
|
||||
|
||||
function renderTable(bonds) {
|
||||
if (bonds.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:20px;color:#888">暂无数据</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = bonds.map(b => `
|
||||
<tr>
|
||||
<td>${b.Code}</td><td>${b.Name}</td>
|
||||
<td>${fmt(b.Price)}</td>
|
||||
<td class="${pctClass(b.ChangePct)}">${fmt(b.ChangePct)}</td>
|
||||
<td class="${pctClass(b.PremiumRate)}">${fmt(b.PremiumRate)}</td>
|
||||
<td>${fmt(b.ConvertValue)}</td>
|
||||
<td>${fmt(b.ConvertPrice)}</td>
|
||||
<td>${fmt(b.PureBondVal)}</td>
|
||||
<td>${b.StockName}</td>
|
||||
<td class="${pctClass(b.StockChangePct)}">${fmt(b.StockChangePct)}</td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
|
||||
function sortBonds() {
|
||||
const key = document.getElementById('sortBy').value;
|
||||
const dir = document.getElementById('sortDir').value === 'asc' ? 1 : -1;
|
||||
const search = document.getElementById('search').value.toLowerCase();
|
||||
let filtered = allBonds;
|
||||
if (search) {
|
||||
filtered = allBonds.filter(b =>
|
||||
b.Code.toLowerCase().includes(search) ||
|
||||
b.Name.toLowerCase().includes(search) ||
|
||||
(b.StockName || '').toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
filtered.sort((a, b) => {
|
||||
const va = a[key] || 0, vb = b[key] || 0;
|
||||
return va > vb ? dir : va < vb ? -dir : 0;
|
||||
});
|
||||
renderTable(filtered);
|
||||
statusEl.textContent = `共 ${filtered.length} 只`;
|
||||
}
|
||||
|
||||
async function loadBonds() {
|
||||
statusEl.textContent = '加载中...';
|
||||
document.getElementById('market-error').style.display = 'none';
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:24px;color:#888">加载中...</td></tr>';
|
||||
try {
|
||||
const resp = await apiFetch('/api/bonds');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
allBonds = await resp.json();
|
||||
if (allBonds.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:24px;color:#888">暂无数据,请先通过数据入库获取可转债信息</td></tr>';
|
||||
statusEl.textContent = '暂无数据';
|
||||
return;
|
||||
}
|
||||
sortBonds();
|
||||
} catch (e) {
|
||||
document.getElementById('market-error').style.display = 'block';
|
||||
document.getElementById('market-error').innerHTML = '<div class="error">加载失败: ' + e.message + '</div>';
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:24px;color:#888">加载失败,请稍后重试</td></tr>';
|
||||
statusEl.textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('refresh').addEventListener('click', loadBonds);
|
||||
document.getElementById('search').addEventListener('input', sortBonds);
|
||||
document.getElementById('sortBy').addEventListener('change', sortBonds);
|
||||
document.getElementById('sortDir').addEventListener('change', sortBonds);
|
||||
|
||||
// 表头点击排序
|
||||
document.querySelectorAll('#bond-table th[data-sort]').forEach(th => {
|
||||
th.addEventListener('click', () => {
|
||||
const key = th.dataset.sort;
|
||||
document.getElementById('sortBy').value = key;
|
||||
const dirEl = document.getElementById('sortDir');
|
||||
dirEl.value = dirEl.value === 'asc' ? 'desc' : 'asc';
|
||||
sortBonds();
|
||||
});
|
||||
});
|
||||
|
||||
// ============ 策略回测 ============
|
||||
let btChart = null;
|
||||
let dbBonds = []; // 数据库中的转债列表
|
||||
|
||||
// 加载数据库中的转债列表
|
||||
async function loadDBBonds() {
|
||||
document.getElementById('bt-bond-count').textContent = '加载中...';
|
||||
try {
|
||||
const resp = await apiFetch('/api/db/bonds');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
dbBonds = await resp.json();
|
||||
renderBondList();
|
||||
} catch (e) {
|
||||
document.getElementById('bt-bond-count').textContent = '加载失败';
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedCodes() {
|
||||
const checks = document.querySelectorAll('#bt-bond-table tbody input[type=checkbox]');
|
||||
return Array.from(checks).filter(c => c.checked).map(c => c.value);
|
||||
}
|
||||
|
||||
function renderBondList(filter = '') {
|
||||
const tbody = document.querySelector('#bt-bond-table tbody');
|
||||
const filtered = filter ? dbBonds.filter(b => b.Code.includes(filter)) : dbBonds;
|
||||
tbody.innerHTML = filtered.map(b => `
|
||||
<tr>
|
||||
<td><input type="checkbox" value="${b.Code}" checked></td>
|
||||
<td>${b.Code}</td>
|
||||
<td>${b.Name || '-'}</td>
|
||||
<td>${b.KlineCount}</td>
|
||||
</tr>`).join('');
|
||||
document.getElementById('bt-bond-count').textContent = `共 ${dbBonds.length} 只(已选 ${getSelectedCodes().length})`;
|
||||
updateCheckAll();
|
||||
}
|
||||
|
||||
function updateCheckAll() {
|
||||
const checks = document.querySelectorAll('#bt-bond-table tbody input[type=checkbox]');
|
||||
const all = document.getElementById('bt-check-all');
|
||||
if (checks.length === 0) { all.checked = false; return; }
|
||||
all.checked = Array.from(checks).every(c => c.checked);
|
||||
}
|
||||
|
||||
// 切换回测标签页时加载转债列表
|
||||
document.querySelector('.tab[data-tab="backtest"]').addEventListener('click', () => {
|
||||
if (dbBonds.length === 0) loadDBBonds();
|
||||
});
|
||||
|
||||
document.getElementById('bt-check-all').addEventListener('change', function() {
|
||||
document.querySelectorAll('#bt-bond-table tbody input[type=checkbox]').forEach(c => c.checked = this.checked);
|
||||
document.getElementById('bt-bond-count').textContent = `共 ${dbBonds.length} 只(已选 ${getSelectedCodes().length})`;
|
||||
});
|
||||
document.getElementById('bt-select-all').addEventListener('click', () => {
|
||||
document.querySelectorAll('#bt-bond-table tbody input[type=checkbox]').forEach(c => c.checked = true);
|
||||
document.getElementById('bt-check-all').checked = true;
|
||||
document.getElementById('bt-bond-count').textContent = `共 ${dbBonds.length} 只(已选 ${getSelectedCodes().length})`;
|
||||
});
|
||||
document.getElementById('bt-deselect-all').addEventListener('click', () => {
|
||||
document.querySelectorAll('#bt-bond-table tbody input[type=checkbox]').forEach(c => c.checked = false);
|
||||
document.getElementById('bt-check-all').checked = false;
|
||||
document.getElementById('bt-bond-count').textContent = `共 ${dbBonds.length} 只(已选 0)`;
|
||||
});
|
||||
document.getElementById('bt-bond-search').addEventListener('input', function() {
|
||||
renderBondList(this.value.trim());
|
||||
});
|
||||
|
||||
// 监听表格内 checkbox 变化
|
||||
document.querySelector('#bt-bond-table tbody').addEventListener('change', function(e) {
|
||||
if (e.target.type === 'checkbox') {
|
||||
updateCheckAll();
|
||||
document.getElementById('bt-bond-count').textContent = `共 ${dbBonds.length} 只(已选 ${getSelectedCodes().length})`;
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('bt-strategy').addEventListener('change', function() {
|
||||
document.getElementById('bt-premium-group').style.display =
|
||||
this.value === 'doublelow' ? '' : 'none';
|
||||
if (this.value === 'premium') {
|
||||
document.getElementById('bt-minprice').value = '90';
|
||||
document.getElementById('bt-maxprice').value = '150';
|
||||
} else {
|
||||
document.getElementById('bt-minprice').value = '80';
|
||||
document.getElementById('bt-maxprice').value = '130';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('bt-run').addEventListener('click', async () => {
|
||||
const btn = document.getElementById('bt-run');
|
||||
const status = document.getElementById('bt-status');
|
||||
const errEl = document.getElementById('bt-error');
|
||||
const resultSection = document.getElementById('bt-result-section');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '回测中...';
|
||||
status.textContent = '回测中...';
|
||||
status.style.color = 'var(--blue)';
|
||||
errEl.style.display = 'none';
|
||||
resultSection.style.display = 'none';
|
||||
|
||||
try {
|
||||
const selectedCodes = getSelectedCodes();
|
||||
if (selectedCodes.length === 0 && dbBonds.length > 0) {
|
||||
throw new Error('请至少选择一只可转债进行回测');
|
||||
}
|
||||
const allSelected = dbBonds.length > 0 && selectedCodes.length === dbBonds.length;
|
||||
const body = {
|
||||
strategy: document.getElementById('bt-strategy').value,
|
||||
startDate: document.getElementById('bt-start').value,
|
||||
endDate: document.getElementById('bt-end').value,
|
||||
topN: +document.getElementById('bt-topn').value,
|
||||
rebalance: +document.getElementById('bt-rebalance').value,
|
||||
minPrice: +document.getElementById('bt-minprice').value,
|
||||
maxPrice: +document.getElementById('bt-maxprice').value,
|
||||
maxPremium: +document.getElementById('bt-maxpremium').value,
|
||||
cash: +document.getElementById('bt-cash').value,
|
||||
fundamental: document.getElementById('bt-fundamental').checked,
|
||||
codes: allSelected ? [] : selectedCodes,
|
||||
};
|
||||
const resp = await apiFetch('/api/backtest', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
throw new Error(err.error || 'HTTP ' + resp.status);
|
||||
}
|
||||
const data = await resp.json();
|
||||
showBacktestResult(data);
|
||||
status.textContent = '✓ 回测完成';
|
||||
status.style.color = 'var(--green)';
|
||||
// 滚动到结果区域
|
||||
setTimeout(() => {
|
||||
resultSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
} catch (e) {
|
||||
errEl.style.display = 'block';
|
||||
errEl.innerHTML = '<div class="error">回测失败: ' + e.message + '</div>';
|
||||
status.textContent = '× 回测失败';
|
||||
status.style.color = 'var(--red)';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = '开始回测';
|
||||
}
|
||||
});
|
||||
|
||||
// 策略回测区域折叠/展开
|
||||
document.querySelectorAll('#tab-backtest .section-header').forEach(header => {
|
||||
header.addEventListener('click', e => {
|
||||
if (e.target.closest('.section-toggle')) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
const card = header.closest('.section-card');
|
||||
const collapsed = card.classList.toggle('collapsed');
|
||||
header.querySelector('.section-toggle').textContent = collapsed ? '+' : '−';
|
||||
header.querySelector('.section-toggle').setAttribute('aria-label', collapsed ? '展开' : '折叠');
|
||||
});
|
||||
});
|
||||
|
||||
function showBacktestResult(data) {
|
||||
const resultSection = document.getElementById('bt-result-section');
|
||||
resultSection.style.display = 'block';
|
||||
resultSection.classList.remove('collapsed');
|
||||
|
||||
const m = data.metrics;
|
||||
const bm = data.benchMetrics;
|
||||
const excess = m.TotalReturn - bm.TotalReturn;
|
||||
|
||||
document.getElementById('bt-metrics').innerHTML = `
|
||||
<div class="metric-card"><div class="label">策略</div><div class="value" style="font-size:16px">${data.strategy}</div></div>
|
||||
<div class="metric-card"><div class="label">回测区间</div><div class="value" style="font-size:14px">${data.start} ~ ${data.end}</div></div>
|
||||
<div class="metric-card"><div class="label">累计收益</div><div class="value ${m.TotalReturn >= 0 ? 'pos' : 'neg'}">${(m.TotalReturn * 100).toFixed(2)}%</div></div>
|
||||
<div class="metric-card"><div class="label">年化收益</div><div class="value ${m.AnnualReturn >= 0 ? 'pos' : 'neg'}">${(m.AnnualReturn * 100).toFixed(2)}%</div></div>
|
||||
<div class="metric-card"><div class="label">最大回撤</div><div class="value neg">${(m.MaxDrawdown * 100).toFixed(2)}%</div></div>
|
||||
<div class="metric-card"><div class="label">夏普比率</div><div class="value">${m.Sharpe.toFixed(2)}</div></div>
|
||||
<div class="metric-card"><div class="label">波动率</div><div class="value">${(m.Volatility * 100).toFixed(2)}%</div></div>
|
||||
<div class="metric-card"><div class="label">胜率</div><div class="value">${(m.WinRatio * 100).toFixed(1)}%</div></div>
|
||||
<div class="metric-card"><div class="label">超额收益 vs 基准</div><div class="value ${excess >= 0 ? 'pos' : 'neg'}">${(excess * 100).toFixed(2)}%</div></div>
|
||||
<div class="metric-card"><div class="label">基准年化</div><div class="value ${bm.AnnualReturn >= 0 ? 'pos' : 'neg'}">${(bm.AnnualReturn * 100).toFixed(2)}%</div></div>
|
||||
`;
|
||||
|
||||
// 绘制净值曲线
|
||||
const labels = data.curve.map(p => p.Date);
|
||||
const stratValues = data.curve.map(p => p.Value);
|
||||
const benchMap = {};
|
||||
data.benchCurve.forEach(p => { benchMap[p.Date] = p.Value; });
|
||||
const benchValues = data.curve.map(p => benchMap[p.Date] || null);
|
||||
|
||||
if (btChart) btChart.destroy();
|
||||
const ctx = document.getElementById('bt-chart').getContext('2d');
|
||||
btChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: data.strategy,
|
||||
data: stratValues,
|
||||
borderColor: '#1E5FBB',
|
||||
backgroundColor: 'rgba(30,95,187,0.06)',
|
||||
borderWidth: 2,
|
||||
pointRadius: 0,
|
||||
fill: true,
|
||||
tension: 0.15,
|
||||
},
|
||||
{
|
||||
label: '等权全转债基准',
|
||||
data: benchValues,
|
||||
borderColor: '#7C8499',
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
borderDash: [6, 3],
|
||||
fill: false,
|
||||
tension: 0.15,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
plugins: {
|
||||
title: {
|
||||
display: true,
|
||||
text: '净值曲线对比',
|
||||
font: { size: 14, family: '-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif' },
|
||||
color: '#1A1F2E',
|
||||
padding: { bottom: 16 },
|
||||
},
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 24,
|
||||
font: { size: 12, family: '-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif' },
|
||||
color: '#4A5168',
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(13,27,42,0.92)',
|
||||
titleFont: { size: 13, family: '-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif' },
|
||||
bodyFont: { size: 12 },
|
||||
padding: 12,
|
||||
cornerRadius: 6,
|
||||
callbacks: { label: ctx => ctx.dataset.label + ': ' + parseFloat(ctx.raw).toFixed(4) },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: '#E8EAF0', drawBorder: false },
|
||||
ticks: { color: '#7C8499', maxTicksLimit: 16, maxRotation: 0, font: { size: 11 } },
|
||||
},
|
||||
y: {
|
||||
grid: { color: '#E8EAF0', drawBorder: false },
|
||||
ticks: { color: '#7C8499', font: { size: 11 }, callback: v => v.toFixed(2) },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ============ 数据管理 ============
|
||||
async function loadDataStatus() {
|
||||
const statusText = document.getElementById('data-status-text');
|
||||
const cards = document.getElementById('data-cards');
|
||||
const errEl = document.getElementById('data-error');
|
||||
statusText.textContent = '加载中...';
|
||||
errEl.style.display = 'none';
|
||||
try {
|
||||
const resp = await apiFetch('/api/status');
|
||||
if (!resp.ok) throw new Error('HTTP ' + resp.status);
|
||||
const data = await resp.json();
|
||||
cards.innerHTML = `
|
||||
<div class="status-card">
|
||||
<div class="label">📁 数据库路径</div>
|
||||
<div class="value" style="font-size:12px;word-break:break-all;font-weight:500">${data.dbPath || '-'}</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="label">📊 转债数量</div>
|
||||
<div class="value" style="color:var(--blue)">${data.bondCount || 0}</div>
|
||||
<div style="font-size:11px;color:var(--gray-500);margin-top:4px">只可转债</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="label">📈 K线总数</div>
|
||||
<div class="value" style="color:var(--green)">${(data.klineCount || 0).toLocaleString()}</div>
|
||||
<div style="font-size:11px;color:var(--gray-500);margin-top:4px">条日K线数据</div>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<div class="label">📅 数据区间</div>
|
||||
<div class="value" style="font-size:14px;font-weight:500">${data.dateRange || '-'}</div>
|
||||
<div style="font-size:11px;color:var(--gray-500);margin-top:4px">交易日范围</div>
|
||||
</div>
|
||||
`;
|
||||
statusText.textContent = '✓ 已加载';
|
||||
statusText.style.color = 'var(--green)';
|
||||
} catch (e) {
|
||||
errEl.style.display = 'block';
|
||||
errEl.innerHTML = '<div class="error">加载失败: ' + e.message + '(请确保已执行数据入库)</div>';
|
||||
statusText.textContent = '× 加载失败';
|
||||
statusText.style.color = 'var(--red)';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('data-refresh').addEventListener('click', loadDataStatus);
|
||||
|
||||
// 初始化
|
||||
loadBonds();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
320
web/style.css
Normal file
320
web/style.css
Normal file
@@ -0,0 +1,320 @@
|
||||
:root {
|
||||
--navy: #0D1B2A;
|
||||
--navy-light: #1B2A4A;
|
||||
--blue: #1E5FBB;
|
||||
--blue-light: #E8F0FE;
|
||||
--green: #007A5E;
|
||||
--green-light: #E6F4F0;
|
||||
--red: #C8102E;
|
||||
--red-light: #FDE8EC;
|
||||
--gray-50: #F5F6FA;
|
||||
--gray-100: #E8EAF0;
|
||||
--gray-200: #D1D5DE;
|
||||
--gray-500: #7C8499;
|
||||
--gray-700: #4A5168;
|
||||
--gray-900: #1A1F2E;
|
||||
--white: #FFFFFF;
|
||||
--shadow-sm: 0 1px 2px rgba(13,27,42,0.06);
|
||||
--shadow-md: 0 2px 8px rgba(13,27,42,0.08);
|
||||
--shadow-lg: 0 4px 16px rgba(13,27,42,0.10);
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif;
|
||||
background: var(--gray-50);
|
||||
color: var(--gray-900);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Auth page */
|
||||
.auth-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, var(--navy) 0%, var(--navy-light) 100%);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
background: var(--white);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.auth-header h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--navy);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.auth-header p {
|
||||
font-size: 13px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
|
||||
.auth-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--gray-100);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.auth-tab {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
cursor: pointer;
|
||||
color: var(--gray-500);
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
}
|
||||
.auth-tab.active {
|
||||
color: var(--blue);
|
||||
font-weight: 600;
|
||||
}
|
||||
.auth-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 25%;
|
||||
width: 50%;
|
||||
height: 2px;
|
||||
background: var(--blue);
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
|
||||
.auth-form .form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.auth-form label {
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.auth-form input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--gray-200);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
color: var(--gray-900);
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
.auth-form input:focus {
|
||||
border-color: var(--blue);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px var(--blue-light);
|
||||
}
|
||||
|
||||
.code-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.code-input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
.code-input-group button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--gray-500);
|
||||
margin-top: 16px;
|
||||
}
|
||||
.auth-hint a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 20px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
transition: all var(--transition);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--blue);
|
||||
color: var(--white);
|
||||
box-shadow: 0 1px 3px rgba(30,95,187,0.3);
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #1A52A3;
|
||||
box-shadow: 0 2px 6px rgba(30,95,187,0.4);
|
||||
}
|
||||
.btn-default {
|
||||
background: var(--white);
|
||||
border: 1px solid var(--gray-200);
|
||||
color: var(--gray-700);
|
||||
}
|
||||
.btn-default:hover:not(:disabled) {
|
||||
border-color: var(--blue);
|
||||
color: var(--blue);
|
||||
background: var(--blue-light);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--red);
|
||||
background: var(--red-light);
|
||||
border: 1px solid #F5A3B0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Header user */
|
||||
.header-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-user .avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--blue), #1A52A3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--white);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-user .user-name {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.header-user a {
|
||||
color: var(--white);
|
||||
font-size: 13px;
|
||||
text-decoration: none;
|
||||
opacity: 0.75;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
.header-user a:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
/* Toast notification */
|
||||
.toast {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 20px rgba(13,27,42,0.15);
|
||||
animation: toastIn 0.3s ease-out;
|
||||
max-width: 360px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.toast.success { background: var(--green); color: var(--white); }
|
||||
.toast.error { background: var(--red); color: var(--white); }
|
||||
.toast.info { background: var(--navy); color: var(--white); }
|
||||
@keyframes toastIn {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
@keyframes toastOut {
|
||||
from { opacity: 1; transform: translateX(0); }
|
||||
to { opacity: 0; transform: translateX(20px); }
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 20px;
|
||||
color: var(--gray-500);
|
||||
}
|
||||
.empty-state .empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.empty-state .empty-text {
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.empty-state .empty-hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Table row animation */
|
||||
@keyframes rowIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.table-wrap tbody tr {
|
||||
animation: rowIn 0.2s ease-out both;
|
||||
}
|
||||
.table-wrap tbody tr:nth-child(1) { animation-delay: 0s; }
|
||||
.table-wrap tbody tr:nth-child(2) { animation-delay: 0.02s; }
|
||||
.table-wrap tbody tr:nth-child(3) { animation-delay: 0.04s; }
|
||||
.table-wrap tbody tr:nth-child(4) { animation-delay: 0.06s; }
|
||||
.table-wrap tbody tr:nth-child(5) { animation-delay: 0.08s; }
|
||||
.table-wrap tbody tr:nth-child(6) { animation-delay: 0.10s; }
|
||||
.table-wrap tbody tr:nth-child(7) { animation-delay: 0.12s; }
|
||||
.table-wrap tbody tr:nth-child(8) { animation-delay: 0.14s; }
|
||||
.table-wrap tbody tr:nth-child(9) { animation-delay: 0.16s; }
|
||||
.table-wrap tbody tr:nth-child(10) { animation-delay: 0.18s; }
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--gray-200); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--gray-500); }
|
||||
|
||||
/* Tab content transition */
|
||||
.tab-content {
|
||||
animation: fadeIn 0.25s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.header-user .user-name { max-width: 80px; }
|
||||
.toast { top: 10px; right: 10px; left: 10px; max-width: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user