Files
stock-scanner/frontend/src/components/StockAnalysisApp.vue
2025-03-06 20:30:54 +08:00

930 lines
25 KiB
Vue
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.

<template>
<div class="app-container">
<!-- 公告栏 -->
<AnnouncementBanner v-if="announcement" :content="announcement" :auto-close-time="5" />
<n-layout class="main-layout">
<n-layout-content class="main-content">
<n-page-header title="股票分析系统">
<template #avatar>
<n-icon :component="BarChartIcon" color="#2080f0" size="28" />
</template>
</n-page-header>
<!-- 市场时间显示 -->
<MarketTimeDisplay />
<!-- API配置面板 -->
<ApiConfigPanel
:default-api-url="defaultApiUrl"
:default-api-model="defaultApiModel"
:default-api-timeout="defaultApiTimeout"
@update:api-config="updateApiConfig"
/>
<!-- 主要内容 -->
<n-card class="analysis-container">
<template #header>
<div class="card-title">股票批量分析</div>
</template>
<n-grid :cols="24" :x-gap="16" :y-gap="16">
<!-- 左侧配置区域 -->
<n-grid-item :span="24" :lg-span="8">
<div class="config-section">
<n-form-item label="选择市场类型">
<n-select
v-model:value="marketType"
:options="marketOptions"
@update:value="handleMarketTypeChange"
/>
</n-form-item>
<n-form-item label="股票搜索" v-if="marketType === 'US'">
<StockSearch :market-type="marketType" @select="addSelectedStock" />
</n-form-item>
<n-form-item label="输入代码">
<n-input
v-model:value="stockCodes"
type="textarea"
placeholder="输入股票代码,多个代码用逗号、空格或换行分隔"
:autosize="{ minRows: 3, maxRows: 6 }"
/>
</n-form-item>
<div class="action-buttons">
<n-button
type="primary"
:loading="isAnalyzing"
:disabled="!stockCodes.trim()"
@click="analyzeStocks"
>
{{ isAnalyzing ? '分析中...' : '开始分析' }}
</n-button>
<n-button
:disabled="analyzedStocks.length === 0"
@click="copyAnalysisResults"
>
复制结果
</n-button>
</div>
</div>
</n-grid-item>
<!-- 右侧结果区域 -->
<n-grid-item :span="24" :lg-span="16">
<div class="results-section">
<div class="results-header">
<n-space align="center" justify="space-between">
<n-text>分析结果 ({{ analyzedStocks.length }})</n-text>
<n-space>
<n-select
v-model:value="displayMode"
size="small"
style="width: 120px"
:options="[
{ label: '卡片视图', value: 'card' },
{ label: '表格视图', value: 'table' }
]"
/>
<n-button
size="small"
:disabled="analyzedStocks.length === 0"
@click="copyAnalysisResults"
>
复制结果
</n-button>
<n-dropdown
trigger="click"
:disabled="analyzedStocks.length === 0"
:options="exportOptions"
@select="handleExportSelect"
>
<n-button size="small" :disabled="analyzedStocks.length === 0">
导出
<template #icon>
<n-icon>
<DownloadIcon />
</n-icon>
</template>
</n-button>
</n-dropdown>
</n-space>
</n-space>
</div>
<template v-if="analyzedStocks.length === 0 && !isAnalyzing">
<n-empty description="尚未分析股票" size="large">
<template #icon>
<n-icon :component="DocumentTextIcon" />
</template>
</n-empty>
</template>
<template v-else-if="displayMode === 'card'">
<n-grid :cols="1" :x-gap="16" :y-gap="16" :lg-cols="2">
<n-grid-item v-for="stock in analyzedStocks" :key="stock.code">
<StockCard :stock="stock" />
</n-grid-item>
</n-grid>
</template>
<template v-else>
<n-data-table
:columns="stockTableColumns"
:data="analyzedStocks"
:pagination="{ pageSize: 10 }"
:row-key="(row) => row.code"
:bordered="false"
:single-line="false"
striped
/>
</template>
</div>
</n-grid-item>
</n-grid>
</n-card>
</n-layout-content>
</n-layout>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import {
NLayout,
NLayoutContent,
NCard,
NPageHeader,
NIcon,
NGrid,
NGridItem,
NFormItem,
NSelect,
NInput,
NButton,
NEmpty,
useMessage,
NSpace,
NText,
NDataTable,
NDropdown,
type DataTableColumns
} from 'naive-ui';
import { useClipboard } from '@vueuse/core'
import {
BarChartOutline as BarChartIcon,
DocumentTextOutline as DocumentTextIcon,
DownloadOutline as DownloadIcon
} from '@vicons/ionicons5';
import AnnouncementBanner from './AnnouncementBanner.vue';
import MarketTimeDisplay from './MarketTimeDisplay.vue';
import ApiConfigPanel from './ApiConfigPanel.vue';
import StockSearch from './StockSearch.vue';
import StockCard from './StockCard.vue';
import { apiService } from '@/services/api';
import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types';
import { loadApiConfig } from '@/utils';
// 使用Naive UI的消息组件
const message = useMessage();
const { copy } = useClipboard();
// 从环境变量获取的默认配置
const defaultApiUrl = ref('');
const defaultApiModel = ref('gpt-3.5-turbo');
const defaultApiTimeout = ref('60');
const announcement = ref('');
// 股票分析配置
const marketType = ref('A');
const stockCodes = ref('');
const isAnalyzing = ref(false);
const analyzedStocks = ref<StockInfo[]>([]);
const displayMode = ref<'card' | 'table'>('card');
// API配置
const apiConfig = ref<ApiConfig>({
apiUrl: '',
apiKey: '',
apiModel: 'gpt-3.5-turbo',
apiTimeout: '60',
saveApiConfig: false
});
// 市场选项
const marketOptions = [
{ label: 'A股', value: 'A' },
{ label: '港股', value: 'HK' },
{ label: '美股', value: 'US' },
{ label: 'ETF', value: 'ETF' },
{ label: 'LOF', value: 'LOF' }
];
// 表格列定义
const stockTableColumns = ref<DataTableColumns<StockInfo>>([
{
title: '代码',
key: 'code',
width: 100,
fixed: 'left'
},
{
title: '状态',
key: 'analysisStatus',
width: 100,
render(row: StockInfo) {
const statusMap = {
'waiting': '等待分析',
'analyzing': '分析中',
'completed': '已完成',
'error': '出错'
};
return statusMap[row.analysisStatus] || row.analysisStatus;
}
},
{
title: '价格',
key: 'price',
width: 100,
render(row: StockInfo) {
return row.price !== undefined ? row.price.toFixed(2) : '--';
}
},
{
title: '涨跌幅',
key: 'changePercent',
width: 100,
render(row: StockInfo) {
if (row.changePercent === undefined) return '--';
const sign = row.changePercent > 0 ? '+' : '';
return `${sign}${row.changePercent.toFixed(2)}%`;
}
},
{
title: 'RSI',
key: 'rsi',
width: 80,
render(row: StockInfo) {
return row.rsi !== undefined ? row.rsi.toFixed(2) : '--';
}
},
{
title: '均线趋势',
key: 'ma_trend',
width: 100,
render(row: StockInfo) {
const trendMap: Record<string, string> = {
'UP': '上升',
'DOWN': '下降',
'NEUTRAL': '平稳'
};
return row.ma_trend ? trendMap[row.ma_trend] || row.ma_trend : '--';
}
},
{
title: 'MACD信号',
key: 'macd_signal',
width: 100,
render(row: StockInfo) {
const signalMap: Record<string, string> = {
'BUY': '买入',
'SELL': '卖出',
'HOLD': '持有',
'NEUTRAL': '中性'
};
return row.macd_signal ? signalMap[row.macd_signal] || row.macd_signal : '--';
}
},
{
title: '评分',
key: 'score',
width: 80,
render(row: StockInfo) {
return row.score !== undefined ? row.score : '--';
}
},
{
title: '推荐',
key: 'recommendation',
width: 100
},
{
title: '分析日期',
key: 'analysis_date',
width: 120,
render(row: StockInfo) {
if (!row.analysis_date) return '--';
try {
const date = new Date(row.analysis_date);
if (isNaN(date.getTime())) {
return row.analysis_date;
}
return date.toISOString().split('T')[0];
} catch (e) {
return row.analysis_date;
}
}
},
{
title: '分析结果',
key: 'analysis',
ellipsis: {
tooltip: true
}
}
]);
// 导出选项
const exportOptions = [
{
label: '导出为CSV',
key: 'csv'
},
{
label: '导出为Excel',
key: 'excel'
},
{
label: '导出为PDF',
key: 'pdf'
}
];
// 更新API配置
function updateApiConfig(config: ApiConfig) {
apiConfig.value = { ...config };
}
// 处理市场类型变更
function handleMarketTypeChange() {
stockCodes.value = '';
analyzedStocks.value = [];
}
// 添加选择的股票
function addSelectedStock(symbol: string) {
if (stockCodes.value) {
stockCodes.value += ', ' + symbol;
} else {
stockCodes.value = symbol;
}
}
// 处理流式响应的数据
function processStreamData(text: string) {
try {
// 尝试解析为JSON
const data = JSON.parse(text);
// 判断是初始消息还是更新消息
if (data.stream_type === 'single' || data.stream_type === 'batch') {
// 初始消息
handleStreamInit(data as StreamInitMessage);
} else if (data.stock_code) {
// 更新消息
handleStreamUpdate(data as StreamAnalysisUpdate);
} else if (data.scan_completed) {
// 扫描完成消息
message.success(`分析完成,共扫描 ${data.total_scanned} 只股票,符合条件 ${data.total_matched} 只`);
// 将所有分析中的股票状态更新为已完成
analyzedStocks.value.forEach((stock, index) => {
if (stock.analysisStatus === 'analyzing') {
const updatedStock = {
...stock,
analysisStatus: 'completed' as const
};
analyzedStocks.value[index] = updatedStock;
}
});
isAnalyzing.value = false;
}
} catch (e) {
console.error('解析流数据出错:', e);
}
}
// 处理流式初始化消息
function handleStreamInit(data: StreamInitMessage) {
if (data.stream_type === 'single' && data.stock_code) {
// 单个股票分析
analyzedStocks.value = [{
code: data.stock_code,
name: '',
marketType: marketType.value,
analysisStatus: 'waiting'
}];
} else if (data.stream_type === 'batch' && data.stock_codes) {
// 批量分析
analyzedStocks.value = data.stock_codes.map(code => ({
code,
name: '',
marketType: marketType.value,
analysisStatus: 'waiting'
}));
}
}
// 处理流式更新消息
function handleStreamUpdate(data: StreamAnalysisUpdate) {
const stockIndex = analyzedStocks.value.findIndex((s: StockInfo) => s.code === data.stock_code);
if (stockIndex >= 0) {
const stock = { ...analyzedStocks.value[stockIndex] };
// 更新分析状态
if (data.status) {
stock.analysisStatus = data.status;
}
// 如果有分析结果,则更新
if (data.analysis !== undefined) {
stock.analysis = data.analysis;
}
// 处理AI分析片段
if (data.ai_analysis_chunk !== undefined) {
// 如果之前没有分析内容,则初始化
if (!stock.analysis) {
stock.analysis = '';
}
// 追加新的分析片段
stock.analysis += data.ai_analysis_chunk;
// 确保分析状态为正在分析
stock.analysisStatus = 'analyzing';
}
// 如果有错误,则更新
if (data.error !== undefined) {
stock.error = data.error;
stock.analysisStatus = 'error';
}
// 更新股票名称、价格等信息
if (data.name !== undefined) {
stock.name = data.name;
}
if (data.price !== undefined) {
stock.price = data.price;
}
if (data.change_percent !== undefined) {
stock.changePercent = data.change_percent;
}
if (data.market_value !== undefined) {
stock.marketValue = data.market_value;
}
// 添加新字段的处理
if (data.score !== undefined) {
stock.score = data.score;
}
if (data.recommendation !== undefined) {
stock.recommendation = data.recommendation;
}
if (data.price_change !== undefined) {
stock.price_change = data.price_change;
}
if (data.rsi !== undefined) {
stock.rsi = data.rsi;
}
// 添加技术指标字段的处理
if (data.ma_trend !== undefined) {
stock.ma_trend = data.ma_trend;
}
if (data.macd_signal !== undefined) {
stock.macd_signal = data.macd_signal;
}
if (data.volume_status !== undefined) {
stock.volume_status = data.volume_status;
}
// 添加分析日期字段的处理
if (data.analysis_date !== undefined) {
stock.analysis_date = data.analysis_date;
}
// 更新数组中的股票信息
analyzedStocks.value[stockIndex] = stock;
}
}
// 分析股票
async function analyzeStocks() {
if (!stockCodes.value.trim()) {
message.warning('请输入股票代码');
return;
}
isAnalyzing.value = true;
analyzedStocks.value = [];
// 解析股票代码
const codes = stockCodes.value
.split(/[,\s\n]+/)
.map((code: string) => code.trim())
.filter((code: string) => code);
if (codes.length === 0) {
message.warning('未找到有效的股票代码');
isAnalyzing.value = false;
return;
}
try {
// 构建请求参数
const requestData = {
stock_codes: codes,
market_type: marketType.value
} as any;
// 添加自定义API配置
if (apiConfig.value.apiUrl) {
requestData.api_url = apiConfig.value.apiUrl;
}
if (apiConfig.value.apiKey) {
requestData.api_key = apiConfig.value.apiKey;
}
if (apiConfig.value.apiModel) {
requestData.api_model = apiConfig.value.apiModel;
}
if (apiConfig.value.apiTimeout) {
requestData.api_timeout = apiConfig.value.apiTimeout;
}
// 发送分析请求
const response = await fetch('/analyze', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(`HTTP error ${response.status}`);
}
// 处理流式响应
const reader = response.body?.getReader();
if (!reader) {
throw new Error('无法读取响应流');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// 解码并处理数据
const text = decoder.decode(value, { stream: true });
buffer += text;
// 按行处理数据
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 最后一行可能不完整,保留到下一次
for (const line of lines) {
if (line.trim()) {
processStreamData(line);
}
}
}
// 处理最后可能剩余的数据
if (buffer.trim()) {
processStreamData(buffer);
}
// 注意不再需要在这里更新状态因为已经在processStreamData中处理了scan_completed消息
message.success('分析完成');
} catch (error: any) {
message.error(`分析出错: ${error.message || '未知错误'}`);
console.error('分析股票时出错:', error);
} finally {
isAnalyzing.value = false;
}
}
// 复制分析结果
async function copyAnalysisResults() {
if (analyzedStocks.value.length === 0) {
message.warning('没有可复制的分析结果');
return;
}
try {
// 格式化分析结果
const formattedResults = analyzedStocks.value
.filter((stock: StockInfo) => stock.analysisStatus === 'completed')
.map((stock: StockInfo) => {
let result = `【${stock.code} ${stock.name || ''}】\n`;
// 添加分析日期
if (stock.analysis_date) {
try {
const date = new Date(stock.analysis_date);
if (!isNaN(date.getTime())) {
result += `分析日期: ${date.toISOString().split('T')[0]}\n`;
} else {
result += `分析日期: ${stock.analysis_date}\n`;
}
} catch (e) {
result += `分析日期: ${stock.analysis_date}\n`;
}
}
// 添加评分和推荐信息
if (stock.score !== undefined) {
result += `评分: ${stock.score}\n`;
}
if (stock.recommendation) {
result += `推荐: ${stock.recommendation}\n`;
}
// 添加技术指标信息
if (stock.rsi !== undefined) {
result += `RSI: ${stock.rsi.toFixed(2)}\n`;
}
if (stock.price_change !== undefined) {
const sign = stock.price_change > 0 ? '+' : '';
result += `价格变动: ${sign}${stock.price_change.toFixed(2)}\n`;
}
if (stock.ma_trend) {
const trendMap: Record<string, string> = {
'UP': '上升',
'DOWN': '下降',
'NEUTRAL': '平稳'
};
const trend = trendMap[stock.ma_trend] || stock.ma_trend;
result += `均线趋势: ${trend}\n`;
}
if (stock.macd_signal) {
const signalMap: Record<string, string> = {
'BUY': '买入',
'SELL': '卖出',
'HOLD': '持有',
'NEUTRAL': '中性'
};
const signal = signalMap[stock.macd_signal] || stock.macd_signal;
result += `MACD信号: ${signal}\n`;
}
if (stock.volume_status) {
const statusMap: Record<string, string> = {
'HIGH': '放量',
'LOW': '缩量',
'NORMAL': '正常'
};
const status = statusMap[stock.volume_status] || stock.volume_status;
result += `成交量: ${status}\n`;
}
// 添加分析结果
result += `\n${stock.analysis || '无分析结果'}\n`;
return result;
})
.join('\n');
if (!formattedResults) {
message.warning('没有已完成的分析结果可复制');
return;
}
// 复制到剪贴板
await copy(formattedResults);
message.success('已复制分析结果到剪贴板');
} catch (error) {
message.error('复制失败,请手动复制');
console.error('复制分析结果时出错:', error);
}
}
// 从本地存储恢复API配置
function restoreLocalApiConfig() {
const savedConfig = loadApiConfig();
if (savedConfig && savedConfig.saveApiConfig) {
apiConfig.value = {
apiUrl: savedConfig.apiUrl || '',
apiKey: savedConfig.apiKey || '',
apiModel: savedConfig.apiModel || defaultApiModel.value,
apiTimeout: savedConfig.apiTimeout || defaultApiTimeout.value,
saveApiConfig: savedConfig.saveApiConfig
};
// 通知父组件配置已更新
updateApiConfig(apiConfig.value);
}
}
// 处理导出选择
function handleExportSelect(key: string) {
switch (key) {
case 'csv':
exportToCSV();
break;
case 'excel':
message.info('Excel导出功能即将推出');
break;
case 'pdf':
message.info('PDF导出功能即将推出');
break;
}
}
// 导出为CSV
function exportToCSV() {
if (analyzedStocks.value.length === 0) {
message.warning('没有可导出的分析结果');
return;
}
try {
// 创建CSV内容
const headers = ['代码', '名称', '价格', '涨跌幅', 'RSI', '均线趋势', 'MACD信号', '成交量状态', '评分', '推荐', '分析日期'];
let csvContent = headers.join(',') + '\n';
// 添加数据行
analyzedStocks.value.forEach(stock => {
const row = [
`"${stock.code}"`,
`"${stock.name || ''}"`,
stock.price !== undefined ? stock.price.toFixed(2) : '',
stock.changePercent !== undefined ? `${stock.changePercent > 0 ? '+' : ''}${stock.changePercent.toFixed(2)}%` : '',
stock.rsi !== undefined ? stock.rsi.toFixed(2) : '',
stock.ma_trend ? getChineseTrend(stock.ma_trend) : '',
stock.macd_signal ? getChineseSignal(stock.macd_signal) : '',
stock.volume_status ? getChineseVolumeStatus(stock.volume_status) : '',
stock.score !== undefined ? stock.score : '',
`"${stock.recommendation || ''}"`,
stock.analysis_date || ''
];
csvContent += row.join(',') + '\n';
});
// 创建Blob对象
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `股票分析结果_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
// 添加到文档并触发点击
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('已导出CSV文件');
} catch (error) {
message.error('导出失败');
console.error('导出CSV时出错:', error);
}
}
// 辅助函数:获取中文趋势描述
function getChineseTrend(trend: string): string {
const trendMap: Record<string, string> = {
'UP': '上升',
'DOWN': '下降',
'NEUTRAL': '平稳'
};
return trendMap[trend] || trend;
}
// 辅助函数:获取中文信号描述
function getChineseSignal(signal: string): string {
const signalMap: Record<string, string> = {
'BUY': '买入',
'SELL': '卖出',
'HOLD': '持有',
'NEUTRAL': '中性'
};
return signalMap[signal] || signal;
}
// 辅助函数:获取中文成交量状态描述
function getChineseVolumeStatus(status: string): string {
const statusMap: Record<string, string> = {
'HIGH': '放量',
'LOW': '缩量',
'NORMAL': '正常'
};
return statusMap[status] || status;
}
// 页面加载时获取默认配置和公告
onMounted(async () => {
try {
// 从API获取配置信息
const config = await apiService.getConfig();
if (config.default_api_url) {
defaultApiUrl.value = config.default_api_url;
}
if (config.default_api_model) {
defaultApiModel.value = config.default_api_model;
}
if (config.default_api_timeout) {
defaultApiTimeout.value = config.default_api_timeout;
}
if (config.announcement) {
announcement.value = config.announcement;
}
// 初始化后恢复本地保存的配置
restoreLocalApiConfig();
} catch (error) {
console.error('获取默认配置时出错:', error);
}
});
</script>
<style scoped>
.app-container {
min-height: 100vh;
}
.main-layout {
background-color: #f6f6f6;
}
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
}
.analysis-container {
margin-bottom: 2rem;
}
.config-section {
padding: 0.5rem;
}
.action-buttons {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.results-section {
padding: 0.5rem;
min-height: 200px;
}
.results-header {
margin-bottom: 1rem;
}
.n-data-table .analysis-cell {
max-width: 300px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>