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>