Initial commit: 股票数据分析工具
- Go后端服务 - 内置Web界面 - SQLite数据库 - 股票列表展示 - K线图绘制 - 技术指标计算 - 基本面数据展示
This commit is contained in:
633
templates/index.html
Normal file
633
templates/index.html
Normal file
@@ -0,0 +1,633 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>📈 股票数据分析工具</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #fff;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
font-size: 2.5em;
|
||||
background: linear-gradient(90deg, #00d2ff, #3a7bd5);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* 搜索框 */
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 30px;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid #3a7bd5;
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
border-color: #00d2ff;
|
||||
}
|
||||
|
||||
.search-box button {
|
||||
padding: 15px 30px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(90deg, #00d2ff, #3a7bd5);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.search-box button:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 股票列表 */
|
||||
.stock-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stock-card {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.stock-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
border-color: #00d2ff;
|
||||
}
|
||||
|
||||
.stock-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stock-symbol {
|
||||
font-size: 1.5em;
|
||||
font-weight: bold;
|
||||
color: #00d2ff;
|
||||
}
|
||||
|
||||
.stock-name {
|
||||
font-size: 0.9em;
|
||||
color: #aaa;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.stock-price {
|
||||
font-size: 1.8em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stock-change {
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.stock-change.positive {
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.stock-change.negative {
|
||||
color: #ff4757;
|
||||
}
|
||||
|
||||
.stock-metrics {
|
||||
margin-top: 15px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.metric {
|
||||
background: rgba(255,255,255,0.05);
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: #888;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 详情页面 */
|
||||
.detail-view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.detail-price {
|
||||
font-size: 3em;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.detail-change {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.detail-tabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 12px 25px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: linear-gradient(90deg, #00d2ff, #3a7bd5);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
height: 400px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 简单的CSS图表 */
|
||||
.simple-chart {
|
||||
width: 100%;
|
||||
height: 350px;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: rgba(255,255,255,0.1);
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: #00ff88;
|
||||
color: #1a1a2e;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
font-size: 1.2em;
|
||||
color: #00d2ff;
|
||||
}
|
||||
|
||||
.loading::after {
|
||||
content: '...';
|
||||
animation: dots 1.5s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0%, 20% { content: '.'; }
|
||||
40% { content: '..'; }
|
||||
60%, 100% { content: '...'; }
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stock-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>📈 股票数据分析工具</h1>
|
||||
|
||||
<!-- 搜索框 -->
|
||||
<div class="search-box">
|
||||
<input type="text" id="searchInput" placeholder="输入股票代码或公司名称...">
|
||||
<button onclick="searchStocks()">🔍 搜索</button>
|
||||
</div>
|
||||
|
||||
<!-- 股票列表 -->
|
||||
<div id="stockList" class="stock-list"></div>
|
||||
|
||||
<!-- 详情页面 -->
|
||||
<div id="detailView" class="detail-view">
|
||||
<div class="detail-header">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;">
|
||||
<div>
|
||||
<h2 id="detailSymbol" style="color: #00d2ff; margin-bottom: 5px;"></h2>
|
||||
<p id="detailName" style="color: #888;"></p>
|
||||
</div>
|
||||
<button class="refresh-btn" onclick="refreshCurrentStock()">🔄 刷新数据</button>
|
||||
</div>
|
||||
<div style="display: flex; align-items: baseline; gap: 20px;">
|
||||
<span id="detailPrice" class="detail-price"></span>
|
||||
<span id="detailChange" class="detail-change"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-tabs">
|
||||
<button class="tab active" onclick="switchTab('kline')">📊 K线图</button>
|
||||
<button class="tab" onclick="switchTab('indicators')">📈 技术指标</button>
|
||||
<button class="tab" onclick="switchTab('financials')">💰 基本面</button>
|
||||
</div>
|
||||
|
||||
<div id="klineTab" class="tab-content active">
|
||||
<div class="chart-container">
|
||||
<canvas id="klineChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="indicatorsTab" class="tab-content">
|
||||
<div class="chart-container">
|
||||
<canvas id="indicatorsChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="financialsTab" class="tab-content">
|
||||
<div class="chart-container">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>指标</th>
|
||||
<th>数值</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="financialsTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="loading" class="loading"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentSymbol = '';
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadStocks();
|
||||
});
|
||||
|
||||
// 加载股票列表
|
||||
async function loadStocks() {
|
||||
try {
|
||||
const response = await fetch('/api/stocks');
|
||||
const stocks = await response.json();
|
||||
displayStocks(stocks);
|
||||
} catch (error) {
|
||||
console.error('加载失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示股票列表
|
||||
function displayStocks(stocks) {
|
||||
const container = document.getElementById('stockList');
|
||||
container.innerHTML = '';
|
||||
|
||||
stocks.forEach(stock => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'stock-card';
|
||||
card.onclick = () => showDetail(stock.symbol);
|
||||
|
||||
const changeClass = stock.change_percent >= 0 ? 'positive' : 'negative';
|
||||
const changeSymbol = stock.change_percent >= 0 ? '↑' : '↓';
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="stock-header">
|
||||
<span class="stock-symbol">${stock.symbol}</span>
|
||||
<span class="stock-change ${changeClass}">${changeSymbol} ${Math.abs(stock.change_percent).toFixed(2)}%</span>
|
||||
</div>
|
||||
<p class="stock-name">${stock.name}</p>
|
||||
<p class="stock-price">$${stock.current_price.toFixed(2)}</p>
|
||||
<div class="stock-metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-label">市值</div>
|
||||
<div class="metric-value">${stock.market_cap || 'N/A'}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">市盈率</div>
|
||||
<div class="metric-value">${stock.pe_ratio ? stock.pe_ratio.toFixed(2) : 'N/A'}</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">ROE</div>
|
||||
<div class="metric-value">${stock.roe ? stock.roe.toFixed(2) : 'N/A'}%</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-label">负债率</div>
|
||||
<div class="metric-value">${stock.debt_ratio ? stock.debt_ratio.toFixed(2) : 'N/A'}%</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索股票
|
||||
async function searchStocks() {
|
||||
const query = document.getElementById('searchInput').value.trim();
|
||||
if (!query) {
|
||||
alert('请输入搜索关键词');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading();
|
||||
try {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
const results = await response.json();
|
||||
|
||||
if (results.length === 0) {
|
||||
alert('未找到相关股票');
|
||||
} else {
|
||||
showDetail(results[0].symbol);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 显示详情
|
||||
async function showDetail(symbol) {
|
||||
currentSymbol = symbol;
|
||||
document.getElementById('stockList').style.display = 'none';
|
||||
document.getElementById('detailView').style.display = 'block';
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
|
||||
try {
|
||||
// 加载股票信息
|
||||
const stockResponse = await fetch(`/api/stock/${symbol}`);
|
||||
const stock = await stockResponse.json();
|
||||
|
||||
document.getElementById('detailSymbol').textContent = stock.symbol;
|
||||
document.getElementById('detailName').textContent = stock.name;
|
||||
document.getElementById('detailPrice').textContent = `$${stock.current_price.toFixed(2)}`;
|
||||
|
||||
const changeClass = stock.change_percent >= 0 ? 'positive' : 'negative';
|
||||
const changeSymbol = stock.change_percent >= 0 ? '↑' : '↓';
|
||||
document.getElementById('detailChange').innerHTML =
|
||||
`<span class="${changeClass}">${changeSymbol} ${Math.abs(stock.change_percent).toFixed(2)}%</span>`;
|
||||
|
||||
// 加载K线数据
|
||||
loadKLineData(symbol);
|
||||
loadIndicatorData(symbol);
|
||||
loadFinancials(symbol);
|
||||
} catch (error) {
|
||||
console.error('加载失败:', error);
|
||||
}
|
||||
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
|
||||
// 加载K线数据
|
||||
async function loadKLineData(symbol) {
|
||||
try {
|
||||
const response = await fetch(`/api/stock/${symbol}/kline`);
|
||||
const klines = await response.json();
|
||||
drawKlineChart(klines);
|
||||
} catch (error) {
|
||||
console.error('加载K线失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载技术指标
|
||||
async function loadIndicatorData(symbol) {
|
||||
try {
|
||||
const response = await fetch(`/api/stock/${symbol}/indicators`);
|
||||
const indicators = await response.json();
|
||||
drawIndicatorsChart(indicators);
|
||||
} catch (error) {
|
||||
console.error('加载指标失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载财务数据
|
||||
async function loadFinancials(symbol) {
|
||||
try {
|
||||
const response = await fetch(`/api/stock/${symbol}/financials`);
|
||||
const financials = await response.json();
|
||||
|
||||
const tbody = document.getElementById('financialsTable');
|
||||
tbody.innerHTML = `
|
||||
<tr><td>营业收入</td><td>${financials.revenue ? '$' + financials.revenue.toFixed(2) : 'N/A'}</td><td>年度总收入</td></tr>
|
||||
<tr><td>净利润</td><td>${financials.profit ? '$' + financials.profit.toFixed(2) : 'N/A'}</td><td>年度净利润</td></tr>
|
||||
<tr><td>ROE</td><td>${financials.roe ? financials.roe.toFixed(2) : 'N/A'}%</td><td>净资产收益率</td></tr>
|
||||
<tr><td>负债权益比</td><td>${financials.debt_to_equity ? financials.debt_to_equity.toFixed(2) : 'N/A'}</td><td>杠杆率</td></tr>
|
||||
`;
|
||||
} catch (error) {
|
||||
console.error('加载财务数据失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 绘制K线图
|
||||
function drawKlineChart(klines) {
|
||||
const canvas = document.getElementById('klineChart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = canvas.parentElement.clientWidth - 40;
|
||||
canvas.height = 350;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (klines.length < 2) return;
|
||||
|
||||
const data = klines.slice().reverse();
|
||||
const prices = data.map(d => d.close);
|
||||
const minPrice = Math.min(...prices) * 0.99;
|
||||
const maxPrice = Math.max(...prices) * 1.01;
|
||||
const priceRange = maxPrice - minPrice;
|
||||
const candleWidth = (canvas.width - 60) / data.length;
|
||||
|
||||
// 绘制网格
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.1)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const y = 30 + (i * (canvas.height - 60) / 4);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(30, y);
|
||||
ctx.lineTo(canvas.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 绘制蜡烛图
|
||||
data.forEach((candle, i) => {
|
||||
const x = 30 + i * candleWidth + candleWidth / 2;
|
||||
const yOpen = 30 + (maxPrice - candle.open) / priceRange * (canvas.height - 60);
|
||||
const yClose = 30 + (maxPrice - candle.close) / priceRange * (canvas.height - 60);
|
||||
const yHigh = 30 + (maxPrice - candle.high) / priceRange * (canvas.height - 60);
|
||||
const yLow = 30 + (maxPrice - candle.low) / priceRange * (canvas.height - 60);
|
||||
|
||||
const isUp = candle.close >= candle.open;
|
||||
ctx.fillStyle = isUp ? '#00ff88' : '#ff4757';
|
||||
ctx.strokeStyle = ctx.fillStyle;
|
||||
|
||||
// 绘制影线
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yHigh);
|
||||
ctx.lineTo(x, yLow);
|
||||
ctx.stroke();
|
||||
|
||||
// 绘制实体
|
||||
const bodyHeight = Math.max(Math.abs(yClose - yOpen), 1);
|
||||
ctx.fillRect(x - candleWidth / 2 + 2, Math.min(yOpen, yClose), candleWidth - 4, bodyHeight);
|
||||
});
|
||||
}
|
||||
|
||||
// 绘制技术指标图
|
||||
function drawIndicatorsChart(indicators) {
|
||||
const canvas = document.getElementById('indicatorsChart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = canvas.parentElement.clientWidth - 40;
|
||||
canvas.height = 350;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
if (indicators.length < 2) return;
|
||||
|
||||
const data = indicators.slice().reverse();
|
||||
const values = data.map(d => d.rsi);
|
||||
const min = Math.min(...values) * 0.9;
|
||||
const max = Math.max(...values) * 1.1;
|
||||
const valueRange = max - min;
|
||||
const stepX = (canvas.width - 40) / data.length;
|
||||
|
||||
// 绘制RSI中线(70和30)
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(30, 30 + (70 - min) / valueRange * (canvas.height - 60));
|
||||
ctx.lineTo(canvas.width, 30 + (70 - min) / valueRange * (canvas.height - 60));
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(30, 30 + (30 - min) / valueRange * (canvas.height - 60));
|
||||
ctx.lineTo(canvas.width, 30 + (30 - min) / valueRange * (canvas.height - 60));
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// 绘制RSI曲线
|
||||
ctx.strokeStyle = '#00d2ff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
data.forEach((ind, i) => {
|
||||
const x = 30 + i * stepX;
|
||||
const y = 30 + (ind.rsi - min) / valueRange * (canvas.height - 60);
|
||||
if (i === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 切换标签页
|
||||
function switchTab(tab) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
event.target.classList.add('active');
|
||||
document.getElementById(tab + 'Tab').classList.add('active');
|
||||
}
|
||||
|
||||
// 刷新数据
|
||||
async function refreshCurrentStock() {
|
||||
showLoading();
|
||||
try {
|
||||
const response = await fetch(`/api/refresh/${currentSymbol}`);
|
||||
const result = await response.json();
|
||||
alert(result.message);
|
||||
|
||||
// 重新加载当前页面
|
||||
await loadStocks();
|
||||
showDetail(currentSymbol);
|
||||
} catch (error) {
|
||||
console.error('刷新失败:', error);
|
||||
}
|
||||
document.getElementById('loading').style.display = 'none';
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
document.getElementById('loading').style.display = 'block';
|
||||
document.getElementById('stockList').style.display = 'none';
|
||||
document.getElementById('detailView').style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user