- 重构数据访问层:引入DAO模式,支持MySQL/SQLite双数据库 - 新增数据库架构:完整的股票数据、AI分析、自选股管理表结构 - 升级AI分析服务:集成豆包大模型,支持多维度分析 - 优化API路由:分离市场数据API,提供更清晰的接口设计 - 完善项目文档:添加数据库迁移指南、新功能指南等 - 清理冗余文件:删除旧的缓存文件和无用配置 - 新增调度器:支持定时任务和数据自动更新 - 改进前端模板:简化的股票展示页面 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
678 lines
32 KiB
HTML
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> |