feat: vue重构

This commit is contained in:
Cassianvale
2025-03-05 17:45:09 +08:00
parent 5dacc9f528
commit 4393bf68cd
27 changed files with 4164 additions and 15 deletions

View File

@@ -0,0 +1,489 @@
<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>