490 lines
13 KiB
Vue
490 lines
13 KiB
Vue
<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">
|
|
<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>
|
|
<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>
|
|
</div>
|
|
</n-grid-item>
|
|
</n-grid>
|
|
</n-card>
|
|
</n-layout-content>
|
|
</n-layout>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted } from 'vue';
|
|
import {
|
|
NLayout,
|
|
NLayoutContent,
|
|
NCard,
|
|
NPageHeader,
|
|
NIcon,
|
|
NGrid,
|
|
NGridItem,
|
|
NFormItem,
|
|
NSelect,
|
|
NInput,
|
|
NButton,
|
|
NEmpty,
|
|
useMessage
|
|
} from 'naive-ui';
|
|
import { useClipboard } from '@vueuse/core'
|
|
import {
|
|
BarChart as BarChartIcon,
|
|
DocumentText as DocumentTextIcon
|
|
} 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[]>([]);
|
|
|
|
// 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' }
|
|
];
|
|
|
|
// 更新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);
|
|
}
|
|
} 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 => s.code === data.stock_code);
|
|
|
|
if (stockIndex >= 0) {
|
|
const stock = { ...analyzedStocks.value[stockIndex] };
|
|
|
|
// 更新分析状态
|
|
stock.analysisStatus = data.status;
|
|
|
|
// 如果有分析结果,则更新
|
|
if (data.analysis !== undefined) {
|
|
stock.analysis = data.analysis;
|
|
}
|
|
|
|
// 如果有错误,则更新
|
|
if (data.error !== undefined) {
|
|
stock.error = data.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;
|
|
}
|
|
|
|
// 更新数组中的股票信息
|
|
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 => code.trim())
|
|
.filter(code => 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);
|
|
}
|
|
|
|
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 => stock.analysisStatus === 'completed')
|
|
.map(stock => {
|
|
return `【${stock.code} ${stock.name || ''}】\n${stock.analysis || '无分析结果'}\n`;
|
|
})
|
|
.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);
|
|
}
|
|
}
|
|
|
|
// 页面加载时获取默认配置和公告
|
|
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;
|
|
}
|
|
</style>
|