stock-monitor/app/templates/stocks_simple.html
ycg 569c1c8813 重构股票监控系统:数据库架构升级与功能完善
- 重构数据访问层:引入DAO模式,支持MySQL/SQLite双数据库
- 新增数据库架构:完整的股票数据、AI分析、自选股管理表结构
- 升级AI分析服务:集成豆包大模型,支持多维度分析
- 优化API路由:分离市场数据API,提供更清晰的接口设计
- 完善项目文档:添加数据库迁移指南、新功能指南等
- 清理冗余文件:删除旧的缓存文件和无用配置
- 新增调度器:支持定时任务和数据自动更新
- 改进前端模板:简化的股票展示页面

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 15:44:25 +08:00

678 lines
32 KiB
HTML

<!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 href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<style>
.positive { color: #e74c3c; }
.negative { color: #27ae60; }
.chart-container {
height: 400px;
margin: 20px 0;
}
.filter-section {
background-color: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin-bottom: 20px;
}
.loading {
text-align: center;
padding: 50px;
}
.sector-tag {
background-color: #e3f2fd;
color: #1976d2;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
margin: 2px;
}
.spinning {
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body>
<div id="app">
<!-- 导航栏 -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="/">
<i class="bi bi-graph-up"></i>
股票监控系统
</a>
<div class="navbar-nav">
<a class="nav-link active" href="/stocks">股票市场</a>
<a class="nav-link" href="/">我的监控</a>
<a class="nav-link" href="/market">指数行情</a>
</div>
</div>
</nav>
<div class="container mt-4">
<!-- 市场概览 -->
<div class="row mb-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-bar-chart"></i>
市场概览
<button class="btn btn-sm btn-outline-primary float-end" @click="refreshOverview">
<i class="bi bi-arrow-clockwise"></i>
刷新
</button>
</h5>
</div>
<div class="card-body">
<div class="row text-center" v-if="overviewLoading">
<div class="col-12 loading">
<div class="spinner-border" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<p>加载市场数据中...</p>
</div>
</div>
<div class="row" v-else-if="overview.statistics">
<div class="col-md-2">
<h6>总股票数</h6>
<h4>[[ overview.statistics.total_count ]]</h4>
</div>
<div class="col-md-2">
<h6>上涨</h6>
<h4 class="positive">[[ overview.statistics.up_count ]]</h4>
</div>
<div class="col-md-2">
<h6>下跌</h6>
<h4 class="negative">[[ overview.statistics.down_count ]]</h4>
</div>
<div class="col-md-3">
<h6>成交量</h6>
<h4>[[ formatVolume(overview.statistics.total_volume) ]]</h4>
</div>
<div class="col-md-3">
<h6>成交额</h6>
<h4>[[ formatAmount(overview.statistics.total_amount) ]]</h4>
</div>
</div>
<div v-else class="text-center py-3">
<p class="text-muted">暂无市场数据</p>
</div>
</div>
</div>
</div>
</div>
<!-- 筛选和搜索 -->
<div class="filter-section">
<div class="row">
<div class="col-md-3">
<label class="form-label">搜索股票</label>
<input type="text" class="form-control" v-model="searchKeyword"
placeholder="股票代码或名称" @input="searchStocks">
</div>
<div class="col-md-2">
<label class="form-label">行业筛选</label>
<select class="form-select" v-model="selectedIndustry" @change="filterStocks">
<option value="">全部行业</option>
<option v-for="industry in industries" :value="industry.industry_code">
[[ industry.industry_name ]] ([[ industry.stock_count ]])
</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">概念筛选</label>
<select class="form-select" v-model="selectedSector" @change="filterStocks">
<option value="">全部概念</option>
<option v-for="sector in sectors" :value="sector.sector_code">
[[ sector.sector_name ]] ([[ sector.stock_count ]])
</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">热门排行</label>
<select class="form-select" @change="loadHotStocks($event.target.value)">
<option value="">选择排行榜</option>
<option value="volume">成交量排行榜</option>
<option value="amount">成交额排行榜</option>
<option value="change">涨幅排行榜</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">数据同步</label>
<div>
<button class="btn btn-primary btn-sm me-2" @click="syncData" :disabled="syncing">
<i class="bi bi-arrow-repeat" v-if="!syncing"></i>
<i class="bi bi-arrow-repeat spinning" v-if="syncing"></i>
同步数据
</button>
</div>
</div>
</div>
</div>
<!-- 股票列表 -->
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-list"></i>
股票列表
<span class="badge bg-secondary ms-2">[[ pagination.total ]] 只</span>
</h5>
<div>
<span class="text-muted">
显示第 [[ (pagination.page - 1) * pagination.size + 1 ]] -
[[ Math.min(pagination.page * pagination.size, pagination.total) ]] 条
</span>
</div>
</div>
<div class="card-body">
<div v-if="loading" class="loading">
<div class="spinner-border" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<p>加载股票数据中...</p>
</div>
<div v-else-if="stocks.length === 0" class="text-center py-5">
<i class="bi bi-inbox display-1 text-muted"></i>
<p class="text-muted">暂无股票数据,请先同步数据</p>
</div>
<div v-else>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>股票代码</th>
<th>股票名称</th>
<th>所属行业</th>
<th>概念板块</th>
<th>最新价</th>
<th>涨跌幅</th>
<th>成交量</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="stock in stocks" :key="stock.stock_code"
@click="showStockDetail(stock.stock_code)"
class="stock-card">
<td>
<strong>[[ stock.stock_code ]]</strong>
<small class="text-muted d-block">[[ stock.market ]]</small>
</td>
<td>[[ stock.stock_name ]]</td>
<td>
<small class="badge bg-light text-dark">
[[ stock.industry_name || '未分类' ]]
</small>
</td>
<td>
<span v-for="sector in getSectorNames(stock.sector_names)"
:key="sector" class="sector-tag">
[[ sector ]]
</span>
</td>
<td v-if="stock.price">
<strong>[[ stock.price.toFixed(2) ]]</strong>
</td>
<td v-else>-</td>
<td v-if="stock.change_percent !== null">
<span :class="stock.change_percent >= 0 ? 'positive' : 'negative'">
[[ (stock.change_percent * 100).toFixed(2) ]]%
</span>
</td>
<td v-else>-</td>
<td v-if="stock.volume">
[[ formatVolume(stock.volume) ]]
</td>
<td v-else>-</td>
<td>
<button class="btn btn-sm btn-outline-primary me-1"
@click.stop="showStockDetail(stock.stock_code)">
<i class="bi bi-eye"></i>
</button>
<button class="btn btn-sm btn-outline-success"
@click.stop="addToWatchlist(stock.stock_code)">
<i class="bi bi-plus"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 分页 -->
<nav aria-label="股票列表分页" class="mt-3" v-if="pagination.pages > 1">
<ul class="pagination justify-content-center">
<li class="page-item" :class="{ disabled: pagination.page <= 1 }">
<a class="page-link" href="#" @click.prevent="changePage(pagination.page - 1)">
上一页
</a>
</li>
<li v-for="page in pagination.pages" :key="page"
class="page-item" :class="{ active: page === pagination.page }">
<a class="page-link" href="#" @click.prevent="changePage(page)">
[[ page ]]
</a>
</li>
<li class="page-item" :class="{ disabled: pagination.page >= pagination.pages }">
<a class="page-link" href="#" @click.prevent="changePage(pagination.page + 1)">
下一页
</a>
</li>
</ul>
</nav>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 股票详情模态框 -->
<div class="modal fade" id="stockModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" v-if="selectedStock">
[[ selectedStock.stock_name ]] ([[ selectedStock.stock_code ]])
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div v-if="selectedStock" class="row">
<div class="col-md-12">
<div class="chart-container">
<div ref="klineChart"></div>
</div>
</div>
</div>
<div v-if="stockDetailLoading" class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">加载中...</span>
</div>
<p>加载股票详情中...</p>
</div>
<div v-else-if="stockDetail && stockDetail.stock_info">
<div class="row">
<div class="col-md-6">
<h6>基本信息</h6>
<table class="table table-sm">
<tr>
<td>股票代码:</td>
<td>[[ stockDetail.stock_info.code ]]</td>
</tr>
<tr>
<td>股票名称:</td>
<td>[[ stockDetail.stock_info.name ]]</td>
</tr>
<tr>
<td>当前价格:</td>
<td>[[ stockDetail.stock_info.price ]]元</td>
</tr>
<tr>
<td>市盈率:</td>
<td>[[ stockDetail.stock_info.pe_ratio ]]</td>
</tr>
</table>
</div>
<div class="col-md-6">
<h6>估值指标</h6>
<table class="table table-sm">
<tr>
<td>市净率:</td>
<td>[[ stockDetail.stock_info.pb_ratio ]]</td>
</tr>
<tr>
<td>市销率:</td>
<td>[[ stockDetail.stock_info.ps_ratio ]]</td>
</tr>
<tr>
<td>股息率:</td>
<td>[[ (stockDetail.stock_info.dividend_yield * 100).toFixed(2) ]]%</td>
</tr>
<tr>
<td>总市值:</td>
<td>[[ stockDetail.stock_info.market_value ]]亿元</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">关闭</button>
<button type="button" class="btn btn-primary"
v-if="selectedStock" @click="addToWatchlist(selectedStock.stock_code)">
<i class="bi bi-plus"></i>
添加到监控
</button>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@3.2.31/dist/vue.global.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.0/dist/echarts.min.js"></script>
<script>
const { createApp } = Vue;
createApp({
delimiters: ['[[', ']]'],
data() {
return {
stocks: [],
industries: [],
sectors: [],
overview: {},
loading: false,
overviewLoading: false,
syncing: false,
stockDetailLoading: false,
selectedStock: null,
stockDetail: null,
searchKeyword: '',
selectedIndustry: '',
selectedSector: '',
pagination: {
page: 1,
size: 20,
total: 0,
pages: 1
},
searchTimer: null,
klineChart: null
};
},
mounted() {
this.loadStocks();
this.loadIndustries();
this.loadSectors();
this.loadOverview();
},
methods: {
async loadStocks() {
this.loading = true;
try {
const params = {
page: this.pagination.page,
size: this.pagination.size
};
if (this.searchKeyword) params.search = this.searchKeyword;
if (this.selectedIndustry) params.industry = this.selectedIndustry;
if (this.selectedSector) params.sector = this.selectedSector;
const response = await axios.get('/api/market/stocks', { params });
this.stocks = response.data.data;
this.pagination.total = response.data.total;
this.pagination.pages = response.data.pages;
} catch (error) {
console.error('加载股票列表失败:', error);
} finally {
this.loading = false;
}
},
async loadIndustries() {
try {
const response = await axios.get('/api/market/industries');
this.industries = response.data.data;
} catch (error) {
console.error('加载行业列表失败:', error);
}
},
async loadSectors() {
try {
const response = await axios.get('/api/market/sectors');
this.sectors = response.data.data;
} catch (error) {
console.error('加载概念板块失败:', error);
}
},
async loadOverview() {
this.overviewLoading = true;
try {
const response = await axios.get('/api/market/overview');
this.overview = response.data.data;
} catch (error) {
console.error('加载市场概览失败:', error);
} finally {
this.overviewLoading = false;
}
},
searchStocks() {
clearTimeout(this.searchTimer);
this.searchTimer = setTimeout(() => {
this.pagination.page = 1;
this.loadStocks();
}, 500);
},
filterStocks() {
this.pagination.page = 1;
this.loadStocks();
},
async loadHotStocks(rankType) {
if (!rankType) return;
this.loading = true;
try {
const response = await axios.get('/api/market/hot-stocks', {
params: { rank_type: rankType, limit: 50 }
});
this.stocks = response.data.data;
this.pagination.total = response.data.data.length;
this.pagination.pages = 1;
} catch (error) {
console.error('加载热门股票失败:', error);
} finally {
this.loading = false;
}
},
async syncData() {
this.syncing = true;
try {
const response = await axios.post('/api/market/sync');
if (response.data.message) {
alert('数据同步成功!');
this.loadOverview();
this.loadStocks();
}
} catch (error) {
console.error('同步数据失败:', error);
alert('数据同步失败: ' + (error.response?.data?.error || error.message));
} finally {
this.syncing = false;
}
},
refreshOverview() {
this.loadOverview();
},
changePage(page) {
if (page >= 1 && page <= this.pagination.pages) {
this.pagination.page = page;
this.loadStocks();
}
},
formatVolume(volume) {
if (!volume) return '-';
if (volume >= 100000000) {
return (volume / 100000000).toFixed(2) + '亿';
} else if (volume >= 10000) {
return (volume / 10000).toFixed(2) + '万';
}
return volume.toString();
},
formatAmount(amount) {
if (!amount) return '-';
if (amount >= 10000) {
return (amount / 10000).toFixed(2) + '亿';
}
return amount.toFixed(2);
},
getSectorNames(sectorNames) {
if (!sectorNames) return [];
return sectorNames.split(',');
},
async showStockDetail(stockCode) {
this.selectedStock = { stock_code: stockCode };
this.stockDetailLoading = true;
try {
const response = await axios.get(`/api/market/stocks/${stockCode}`);
this.selectedStock = response.data.data;
} catch (error) {
console.error('获取股票详情失败:', error);
}
try {
const response = await axios.get(`/api/market/stocks/${stockCode}/kline`, {
params: { days: 60 }
});
this.stockDetail = response.data;
this.$nextTick(() => {
this.renderKlineChart();
});
} catch (error) {
console.error('获取K线数据失败:', error);
} finally {
this.stockDetailLoading = false;
}
const modal = new bootstrap.Modal(document.getElementById('stockModal'));
modal.show();
},
renderKlineChart() {
if (!this.$refs.klineChart || !this.stockDetail?.data) return;
const klineData = this.stockDetail.data;
const dates = klineData.map(item => item.date);
const prices = klineData.map(item => item.close);
const volumes = klineData.map(item => item.volume / 1000);
if (this.klineChart) {
this.klineChart.dispose();
}
this.klineChart = echarts.init(this.$refs.klineChart);
const option = {
title: {
text: `${this.selectedStock.stock_name} K线图`,
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
legend: {
data: ['收盘价', '成交量'],
top: 30
},
xAxis: {
type: 'category',
data: dates,
axisPointer: {
type: 'shadow'
}
},
yAxis: [
{
type: 'value',
name: '价格',
position: 'left',
axisLabel: {
formatter: '{value}元'
}
},
{
type: 'value',
name: '成交量',
position: 'right',
axisLabel: {
formatter: '{value}千手'
}
}
],
series: [
{
name: '收盘价',
type: 'line',
data: prices,
smooth: true,
itemStyle: {
color: '#1890ff'
}
},
{
name: '成交量',
type: 'bar',
yAxisIndex: 1,
data: volumes,
itemStyle: {
color: '#ffa940'
}
}
],
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
}
};
this.klineChart.setOption(option);
},
async addToWatchlist(stockCode) {
try {
const formData = new FormData();
formData.append('stock_code', stockCode);
const response = await axios.post('/api/add_watch', formData);
if (response.data.status === 'success') {
alert('已添加到监控列表!');
} else {
alert('添加失败: ' + (response.data.error || '未知错误'));
}
} catch (error) {
console.error('添加到监控列表失败:', error);
alert('添加失败: ' + (error.response?.data?.error || error.message));
}
}
}
}).mount('#app');
</script>
</body>
</html>