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

41
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,41 @@
<template>
<n-config-provider :theme="theme">
<n-message-provider>
<n-loading-bar-provider>
<n-dialog-provider>
<n-notification-provider>
<StockAnalysisApp />
</n-notification-provider>
</n-dialog-provider>
</n-loading-bar-provider>
</n-message-provider>
</n-config-provider>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import {
NConfigProvider,
NMessageProvider,
NLoadingBarProvider,
NDialogProvider,
NNotificationProvider,
} from 'naive-ui'
import StockAnalysisApp from './components/StockAnalysisApp.vue'
// 主题设置 (默认使用亮色主题)
const theme = ref<any>(null) // 可以切换为 darkTheme 以启用暗色模式
</script>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
margin: 0;
padding: 0;
min-height: 100vh;
background-color: #f6f6f6;
}
</style>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,128 @@
<template>
<div v-if="showAnnouncement" class="announcement-container">
<n-card class="announcement-card">
<template #header>
<div class="announcement-header">
<n-icon size="18" :component="InformationCircleIcon" class="info-icon" />
<span>系统公告</span>
</div>
</template>
<div class="announcement-content" v-html="processedContent"></div>
<div class="announcement-timer">{{ remainingTimeText }}</div>
<template #action>
<n-button quaternary circle size="small" @click="closeAnnouncement">
<template #icon>
<n-icon :component="CloseIcon" />
</template>
</n-button>
</template>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { NCard, NIcon, NButton } from 'naive-ui';
import { InformationCircleOutline as InformationCircleIcon } from '@vicons/ionicons5';
import { Close as CloseIcon } from '@vicons/ionicons5';
const props = defineProps<{
content: string;
autoCloseTime?: number;
}>();
const showAnnouncement = ref(true);
const remainingTime = ref(props.autoCloseTime || 5);
const timer = ref<number | null>(null);
const remainingTimeText = computed(() => {
return `${remainingTime.value}秒后自动关闭`;
});
const processedContent = computed(() => {
// 处理文本中的URL
const urlRegex = /(https?:\/\/[^\s]+)/g;
return props.content.replace(
urlRegex,
'<a href="$1" target="_blank" class="announcement-link">$1</a>'
);
});
function closeAnnouncement() {
showAnnouncement.value = false;
if (timer.value !== null) {
window.clearInterval(timer.value);
timer.value = null;
}
}
function updateTimer() {
if (remainingTime.value <= 1) {
closeAnnouncement();
} else {
remainingTime.value--;
}
}
onMounted(() => {
timer.value = window.setInterval(updateTimer, 1000);
});
onBeforeUnmount(() => {
if (timer.value !== null) {
window.clearInterval(timer.value);
}
});
</script>
<style scoped>
.announcement-container {
position: fixed;
top: 1rem;
right: 1rem;
max-width: 24rem;
z-index: 50;
animation: fadeInDown 0.3s ease-out;
}
.announcement-card {
border-left: 4px solid var(--n-primary-color);
}
.announcement-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
}
.info-icon {
color: var(--n-primary-color);
}
.announcement-content {
margin-bottom: 0.5rem;
white-space: pre-line;
}
.announcement-timer {
font-size: 0.75rem;
color: var(--n-text-color-disabled);
}
.announcement-link {
color: var(--n-primary-color);
text-decoration: underline;
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -0,0 +1,339 @@
<template>
<div class="api-config-section">
<n-button
class="toggle-button"
size="small"
@click="toggleConfig"
:quaternary="true"
>
<template #icon>
<n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" />
</template>
API配置 {{ expanded ? '收起' : '展开' }}
</n-button>
<n-collapse-transition :show="expanded">
<n-card class="api-config-card" content-style="padding: 0.75rem;">
<n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible">
<template #icon>
<n-icon :component="InformationCircleIcon" />
</template>
<p>您可以配置自己的API也可以使用系统默认配置API密钥仅在您的浏览器中使用不会发送到服务器存储</p>
<div class="alert-actions">
<n-button text @click="isApiInfoVisible = false">
<template #icon>
<n-icon :component="CloseIcon" />
</template>
</n-button>
</div>
</n-alert>
<n-grid :cols="24" :x-gap="12">
<n-grid-item :span="14">
<n-form-item label="API URL" path="apiUrl">
<n-input
v-model:value="apiConfig.apiUrl"
placeholder="https://api.openai.com/v1/chat/completions"
@update:value="handleConfigChange"
/>
<template #feedback>
<span class="formatted-url">{{ formattedUrl }}</span>
</template>
</n-form-item>
</n-grid-item>
<n-grid-item :span="10">
<n-form-item label="API Key" path="apiKey">
<n-input
v-model:value="apiConfig.apiKey"
type="password"
placeholder="sk-..."
show-password-on="click"
@update:value="handleConfigChange"
/>
</n-form-item>
</n-grid-item>
<n-grid-item :span="12">
<n-form-item label="模型" path="apiModel">
<n-input
v-model:value="apiConfig.apiModel"
placeholder="gpt-3.5-turbo"
@update:value="handleConfigChange"
/>
</n-form-item>
</n-grid-item>
<n-grid-item :span="12">
<n-form-item label="超时时间(秒)" path="apiTimeout">
<n-input-number
v-model:value="apiTimeout"
placeholder="60"
:min="1"
:max="300"
@update:value="handleTimeoutChange"
/>
</n-form-item>
</n-grid-item>
</n-grid>
<div class="api-actions">
<div class="api-save-option">
<n-switch
v-model:value="apiConfig.saveApiConfig"
@update:value="handleConfigChange"
/>
<span class="save-label">保存配置到本地</span>
</div>
<div class="api-buttons">
<n-button
type="primary"
:loading="testingConnection"
:disabled="!isConfigValid"
@click="testConnection"
>
测试连接
</n-button>
<n-button @click="resetConfig">
重置
</n-button>
</div>
</div>
</n-card>
</n-collapse-transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import {
NButton,
NIcon,
NCard,
NCollapseTransition,
NGrid,
NGridItem,
NFormItem,
NInput,
NInputNumber,
NSwitch,
NAlert,
useMessage
} from 'naive-ui';
import {
ChevronDown as ChevronDownIcon,
ChevronUp as ChevronUpIcon,
InformationCircleOutline as InformationCircleIcon,
Close as CloseIcon
} from '@vicons/ionicons5';
import { apiService } from '@/services/api';
import { saveApiConfigToLocalStorage, loadApiConfig } from '@/utils';
import type { ApiConfig } from '@/types';
const props = defineProps<{
defaultApiUrl?: string;
defaultApiModel?: string;
defaultApiTimeout?: string;
}>();
const emit = defineEmits<{
(e: 'update:apiConfig', value: ApiConfig): void;
}>();
const message = useMessage();
const expanded = ref(false);
const testingConnection = ref(false);
const isApiInfoVisible = ref(true);
// API配置
const apiConfig = ref<ApiConfig>({
apiUrl: props.defaultApiUrl || '',
apiKey: '',
apiModel: props.defaultApiModel || 'gpt-3.5-turbo',
apiTimeout: props.defaultApiTimeout || '60',
saveApiConfig: false
});
const apiTimeout = computed({
get: () => parseInt(apiConfig.value.apiTimeout) || 60,
set: (val: number) => {
apiConfig.value.apiTimeout = val.toString();
}
});
const isConfigValid = computed(() => {
return apiConfig.value.apiUrl && apiConfig.value.apiKey;
});
const formattedUrl = computed(() => {
return formatApiUrl(apiConfig.value.apiUrl);
});
function toggleConfig() {
expanded.value = !expanded.value;
}
function handleConfigChange() {
// 如果选择了保存配置,则自动保存
if (apiConfig.value.saveApiConfig) {
saveApiConfigToLocalStorage({
apiUrl: apiConfig.value.apiUrl,
apiKey: apiConfig.value.apiKey,
apiModel: apiConfig.value.apiModel,
apiTimeout: apiConfig.value.apiTimeout,
saveApiConfig: true
});
}
// 向父组件发送更新事件
emit('update:apiConfig', { ...apiConfig.value });
}
function handleTimeoutChange(value: number | null) {
if (value !== null) {
apiConfig.value.apiTimeout = value.toString();
handleConfigChange();
}
}
function formatApiUrl(url: string): string {
if (!url) return '';
try {
// 尝试解析URL
const parsedUrl = new URL(url);
return `${parsedUrl.origin}${parsedUrl.pathname}`;
} catch (e) {
// 如果URL格式错误则返回原始字符串
return url;
}
}
async function testConnection() {
if (!isConfigValid.value) {
message.error('请填写完整的API配置信息');
return;
}
testingConnection.value = true;
try {
const response = await apiService.testApiConnection({
api_url: apiConfig.value.apiUrl,
api_key: apiConfig.value.apiKey,
api_model: apiConfig.value.apiModel,
api_timeout: apiConfig.value.apiTimeout
});
if (response.success) {
message.success('API连接测试成功');
// 如果选择了保存配置,则保存
if (apiConfig.value.saveApiConfig) {
saveApiConfigToLocalStorage({
apiUrl: apiConfig.value.apiUrl,
apiKey: apiConfig.value.apiKey,
apiModel: apiConfig.value.apiModel,
apiTimeout: apiConfig.value.apiTimeout,
saveApiConfig: true
});
}
} else {
message.error(`API连接测试失败: ${response.message}`);
}
} catch (error: any) {
message.error(`测试连接出错: ${error.message || '未知错误'}`);
} finally {
testingConnection.value = false;
}
}
function resetConfig() {
apiConfig.value = {
apiUrl: props.defaultApiUrl || '',
apiKey: '',
apiModel: props.defaultApiModel || 'gpt-3.5-turbo',
apiTimeout: props.defaultApiTimeout || '60',
saveApiConfig: false
};
// 清除本地存储
if (window.localStorage) {
localStorage.removeItem('apiConfig');
}
message.success('已重置API配置');
emit('update:apiConfig', { ...apiConfig.value });
}
onMounted(() => {
// 加载保存的配置
const savedConfig = loadApiConfig();
if (savedConfig.saveApiConfig) {
apiConfig.value = {
apiUrl: savedConfig.apiUrl || props.defaultApiUrl || '',
apiKey: savedConfig.apiKey || '',
apiModel: savedConfig.apiModel || props.defaultApiModel || 'gpt-3.5-turbo',
apiTimeout: savedConfig.apiTimeout || props.defaultApiTimeout || '60',
saveApiConfig: true
};
// 通知父组件配置已加载
emit('update:apiConfig', { ...apiConfig.value });
}
});
</script>
<style scoped>
.api-config-section {
margin-bottom: 1rem;
}
.toggle-button {
margin-bottom: 0.5rem;
}
.api-config-card {
margin-bottom: 1rem;
}
.formatted-url {
color: var(--n-text-color-info);
font-size: 0.85rem;
}
.button-group {
display: flex;
gap: 0.75rem;
}
.alert-actions {
margin-top: 0.5rem;
text-align: right;
}
.api-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
}
.api-save-option {
display: flex;
align-items: center;
}
.save-label {
margin-left: 0.5rem;
font-size: 0.875rem;
}
.api-buttons {
display: flex;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<n-card class="market-time-card">
<n-grid :x-gap="16" :y-gap="16" :cols="gridCols">
<!-- 当前时间 -->
<n-grid-item>
<div class="time-block">
<p class="time-label">当前时间</p>
<p class="current-time">{{ marketInfo.currentTime }}</p>
</div>
</n-grid-item>
<!-- A股状态 -->
<n-grid-item>
<div class="time-block">
<p class="time-label">A股市场</p>
<p class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'">
{{ marketInfo.cnMarket.isOpen ? '交易中' : '已休市' }}
</p>
<p class="time-counter">{{ marketInfo.cnMarket.nextTime }}</p>
</div>
</n-grid-item>
<!-- 港股状态 -->
<n-grid-item>
<div class="time-block">
<p class="time-label">港股市场</p>
<p class="market-status" :class="marketInfo.hkMarket.isOpen ? 'status-open' : 'status-closed'">
{{ marketInfo.hkMarket.isOpen ? '交易中' : '已休市' }}
</p>
<p class="time-counter">{{ marketInfo.hkMarket.nextTime }}</p>
</div>
</n-grid-item>
<!-- 美股状态 -->
<n-grid-item>
<div class="time-block">
<p class="time-label">美股市场</p>
<p class="market-status" :class="marketInfo.usMarket.isOpen ? 'status-open' : 'status-closed'">
{{ marketInfo.usMarket.isOpen ? '交易中' : '已休市' }}
</p>
<p class="time-counter">{{ marketInfo.usMarket.nextTime }}</p>
</div>
</n-grid-item>
</n-grid>
</n-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { NCard, NGrid, NGridItem } from 'naive-ui';
import { updateMarketTimeInfo } from '@/utils';
import type { MarketTimeInfo } from '@/types';
const props = defineProps({
isMobile: {
type: Boolean,
default: false
}
});
const marketInfo = ref<MarketTimeInfo>({
currentTime: '',
cnMarket: { isOpen: false, nextTime: '' },
hkMarket: { isOpen: false, nextTime: '' },
usMarket: { isOpen: false, nextTime: '' }
});
const gridCols = computed(() => {
return props.isMobile ? 1 : 4;
});
let intervalId: number | null = null;
function updateMarketTime() {
marketInfo.value = updateMarketTimeInfo();
}
onMounted(() => {
updateMarketTime(); // 立即更新一次
intervalId = window.setInterval(updateMarketTime, 1000);
});
onBeforeUnmount(() => {
if (intervalId !== null) {
window.clearInterval(intervalId);
intervalId = null;
}
});
</script>
<style scoped>
.market-time-card {
margin-bottom: 1.5rem;
}
.time-block {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.time-label {
font-size: 0.875rem;
color: var(--n-text-color-3);
margin-bottom: 0.5rem;
}
.current-time {
font-size: 1.5rem;
font-weight: bold;
color: var(--n-text-color);
}
.market-status {
font-size: 1.125rem;
font-weight: 500;
margin-bottom: 0.25rem;
}
.status-open {
color: var(--n-success-color);
}
.status-closed {
color: var(--n-text-color-3);
}
.time-counter {
font-size: 0.75rem;
color: var(--n-text-color-3);
}
</style>

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>

View File

@@ -0,0 +1,207 @@
<template>
<n-card class="stock-card" :bordered="false" :class="{ 'is-analyzing': isAnalyzing }">
<div class="card-header">
<div class="stock-info">
<div class="stock-code">{{ stock.code }}</div>
<div class="stock-name">{{ stock.name || '加载中...' }}</div>
</div>
<div class="stock-price-info" v-if="stock.price !== undefined">
<div class="stock-price">{{ stock.price.toFixed(2) }}</div>
<div class="stock-change" :class="{
'up': stock.changePercent && stock.changePercent > 0,
'down': stock.changePercent && stock.changePercent < 0
}">
{{ formatChangePercent(stock.changePercent) }}
</div>
</div>
</div>
<n-divider />
<div class="card-content">
<template v-if="stock.analysisStatus === 'waiting'">
<div class="waiting-status">
<n-spin size="small" />
<span>等待分析...</span>
</div>
</template>
<template v-else-if="stock.analysisStatus === 'analyzing'">
<div class="analyzing-status">
<n-spin size="small" />
<span>正在分析...</span>
</div>
</template>
<template v-else-if="stock.analysisStatus === 'error'">
<div class="error-status">
<n-icon :component="AlertCircleIcon" class="error-icon" />
<span>分析出错: {{ stock.error || '未知错误' }}</span>
</div>
</template>
<template v-else-if="stock.analysisStatus === 'completed'">
<div class="analysis-result" v-html="parsedAnalysis"></div>
</template>
</div>
<template #footer>
<div class="card-footer">
<div class="market-value" v-if="stock.marketValue">
市值: {{ formatMarketValue(stock.marketValue) }}
</div>
<div class="market-type">
{{ getMarketName(stock.marketType) }}
</div>
</div>
</template>
</n-card>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { NCard, NDivider, NSpin, NIcon } from 'naive-ui';
import { AlertCircleOutline as AlertCircleIcon } from '@vicons/ionicons5';
import { parseMarkdown, formatMarketValue as formatMarketValueFn } from '@/utils';
import type { StockInfo } from '@/types';
const props = defineProps<{
stock: StockInfo;
}>();
const isAnalyzing = computed(() => {
return props.stock.analysisStatus === 'analyzing';
});
const parsedAnalysis = computed(() => {
if (props.stock.analysis) {
return parseMarkdown(props.stock.analysis);
}
return '';
});
function formatChangePercent(percent: number | undefined): string {
if (percent === undefined) return '--';
const sign = percent > 0 ? '+' : '';
return `${sign}${percent.toFixed(2)}%`;
}
function formatMarketValue(value: number): string {
return formatMarketValueFn(value);
}
function getMarketName(marketType: string): string {
const marketMap: Record<string, string> = {
'A': 'A股',
'HK': '港股',
'US': '美股'
};
return marketMap[marketType] || marketType;
}
</script>
<style scoped>
.stock-card {
height: 100%;
display: flex;
flex-direction: column;
transition: all 0.3s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.stock-card.is-analyzing {
border-left: 3px solid var(--n-info-color);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
}
.stock-info {
display: flex;
flex-direction: column;
}
.stock-code {
font-size: 1.125rem;
font-weight: bold;
color: var(--n-text-color);
}
.stock-name {
font-size: 0.875rem;
color: var(--n-text-color-3);
margin-top: 0.25rem;
}
.stock-price-info {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.stock-price {
font-size: 1.125rem;
font-weight: bold;
color: var(--n-text-color);
}
.stock-change {
font-size: 0.875rem;
margin-top: 0.25rem;
}
.up {
color: var(--n-error-color);
}
.down {
color: var(--n-success-color);
}
.card-content {
flex: 1;
min-height: 100px;
margin-bottom: 0.5rem;
}
.waiting-status,
.analyzing-status,
.error-status {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--n-text-color-3);
font-size: 0.875rem;
}
.error-icon {
color: var(--n-error-color);
}
.analysis-result {
font-size: 0.875rem;
line-height: 1.5;
}
.analysis-result :deep(p) {
margin: 0.5rem 0;
}
.analysis-result :deep(ul) {
margin: 0.5rem 0;
padding-left: 1.25rem;
}
.card-footer {
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: var(--n-text-color-3);
}
</style>

View File

@@ -0,0 +1,220 @@
<template>
<div class="stock-search-container">
<n-input
v-model:value="searchKeyword"
placeholder="输入股票代码或名称搜索"
@input="handleSearchInput"
@blur="handleBlur"
@focus="handleFocus"
ref="searchInputRef"
>
<template #prefix>
<n-icon :component="SearchIcon" />
</template>
</n-input>
<div class="search-results" v-show="showResults">
<div v-if="loading" class="loading-results">
<n-spin size="small" />
<span>搜索中...</span>
</div>
<div v-else-if="results.length === 0 && searchKeyword" class="no-results">
未找到相关股票
</div>
<template v-else>
<n-scrollbar style="max-height: 300px;">
<div
v-for="item in results"
:key="item.symbol"
class="search-result-item"
@click="selectStock(item)"
>
<div class="result-symbol-name">
<span class="result-symbol">{{ item.symbol }}</span>
<span class="result-name">{{ item.name }}</span>
</div>
<div class="result-meta">
<span class="result-market">{{ item.market }}</span>
<span v-if="item.marketValue" class="result-market-value">
市值: {{ formatMarketValue(item.marketValue) }}
</span>
</div>
</div>
</n-scrollbar>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue';
import { NInput, NIcon, NSpin, NScrollbar } from 'naive-ui';
import { Search as SearchIcon } from '@vicons/ionicons5';
import { apiService } from '@/services/api';
import { debounce, formatMarketValue as formatMarketValueFn } from '@/utils';
import type { SearchResult } from '@/types';
const props = defineProps<{
marketType: string;
}>();
const emit = defineEmits<{
(e: 'select', symbol: string): void;
}>();
const searchKeyword = ref('');
const results = ref<SearchResult[]>([]);
const loading = ref(false);
const showResults = ref(false);
const searchInputRef = ref<HTMLElement | null>(null);
// 创建防抖搜索函数
const debouncedSearch = debounce(async (keyword: string) => {
if (!keyword) {
results.value = [];
loading.value = false;
return;
}
loading.value = true;
try {
if (props.marketType === 'US') {
// 美股搜索
results.value = await apiService.searchUsStocks(keyword);
} else {
// 其他市场搜索 (后端需要实现对应的接口)
results.value = [];
}
} catch (error) {
console.error('搜索股票时出错:', error);
results.value = [];
} finally {
loading.value = false;
}
}, 300);
function handleSearchInput() {
showResults.value = true;
debouncedSearch(searchKeyword.value);
}
function selectStock(item: SearchResult) {
emit('select', item.symbol);
searchKeyword.value = '';
showResults.value = false;
}
function handleBlur() {
// 延迟隐藏,以便可以点击结果项
setTimeout(() => {
showResults.value = false;
}, 200);
}
function handleFocus() {
if (searchKeyword.value) {
showResults.value = true;
}
}
function formatMarketValue(value: number): string {
return formatMarketValueFn(value);
}
// 点击外部时隐藏搜索结果
function handleClickOutside(event: MouseEvent) {
if (
searchInputRef.value &&
!searchInputRef.value.contains(event.target as Node)
) {
showResults.value = false;
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside);
});
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside);
});
</script>
<style scoped>
.stock-search-container {
position: relative;
width: 100%;
}
.search-results {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 10;
margin-top: 0.25rem;
background-color: var(--n-color);
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
border: 1px solid var(--n-border-color);
}
.loading-results,
.no-results {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
color: var(--n-text-color-3);
font-size: 0.875rem;
}
.search-result-item {
padding: 0.75rem 1rem;
cursor: pointer;
transition: background-color 0.2s;
display: flex;
justify-content: space-between;
align-items: center;
}
.search-result-item:hover {
background-color: var(--n-color-hover);
}
.result-symbol-name {
display: flex;
flex-direction: column;
}
.result-symbol {
font-weight: 500;
color: var(--n-text-color);
}
.result-name {
font-size: 0.75rem;
color: var(--n-text-color-3);
margin-top: 0.25rem;
}
.result-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.result-market,
.result-market-value {
font-size: 0.75rem;
color: var(--n-text-color-3);
}
.result-market-value {
margin-top: 0.25rem;
}
</style>

6
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')

View File

@@ -0,0 +1,59 @@
import axios from 'axios';
import type { AnalyzeRequest, TestApiRequest, TestApiResponse, SearchResult } from '@/types';
// 在开发环境中前缀为空因为已经在vite.config.ts中配置了代理
const API_PREFIX = '';
export const apiService = {
// 分析股票
analyzeStocks: async (request: AnalyzeRequest) => {
return axios.post(`${API_PREFIX}/analyze`, request, {
responseType: 'stream'
});
},
// 测试API连接
testApiConnection: async (request: TestApiRequest): Promise<TestApiResponse> => {
try {
const response = await axios.post(`${API_PREFIX}/test_api_connection`, request);
return response.data;
} catch (error: any) {
if (error.response) {
return error.response.data;
}
return {
success: false,
message: error.message || '连接失败'
};
}
},
// 搜索美股
searchUsStocks: async (keyword: string): Promise<SearchResult[]> => {
try {
const response = await axios.get(`${API_PREFIX}/search_us_stocks`, {
params: { keyword }
});
return response.data.results || [];
} catch (error) {
console.error('搜索美股时出错:', error);
return [];
}
},
// 获取配置
getConfig: async () => {
try {
const response = await axios.get(`${API_PREFIX}/config`);
return response.data;
} catch (error) {
console.error('获取配置时出错:', error);
return {
announcement: '',
default_api_url: '',
default_api_model: 'gpt-3.5-turbo',
default_api_timeout: '60'
};
}
}
};

79
frontend/src/style.css Normal file
View File

@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,80 @@
// API接口相关类型
export interface ApiConfig {
apiUrl: string;
apiKey: string;
apiModel: string;
apiTimeout: string;
saveApiConfig: boolean;
}
export interface StockInfo {
code: string;
name: string;
marketType: string;
price?: number;
changePercent?: number;
marketValue?: number;
analysis?: string;
analysisStatus: 'waiting' | 'analyzing' | 'completed' | 'error';
error?: string;
}
export interface SearchResult {
symbol: string;
name: string;
market: string;
marketValue?: number;
}
export interface MarketStatus {
isOpen: boolean;
nextTime: string;
}
export interface MarketTimeInfo {
currentTime: string;
cnMarket: MarketStatus;
hkMarket: MarketStatus;
usMarket: MarketStatus;
}
// 分析请求和响应
export interface AnalyzeRequest {
stock_codes: string[];
market_type: string;
api_url?: string;
api_key?: string;
api_model?: string;
api_timeout?: string;
}
export interface TestApiRequest {
api_url: string;
api_key: string;
api_model: string;
api_timeout: string;
}
export interface TestApiResponse {
success: boolean;
message: string;
status_code?: number;
}
// 流式响应类型
export interface StreamInitMessage {
stream_type: 'single' | 'batch';
stock_code?: string;
stock_codes?: string[];
}
export interface StreamAnalysisUpdate {
stock_code: string;
analysis?: string;
status: 'analyzing' | 'completed' | 'error';
error?: string;
name?: string;
price?: number;
change_percent?: number;
market_value?: number;
}

201
frontend/src/utils/index.ts Normal file
View File

@@ -0,0 +1,201 @@
import type { MarketTimeInfo } from '@/types';
import { marked } from 'marked';
// 防抖函数
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: number | null = null;
return function(...args: Parameters<T>): void {
const later = () => {
timeout = null;
func(...args);
};
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = window.setTimeout(later, wait);
};
}
// 格式化市值
export function formatMarketValue(value: number): string {
if (!value) return '未知';
if (value >= 1000000000000) {
return (value / 1000000000000).toFixed(2) + '万亿';
} else if (value >= 100000000) {
return (value / 100000000).toFixed(2) + '亿';
} else if (value >= 10000) {
return (value / 10000).toFixed(2) + '万';
} else {
return value.toFixed(2);
}
}
// 解析Markdown
export function parseMarkdown(text: string): string {
try {
const result = marked(text);
if (typeof result === 'string') {
return result;
}
return '';
} catch (e) {
console.error('解析Markdown出错:', e);
return text;
}
}
// 更新市场时间信息
export function updateMarketTimeInfo(): MarketTimeInfo {
const now = new Date();
// 当前时间
const currentTime = now.toLocaleTimeString('zh-CN', { hour12: false });
// 中国时间
const cnOptions = { timeZone: 'Asia/Shanghai', hour12: false } as Intl.DateTimeFormatOptions;
const cnTime = now.toLocaleString('zh-CN', cnOptions);
const cnHour = new Date(cnTime).getHours();
const cnMinute = new Date(cnTime).getMinutes();
// A股市场状态
const cnMarketOpen = (cnHour === 9 && cnMinute >= 30) || (cnHour === 10) ||
(cnHour === 11 && cnMinute <= 30) ||
(cnHour >= 13 && cnHour < 15);
const cnNextTime = getNextTimeText(cnMarketOpen, cnHour, cnMinute, 9, 30, 15, 0);
// 港股市场状态与A股相同时区
const hkMarketOpen = (cnHour === 9 && cnMinute >= 30) ||
(cnHour === 10) || (cnHour === 11) ||
(cnHour >= 13 && cnHour < 16);
const hkNextTime = getNextTimeText(hkMarketOpen, cnHour, cnMinute, 9, 30, 16, 0);
// 获取美国东部时间
const usOptions = { timeZone: 'America/New_York', hour12: false } as Intl.DateTimeFormatOptions;
const usTime = now.toLocaleString('zh-CN', usOptions);
const usHour = new Date(usTime).getHours();
const usMinute = new Date(usTime).getMinutes();
// 美股市场状态
const usMarketOpen = (usHour >= 9 && usHour < 16) ||
(usHour === 16 && usMinute === 0);
const usNextTime = getNextTimeText(usMarketOpen, usHour, usMinute, 9, 30, 16, 0);
return {
currentTime,
cnMarket: { isOpen: cnMarketOpen, nextTime: cnNextTime },
hkMarket: { isOpen: hkMarketOpen, nextTime: hkNextTime },
usMarket: { isOpen: usMarketOpen, nextTime: usNextTime }
};
}
// 辅助函数:获取距离下一次开/闭市的时间文本
function getNextTimeText(
isOpen: boolean,
currentHour: number,
currentMinute: number,
openHour: number,
openMinute: number,
closeHour: number,
closeMinute: number
): string {
if (isOpen) {
// 计算距离收盘时间
let timeToCloseMinutes = (closeHour - currentHour) * 60 + (closeMinute - currentMinute);
if (timeToCloseMinutes <= 0) {
return '即将收盘';
}
const hours = Math.floor(timeToCloseMinutes / 60);
const minutes = timeToCloseMinutes % 60;
return `距离收盘还有 ${hours}小时${minutes}分钟`;
} else {
// 计算距离开盘时间
let nextOpenHour = openHour;
let nextOpenMinute = openMinute;
let isNextDay = false;
if (currentHour >= closeHour) {
// 已经过了今天的收盘时间,下一个开盘是明天
isNextDay = true;
} else if (currentHour < openHour || (currentHour === openHour && currentMinute < openMinute)) {
// 还没到今天的开盘时间
isNextDay = false;
} else {
// 当前处于盘中休息时间,下一个开盘时间是当天下午
nextOpenHour = 13;
nextOpenMinute = 0;
}
let timeToOpenMinutes;
if (isNextDay) {
timeToOpenMinutes = (24 - currentHour + nextOpenHour) * 60 + (nextOpenMinute - currentMinute);
} else {
timeToOpenMinutes = (nextOpenHour - currentHour) * 60 + (nextOpenMinute - currentMinute);
}
if (timeToOpenMinutes <= 0) {
return '即将开盘';
}
const hours = Math.floor(timeToOpenMinutes / 60);
const minutes = timeToOpenMinutes % 60;
return `距离开盘还有 ${hours}小时${minutes}分钟`;
}
}
// 保存API配置到localStorage
export function saveApiConfigToLocalStorage(config: Partial<Pick<
{ apiUrl: string, apiKey: string, apiModel: string, apiTimeout: string, saveApiConfig: boolean },
'apiUrl' | 'apiKey' | 'apiModel' | 'apiTimeout' | 'saveApiConfig'
>>): void {
if (window.localStorage) {
localStorage.setItem('apiConfig', JSON.stringify(config));
}
}
// 从localStorage加载API配置
export function loadApiConfig(): Partial<{
apiUrl: string,
apiKey: string,
apiModel: string,
apiTimeout: string,
saveApiConfig: boolean
}> {
if (window.localStorage) {
const saved = localStorage.getItem('apiConfig');
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error('解析保存的API配置出错:', e);
}
}
}
return {
apiUrl: '',
apiKey: '',
apiModel: '',
apiTimeout: '',
saveApiConfig: false
};
}
// 清除API配置
export function clearApiConfig(): void {
if (window.localStorage) {
localStorage.removeItem('apiConfig');
}
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />