Files
stock-tool/templates/index.html
root f246f06cb1 Initial commit: 股票数据分析工具
- Go后端服务
- 内置Web界面
- SQLite数据库
- 股票列表展示
- K线图绘制
- 技术指标计算
- 基本面数据展示
2026-02-19 03:07:02 +08:00

634 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>