Initial commit: 可转债轮动分析系统

- 实时行情:东财push2主源 + 腾讯批量报价备用源(自动切换)
- 历史K线:东财push2his主源 + 腾讯web子域备用源(含熔断机制)
- 增量同步:支持 -incr/-since 参数按存档日期增量入库
- 策略回测:净值曲线/10指标/基准对比,含基本面过滤
- 用户认证:用户名密码 + 邮箱验证码双登录方式
- 前端:三Tab SPA(实时行情/策略回测/数据管理)
This commit is contained in:
2026-08-17 20:23:22 +08:00
commit e28bfec957
45 changed files with 7889 additions and 0 deletions

626
web/auth.html Normal file
View 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
View 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
View 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; }
}