feat: 优化前端显示&修复若干bug

This commit is contained in:
CaasianVale
2025-03-07 03:33:18 +08:00
parent ff5b820a57
commit 4c115cf325
29 changed files with 3726 additions and 1209 deletions

View File

@@ -19,9 +19,11 @@ docker run -d \
-e API_URL=替换为你的api地址 \ -e API_URL=替换为你的api地址 \
-e API_MODEL=替换为你的模型 \ -e API_MODEL=替换为你的模型 \
-e API_TIMEOUT=60 \ -e API_TIMEOUT=60 \
-e LOGIN_PASSWORD=替换为你的密码 \
lanzhihong/stock-scanner:latest lanzhihong/stock-scanner:latest
API_TIMEOUT=60 202503040712版本开始 (AI分析发生错误查看日志是否有timed out类似错误需要增加你的API超时时间) API_TIMEOUT=60 202503040712版本开始 (AI分析发生错误查看日志是否有timed out类似错误需要增加你的API超时时间)
LOGIN_PASSWORD 为空时,表示不需要登录,否则需要经过登录接口验证
注意⚠️: 环境变量名变更,更新版本后需要调整!!! 注意⚠️: 环境变量名变更,更新版本后需要调整!!!

View File

@@ -3,9 +3,9 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>股票分析系统</title> <title>股票AI分析系统</title>
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="description" content="股票分析系统 - 基于Vue 3 + TypeScript + Naive UI"> <meta name="description" content="股票AI分析系统 - 基于Vue 3 + TypeScript + Naive UI">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -14,7 +14,8 @@
"axios": "^1.8.1", "axios": "^1.8.1",
"marked": "^15.0.7", "marked": "^15.0.7",
"naive-ui": "^2.41.0", "naive-ui": "^2.41.0",
"vue": "^3.5.13" "vue": "^3.5.13",
"vue-router": "^4.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",
@@ -298,6 +299,12 @@
"he": "^1.2.0" "he": "^1.2.0"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/@vue/language-core": { "node_modules/@vue/language-core": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.8.tgz", "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.8.tgz",
@@ -1217,6 +1224,21 @@
} }
} }
}, },
"node_modules/vue-router": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.5.0.tgz",
"integrity": "sha512-HDuk+PuH5monfNuY+ct49mNmkCRK4xJAV9Ts4z9UFc4rzdDnxQLyCMGGc8pKhZhHTVzfanpNwB/lwqevcBwI4w==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/vue-tsc": { "node_modules/vue-tsc": {
"version": "2.2.8", "version": "2.2.8",
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.8.tgz", "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.8.tgz",

View File

@@ -15,7 +15,9 @@
"axios": "^1.8.1", "axios": "^1.8.1",
"marked": "^15.0.7", "marked": "^15.0.7",
"naive-ui": "^2.41.0", "naive-ui": "^2.41.0",
"vue": "^3.5.13" "npm": "^11.2.0",
"vue": "^3.5.13",
"vue-router": "4"
}, },
"devDependencies": { "devDependencies": {
"@types/marked": "^5.0.2", "@types/marked": "^5.0.2",

View File

@@ -4,7 +4,7 @@
<n-loading-bar-provider> <n-loading-bar-provider>
<n-dialog-provider> <n-dialog-provider>
<n-notification-provider> <n-notification-provider>
<StockAnalysisApp /> <router-view />
</n-notification-provider> </n-notification-provider>
</n-dialog-provider> </n-dialog-provider>
</n-loading-bar-provider> </n-loading-bar-provider>
@@ -21,7 +21,6 @@ import {
NDialogProvider, NDialogProvider,
NNotificationProvider, NNotificationProvider,
} from 'naive-ui' } from 'naive-ui'
import StockAnalysisApp from './components/StockAnalysisApp.vue'
// 主题设置 (默认使用亮色主题) // 主题设置 (默认使用亮色主题)
const theme = ref<any>(null) // 可以切换为 darkTheme 以启用暗色模式 const theme = ref<any>(null) // 可以切换为 darkTheme 以启用暗色模式

View File

@@ -5,16 +5,17 @@
size="small" size="small"
@click="toggleConfig" @click="toggleConfig"
:quaternary="true" :quaternary="true"
:type="expanded ? 'primary' : 'default'"
> >
<template #icon> <template #icon>
<n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" /> <n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" />
</template> </template>
API配置 {{ expanded ? '收起' : '展开' }} <span class="toggle-text">API配置 {{ expanded ? '收起' : '展开' }}</span>
</n-button> </n-button>
<n-collapse-transition :show="expanded"> <n-collapse-transition :show="expanded">
<n-card class="api-config-card" content-style="padding: 0.75rem;"> <n-card class="api-config-card" :bordered="false">
<n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible"> <n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible" class="api-info-alert">
<template #icon> <template #icon>
<n-icon :component="InformationCircleIcon" /> <n-icon :component="InformationCircleIcon" />
</template> </template>
@@ -28,21 +29,32 @@
</div> </div>
</n-alert> </n-alert>
<n-grid :cols="24" :x-gap="12"> <n-grid :cols="24" :x-gap="16" :y-gap="16">
<n-grid-item :span="14"> <n-grid-item :span="24" :lg-span="14">
<n-form-item label="API URL" path="apiUrl"> <n-form-item label="API URL" path="apiUrl">
<n-input <n-input
v-model:value="apiConfig.apiUrl" v-model:value="apiConfig.apiUrl"
placeholder="https://api.openai.com/v1/chat/completions" placeholder="https://api.openai.com/v1/chat/completions"
@update:value="handleConfigChange" @update:value="handleConfigChange"
/> round
>
<template #prefix>
<n-icon :component="GlobeIcon" />
</template>
</n-input>
<template #feedback> <template #feedback>
<span class="formatted-url">{{ formattedUrl }}</span> <div class="url-feedback">
<span class="formatted-url">实际请求地址: {{ formattedUrl }}</span>
<div class="url-tips">
<div>提示: URL以/结尾将忽略v1路径</div>
<div>URL以#结尾将使用原始地址</div>
</div>
</div>
</template> </template>
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item :span="10"> <n-grid-item :span="24" :lg-span="10">
<n-form-item label="API Key" path="apiKey"> <n-form-item label="API Key" path="apiKey">
<n-input <n-input
v-model:value="apiConfig.apiKey" v-model:value="apiConfig.apiKey"
@@ -50,21 +62,63 @@
placeholder="sk-..." placeholder="sk-..."
show-password-on="click" show-password-on="click"
@update:value="handleConfigChange" @update:value="handleConfigChange"
/> round
>
<template #prefix>
<n-icon :component="KeyIcon" />
</template>
</n-input>
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item :span="12"> <n-grid-item :span="12" :lg-span="12">
<n-form-item label="模型" path="apiModel"> <n-form-item label="模型" path="apiModel">
<n-input <n-input
v-model:value="apiConfig.apiModel" v-model:value="apiConfig.apiModel"
placeholder="gpt-3.5-turbo" placeholder="输入或选择模型名称"
@update:value="handleConfigChange" @update:value="handleConfigChange"
/> round
>
<template #prefix>
<n-icon :component="CodeIcon" />
</template>
<template #suffix>
<n-dropdown
trigger="click"
:options="modelOptions"
@select="selectModel"
placement="bottom-end"
>
<n-button quaternary circle size="small" class="model-dropdown-btn">
<template #icon>
<n-icon :component="ChevronDownIcon" />
</template>
</n-button>
</n-dropdown>
</template>
</n-input>
<template #feedback>
<div class="model-suggestions">
<div class="model-tip">您可以直接输入模型名称或点击右侧按钮从下拉菜单选择</div>
<span>常用模型:</span>
<div class="model-chips">
<n-tag
v-for="model in commonModels"
:key="model.key"
size="small"
round
clickable
@click="selectModel(model.key)"
>
{{ model.label }}
</n-tag>
</div>
</div>
</template>
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
<n-grid-item :span="12"> <n-grid-item :span="12" :lg-span="12">
<n-form-item label="超时时间(秒)" path="apiTimeout"> <n-form-item label="超时时间(秒)" path="apiTimeout">
<n-input-number <n-input-number
v-model:value="apiTimeout" v-model:value="apiTimeout"
@@ -72,7 +126,20 @@
:min="1" :min="1"
:max="300" :max="300"
@update:value="handleTimeoutChange" @update:value="handleTimeoutChange"
/> :show-button="false"
class="timeout-input"
>
<template #suffix>
<div class="timeout-controls">
<n-button size="tiny" quaternary @click="decreaseTimeout">
<template #icon><n-icon :component="RemoveIcon" /></template>
</n-button>
<n-button size="tiny" quaternary @click="increaseTimeout">
<template #icon><n-icon :component="AddIcon" /></template>
</n-button>
</div>
</template>
</n-input-number>
</n-form-item> </n-form-item>
</n-grid-item> </n-grid-item>
</n-grid> </n-grid>
@@ -92,22 +159,36 @@
:loading="testingConnection" :loading="testingConnection"
:disabled="!isConfigValid" :disabled="!isConfigValid"
@click="testConnection" @click="testConnection"
round
> >
<template #icon>
<n-icon :component="CheckmarkIcon" />
</template>
测试连接 测试连接
</n-button> </n-button>
<n-button @click="resetConfig"> <n-button @click="resetConfig" round>
<template #icon>
<n-icon :component="RefreshIcon" />
</template>
重置 重置
</n-button> </n-button>
</div> </div>
</div> </div>
<n-divider v-if="connectionStatus" style="margin: 16px 0 12px" />
<div v-if="connectionStatus" class="connection-status" :class="connectionStatus.type">
<n-icon :component="connectionStatus.icon" class="status-icon" />
<span class="status-message">{{ connectionStatus.message }}</span>
</div>
</n-card> </n-card>
</n-collapse-transition> </n-collapse-transition>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted, reactive } from 'vue';
import { import {
NButton, NButton,
NIcon, NIcon,
@@ -120,13 +201,25 @@ import {
NInputNumber, NInputNumber,
NSwitch, NSwitch,
NAlert, NAlert,
NDivider,
NDropdown,
NTag,
useMessage useMessage
} from 'naive-ui'; } from 'naive-ui';
import { import {
ChevronDown as ChevronDownIcon, ChevronDown as ChevronDownIcon,
ChevronUp as ChevronUpIcon, ChevronUp as ChevronUpIcon,
InformationCircleOutline as InformationCircleIcon, InformationCircleOutline as InformationCircleIcon,
Close as CloseIcon Close as CloseIcon,
Globe as GlobeIcon,
Key as KeyIcon,
CheckmarkCircleOutline as CheckmarkIcon,
RefreshOutline as RefreshIcon,
AddOutline as AddIcon,
RemoveOutline as RemoveIcon,
CheckmarkCircle as SuccessIcon,
CloseCircle as ErrorIcon,
CodeSlashOutline as CodeIcon
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { apiService } from '@/services/api'; import { apiService } from '@/services/api';
import { saveApiConfigToLocalStorage, loadApiConfig } from '@/utils'; import { saveApiConfigToLocalStorage, loadApiConfig } from '@/utils';
@@ -147,11 +240,41 @@ const expanded = ref(false);
const testingConnection = ref(false); const testingConnection = ref(false);
const isApiInfoVisible = ref(true); const isApiInfoVisible = ref(true);
// 连接状态
const connectionStatus = ref<{
type: 'success' | 'error';
message: string;
icon: any;
} | null>(null);
// 模型选项
const modelOptions = [
{ label: 'GPT-3.5', key: 'gpt-3.5-turbo' },
{ label: 'GPT-4o', key: 'gpt-4o' },
{ label: 'DeepSeek V3', key: 'deepseek-chat' },
{ label: 'DeepSeek R1', key: 'deepseek-reasoner' },
{ label: 'Claude 3.5 Sonnet', key: 'claude-3-5-sonnet' },
{ label: 'Claude 3.5 Sonnet 20241022', key: 'claude-3-5-sonnet-20241022' },
{ label: 'Gemini 1.5 Pro', key: 'gemini-1.5-pro' },
{ label: 'Gemini 1.5 Flash', key: 'gemini-1.5-flash' },
{ label: 'Gemini 2.0 Pro', key: 'gemini-2.0-pro' },
{ label: 'Gemini 2.0 Flash', key: 'gemini-2.0-flash' }
];
// 常用模型(用于快速选择)
const commonModels = [
{ label: 'GPT-3.5', key: 'gpt-3.5-turbo' },
{ label: 'GPT-4o', key: 'gpt-4o' },
{ label: 'Claude 3.5', key: 'claude-3-5-sonnet' },
{ label: 'Gemini 2.0', key: 'gemini-2.0-flash' },
{ label: 'DeepSeek V3', key: 'deepseek-chat' },
];
// API配置 // API配置
const apiConfig = ref<ApiConfig>({ const apiConfig = ref<ApiConfig>({
apiUrl: props.defaultApiUrl || '', apiUrl: props.defaultApiUrl || '',
apiKey: '', apiKey: '',
apiModel: props.defaultApiModel || 'gpt-3.5-turbo', apiModel: props.defaultApiModel || '',
apiTimeout: props.defaultApiTimeout || '60', apiTimeout: props.defaultApiTimeout || '60',
saveApiConfig: false saveApiConfig: false
}); });
@@ -176,6 +299,8 @@ function toggleConfig() {
} }
function handleConfigChange() { function handleConfigChange() {
console.log('API配置变更:', apiConfig.value);
// 如果选择了保存配置,则自动保存 // 如果选择了保存配置,则自动保存
if (apiConfig.value.saveApiConfig) { if (apiConfig.value.saveApiConfig) {
saveApiConfigToLocalStorage({ saveApiConfigToLocalStorage({
@@ -198,13 +323,32 @@ function handleTimeoutChange(value: number | null) {
} }
} }
function increaseTimeout() {
if (apiTimeout.value < 300) {
apiTimeout.value += 10;
handleTimeoutChange(apiTimeout.value);
}
}
function decreaseTimeout() {
if (apiTimeout.value > 10) {
apiTimeout.value -= 10;
handleTimeoutChange(apiTimeout.value);
}
}
function formatApiUrl(url: string): string { function formatApiUrl(url: string): string {
if (!url) return ''; if (!url) return '';
try { try {
// 尝试解析URL // 使用与后端一致的URL格式化逻辑
const parsedUrl = new URL(url); if (url.endsWith('/')) {
return `${parsedUrl.origin}${parsedUrl.pathname}`; return `${url}chat/completions`;
} else if (url.endsWith('#')) {
return url.replace('#', '');
} else {
return `${url}/v1/chat/completions`;
}
} catch (e) { } catch (e) {
// 如果URL格式错误则返回原始字符串 // 如果URL格式错误则返回原始字符串
return url; return url;
@@ -218,6 +362,7 @@ async function testConnection() {
} }
testingConnection.value = true; testingConnection.value = true;
connectionStatus.value = null;
try { try {
const response = await apiService.testApiConnection({ const response = await apiService.testApiConnection({
@@ -229,6 +374,11 @@ async function testConnection() {
if (response.success) { if (response.success) {
message.success('API连接测试成功'); message.success('API连接测试成功');
connectionStatus.value = {
type: 'success',
message: '连接成功API配置有效。',
icon: SuccessIcon
};
// 如果选择了保存配置,则保存 // 如果选择了保存配置,则保存
if (apiConfig.value.saveApiConfig) { if (apiConfig.value.saveApiConfig) {
@@ -242,9 +392,19 @@ async function testConnection() {
} }
} else { } else {
message.error(`API连接测试失败: ${response.message}`); message.error(`API连接测试失败: ${response.message}`);
connectionStatus.value = {
type: 'error',
message: `连接失败: ${response.message}`,
icon: ErrorIcon
};
} }
} catch (error: any) { } catch (error: any) {
message.error(`测试连接出错: ${error.message || '未知错误'}`); message.error(`测试连接出错: ${error.message || '未知错误'}`);
connectionStatus.value = {
type: 'error',
message: `连接错误: ${error.message || '未知错误'}`,
icon: ErrorIcon
};
} finally { } finally {
testingConnection.value = false; testingConnection.value = false;
} }
@@ -254,7 +414,7 @@ function resetConfig() {
apiConfig.value = { apiConfig.value = {
apiUrl: props.defaultApiUrl || '', apiUrl: props.defaultApiUrl || '',
apiKey: '', apiKey: '',
apiModel: props.defaultApiModel || 'gpt-3.5-turbo', apiModel: props.defaultApiModel || '',
apiTimeout: props.defaultApiTimeout || '60', apiTimeout: props.defaultApiTimeout || '60',
saveApiConfig: false saveApiConfig: false
}; };
@@ -264,10 +424,18 @@ function resetConfig() {
localStorage.removeItem('apiConfig'); localStorage.removeItem('apiConfig');
} }
connectionStatus.value = null;
message.success('已重置API配置'); message.success('已重置API配置');
emit('update:apiConfig', { ...apiConfig.value }); emit('update:apiConfig', { ...apiConfig.value });
} }
// 选择模型
function selectModel(key: string) {
console.log('选择模型:', key);
apiConfig.value.apiModel = key;
handleConfigChange();
}
onMounted(() => { onMounted(() => {
// 加载保存的配置 // 加载保存的配置
const savedConfig = loadApiConfig(); const savedConfig = loadApiConfig();
@@ -276,7 +444,7 @@ onMounted(() => {
apiConfig.value = { apiConfig.value = {
apiUrl: savedConfig.apiUrl || props.defaultApiUrl || '', apiUrl: savedConfig.apiUrl || props.defaultApiUrl || '',
apiKey: savedConfig.apiKey || '', apiKey: savedConfig.apiKey || '',
apiModel: savedConfig.apiModel || props.defaultApiModel || 'gpt-3.5-turbo', apiModel: savedConfig.apiModel || props.defaultApiModel || '',
apiTimeout: savedConfig.apiTimeout || props.defaultApiTimeout || '60', apiTimeout: savedConfig.apiTimeout || props.defaultApiTimeout || '60',
saveApiConfig: true saveApiConfig: true
}; };
@@ -289,25 +457,58 @@ onMounted(() => {
<style scoped> <style scoped>
.api-config-section { .api-config-section {
margin-bottom: 1rem; margin-bottom: 1.5rem;
position: relative;
} }
.toggle-button { .toggle-button {
margin-bottom: 0.5rem; margin-bottom: 0.75rem;
font-weight: 500;
transition: all 0.3s ease;
border-radius: 16px;
padding: 4px 12px;
}
.toggle-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.toggle-text {
margin-left: 4px;
} }
.api-config-card { .api-config-card {
margin-bottom: 1rem; margin-bottom: 1rem;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.5), rgba(250, 250, 252, 0.8));
padding: 16px;
transition: all 0.3s ease;
}
.api-info-alert {
margin-bottom: 16px;
border-radius: 8px;
}
.url-feedback {
padding: 6px 0;
} }
.formatted-url { .formatted-url {
color: var(--n-text-color-info); color: var(--n-text-color-info);
font-size: 0.85rem; font-size: 0.85rem;
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
} }
.button-group { .url-tips {
display: flex; color: var(--n-text-color-info);
gap: 0.75rem; font-size: 0.75rem;
opacity: 0.8;
line-height: 1.4;
} }
.alert-actions { .alert-actions {
@@ -319,21 +520,123 @@ onMounted(() => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
margin-top: 1rem; margin-top: 1.5rem;
flex-wrap: wrap;
gap: 12px;
} }
.api-save-option { .api-save-option {
display: flex; display: flex;
align-items: center; align-items: center;
background-color: rgba(0, 0, 0, 0.02);
padding: 6px 12px;
border-radius: 16px;
} }
.save-label { .save-label {
margin-left: 0.5rem; margin-left: 0.5rem;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500;
} }
.api-buttons { .api-buttons {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
} }
.timeout-input {
width: 100%;
}
.timeout-controls {
display: flex;
align-items: center;
margin-left: 8px;
}
.connection-status {
display: flex;
align-items: center;
padding: 10px 16px;
border-radius: 8px;
margin-top: 8px;
font-weight: 500;
animation: fadeIn 0.3s ease;
}
.connection-status.success {
background-color: rgba(24, 160, 88, 0.1);
color: var(--n-success-color);
}
.connection-status.error {
background-color: rgba(208, 48, 80, 0.1);
color: var(--n-error-color);
}
.status-icon {
margin-right: 8px;
font-size: 1.25rem;
}
.status-message {
font-size: 0.9rem;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.api-actions {
flex-direction: column;
align-items: flex-start;
}
.api-buttons {
width: 100%;
justify-content: space-between;
}
}
.model-suggestions {
margin-top: 6px;
font-size: 0.75rem;
color: var(--n-text-color-3);
}
.model-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
}
.model-chips :deep(.n-tag) {
cursor: pointer;
transition: all 0.2s ease;
}
.model-chips :deep(.n-tag:hover) {
background-color: rgba(32, 128, 240, 0.1);
transform: translateY(-1px);
}
.model-tip {
margin-bottom: 6px;
font-size: 0.75rem;
color: var(--n-text-color-3);
font-style: italic;
}
.model-dropdown-btn {
background-color: rgba(32, 128, 240, 0.1);
transition: all 0.2s ease;
}
.model-dropdown-btn:hover {
background-color: rgba(32, 128, 240, 0.2);
transform: translateY(-1px);
}
</style> </style>

View File

@@ -0,0 +1,536 @@
<template>
<div class="login-container">
<div class="login-background">
<div class="login-shape shape1"></div>
<div class="login-shape shape2"></div>
<div class="login-shape shape3"></div>
<div class="login-shape shape4"></div>
<div class="login-shape shape5"></div>
<div class="login-particle particle1"></div>
<div class="login-particle particle2"></div>
<div class="login-particle particle3"></div>
<div class="login-particle particle4"></div>
<div class="login-particle particle5"></div>
<div class="login-particle particle6"></div>
</div>
<n-card class="login-card" :bordered="false">
<div class="login-header">
<div class="login-logo">
<n-icon :component="BarChartIcon" color="#2080f0" size="36" class="logo-icon" />
</div>
<h1 class="login-title">股票AI分析系统</h1>
<p class="login-subtitle">使用AI技术分析股票市场趋势</p>
</div>
<n-form
ref="formRef"
:model="formValue"
:rules="rules"
label-placement="left"
label-width="0"
require-mark-placement="right-hanging"
class="login-form"
>
<n-form-item path="password">
<n-input
v-model:value="formValue.password"
type="password"
placeholder="请输入密码"
@keyup.enter="handleLogin"
size="large"
class="login-input"
>
<template #prefix>
<n-icon :component="LockClosedIcon" />
</template>
</n-input>
</n-form-item>
<div class="login-button-container">
<n-button
type="primary"
size="large"
block
:loading="loading"
@click="handleLogin"
class="login-button"
>
{{ loading ? '登录中...' : '登 录' }}
</n-button>
</div>
</n-form>
<div class="login-footer">
<n-text depth="3">© {{ new Date().getFullYear() }} 股票AI分析系统</n-text>
</div>
</n-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, h } from 'vue';
import { useRouter } from 'vue-router';
import {
NCard,
NForm,
NFormItem,
NInput,
NButton,
NIcon,
NText,
useMessage,
useNotification
} from 'naive-ui';
import type { FormInst, FormRules } from 'naive-ui';
import {
BarChartOutline as BarChartIcon,
LockClosedOutline as LockClosedIcon,
NotificationsOutline as NotificationsIcon
} from '@vicons/ionicons5';
import { apiService } from '@/services/api';
import type { LoginRequest } from '@/types';
const message = useMessage();
const notification = useNotification();
const router = useRouter();
const formRef = ref<FormInst | null>(null);
const loading = ref(false);
const formValue = reactive({
password: ''
});
const rules: FormRules = {
password: [
{
required: true,
message: '请输入密码'
}
]
};
// 显示系统公告
const showAnnouncement = (content: string) => {
if (!content) return;
notification.info({
title: '系统公告',
content: () => h('div', { style: 'display: flex; align-items: center;' }, [
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }),
h('span', null, content)
]),
duration: 10000,
keepAliveOnHover: true,
closable: true
});
};
// 页面加载时检查是否已登录并获取系统公告
onMounted(async () => {
try {
// 获取系统配置
const config = await apiService.getConfig();
if (config.announcement) {
showAnnouncement(config.announcement);
}
// 不重复检查是否需要登录,因为路由守卫已经做了这个检查
// 直接检查是否已登录
const token = localStorage.getItem('token');
if (!token) {
return; // 没有token停留在登录页
}
const isAuthenticated = await apiService.checkAuth();
console.log('登录页面认证检查结果:', isAuthenticated);
if (isAuthenticated) {
// 已登录,跳转到主页
console.log('已登录,跳转到主页');
router.push('/');
}
} catch (error) {
console.error('认证检查或获取配置失败:', error);
}
});
const handleLogin = () => {
formRef.value?.validate(async (errors) => {
if (errors) {
return;
}
loading.value = true;
try {
const loginRequest: LoginRequest = {
password: formValue.password
};
const response = await apiService.login(loginRequest);
if (response.access_token) {
message.success('登录成功');
// 登录成功后跳转到主页
router.push('/');
} else {
message.error(response.message || '登录失败');
}
} catch (error: any) {
console.error('登录失败:', error);
message.error(error.message || '登录失败');
} finally {
loading.value = false;
}
});
};
</script>
<style scoped>
@keyframes float {
0% {
transform: translateY(0px) rotate(0deg);
}
50% {
transform: translateY(-20px) rotate(5deg);
}
100% {
transform: translateY(0px) rotate(0deg);
}
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.8;
}
50% {
transform: scale(1.05);
opacity: 0.6;
}
100% {
transform: scale(1);
opacity: 0.8;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes floatParticle {
0% {
transform: translateY(0) translateX(0);
}
25% {
transform: translateY(-15px) translateX(15px);
}
50% {
transform: translateY(0) translateX(30px);
}
75% {
transform: translateY(15px) translateX(15px);
}
100% {
transform: translateY(0) translateX(0);
}
}
html, body {
margin: 0;
padding: 0;
height: 100%;
width: 100%;
overflow: hidden;
}
.login-container {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
width: 100vw;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
position: fixed;
top: 0;
left: 0;
overflow: hidden;
}
.login-background {
position: absolute;
width: 100%;
height: 100%;
z-index: 0;
top: 0;
left: 0;
overflow: hidden;
}
.login-shape {
position: absolute;
border-radius: 50%;
animation: pulse 8s infinite ease-in-out;
}
.shape1 {
width: 50vw;
height: 50vw;
max-width: 600px;
max-height: 600px;
background: linear-gradient(135deg, rgba(32, 128, 240, 0.2) 0%, rgba(32, 128, 240, 0.1) 100%);
top: -15%;
right: -10%;
animation-delay: 0s;
}
.shape2 {
width: 60vw;
height: 60vw;
max-width: 800px;
max-height: 800px;
background: linear-gradient(135deg, rgba(32, 128, 240, 0.1) 0%, rgba(32, 128, 240, 0.05) 100%);
bottom: -30%;
left: -15%;
animation-delay: 2s;
}
.shape3 {
width: 30vw;
height: 30vw;
max-width: 400px;
max-height: 400px;
background: linear-gradient(135deg, rgba(32, 128, 240, 0.15) 0%, rgba(32, 128, 240, 0.05) 100%);
top: 20%;
right: 15%;
animation-delay: 4s;
}
.shape4 {
width: 25vw;
height: 25vw;
max-width: 300px;
max-height: 300px;
background: linear-gradient(135deg, rgba(32, 128, 240, 0.1) 0%, rgba(32, 128, 240, 0.05) 100%);
top: 60%;
left: 10%;
animation-delay: 1s;
}
.shape5 {
width: 15vw;
height: 15vw;
max-width: 200px;
max-height: 200px;
background: linear-gradient(135deg, rgba(32, 128, 240, 0.15) 0%, rgba(32, 128, 240, 0.1) 100%);
top: 30%;
left: 20%;
animation-delay: 3s;
}
.login-particle {
position: absolute;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.6);
animation: floatParticle 15s infinite ease-in-out;
}
.particle1 {
width: 10px;
height: 10px;
top: 20%;
left: 30%;
animation-duration: 20s;
}
.particle2 {
width: 15px;
height: 15px;
top: 40%;
left: 70%;
animation-duration: 25s;
}
.particle3 {
width: 8px;
height: 8px;
top: 70%;
left: 40%;
animation-duration: 18s;
}
.particle4 {
width: 12px;
height: 12px;
top: 30%;
left: 60%;
animation-duration: 22s;
}
.particle5 {
width: 6px;
height: 6px;
top: 60%;
left: 20%;
animation-duration: 15s;
}
.particle6 {
width: 10px;
height: 10px;
top: 80%;
left: 80%;
animation-duration: 30s;
}
.login-card {
width: 420px;
max-width: 90%;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
border-radius: 16px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1);
z-index: 1;
padding: 30px;
animation: fadeIn 0.8s ease-out;
transition: all 0.3s ease;
position: relative;
}
.login-card:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
transform: translateY(-5px);
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-logo {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.logo-icon {
animation: float 6s infinite ease-in-out;
}
.login-title {
font-size: 28px;
font-weight: 600;
color: #333;
margin: 0 0 8px;
background: linear-gradient(90deg, #2080f0, #44a4ff);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.login-subtitle {
font-size: 14px;
color: #666;
margin: 0;
}
.login-form {
animation: fadeIn 0.8s ease-out 0.2s both;
}
.login-input {
transition: all 0.3s ease;
}
.login-input:hover {
transform: translateY(-2px);
}
.login-button-container {
margin-top: 30px;
margin-bottom: 20px;
animation: fadeIn 0.8s ease-out 0.4s both;
}
.login-button {
height: 48px;
font-size: 16px;
font-weight: 500;
letter-spacing: 2px;
transition: all 0.3s ease;
background: linear-gradient(90deg, #2080f0, #44a4ff);
border: none;
}
.login-button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(32, 128, 240, 0.3);
background: linear-gradient(90deg, #1c72d9, #3b9aff);
}
.login-footer {
text-align: center;
padding: 16px 0 0;
border-top: 1px solid rgba(0, 0, 0, 0.05);
margin-top: 20px;
animation: fadeIn 0.8s ease-out 0.6s both;
}
/* 响应式设计 */
@media (max-width: 480px) {
.login-card {
width: 90%;
padding: 20px;
}
.login-title {
font-size: 24px;
}
.login-subtitle {
font-size: 12px;
}
.login-button {
height: 44px;
font-size: 14px;
}
/* 移动设备上的背景形状调整 */
.shape1 {
width: 70vw;
height: 70vw;
top: -30%;
right: -20%;
}
.shape2 {
width: 80vw;
height: 80vw;
bottom: -40%;
left: -30%;
}
.shape3 {
width: 50vw;
height: 50vw;
top: 50%;
right: -20%;
}
.shape4, .shape5 {
display: none;
}
.login-particle {
display: none;
}
}
</style>

View File

@@ -13,9 +13,16 @@
<n-grid-item> <n-grid-item>
<div class="time-block"> <div class="time-block">
<p class="time-label">A股市场</p> <p class="time-label">A股市场</p>
<p class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'"> <div class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'">
{{ marketInfo.cnMarket.isOpen ? '交易中' : '已休市' }} <n-tag v-if="marketInfo.cnMarket.isOpen" type="success" size="medium" round>
</p> <template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
交易中
</n-tag>
<n-tag v-else type="default" size="medium" round>
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
已休市
</n-tag>
</div>
<p class="time-counter">{{ marketInfo.cnMarket.nextTime }}</p> <p class="time-counter">{{ marketInfo.cnMarket.nextTime }}</p>
</div> </div>
</n-grid-item> </n-grid-item>
@@ -24,9 +31,16 @@
<n-grid-item> <n-grid-item>
<div class="time-block"> <div class="time-block">
<p class="time-label">港股市场</p> <p class="time-label">港股市场</p>
<p class="market-status" :class="marketInfo.hkMarket.isOpen ? 'status-open' : 'status-closed'"> <div class="market-status" :class="marketInfo.hkMarket.isOpen ? 'status-open' : 'status-closed'">
{{ marketInfo.hkMarket.isOpen ? '交易中' : '已休市' }} <n-tag v-if="marketInfo.hkMarket.isOpen" type="success" size="medium" round>
</p> <template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
交易中
</n-tag>
<n-tag v-else type="default" size="medium" round>
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
已休市
</n-tag>
</div>
<p class="time-counter">{{ marketInfo.hkMarket.nextTime }}</p> <p class="time-counter">{{ marketInfo.hkMarket.nextTime }}</p>
</div> </div>
</n-grid-item> </n-grid-item>
@@ -35,9 +49,16 @@
<n-grid-item> <n-grid-item>
<div class="time-block"> <div class="time-block">
<p class="time-label">美股市场</p> <p class="time-label">美股市场</p>
<p class="market-status" :class="marketInfo.usMarket.isOpen ? 'status-open' : 'status-closed'"> <div class="market-status" :class="marketInfo.usMarket.isOpen ? 'status-open' : 'status-closed'">
{{ marketInfo.usMarket.isOpen ? '交易中' : '已休市' }} <n-tag v-if="marketInfo.usMarket.isOpen" type="success" size="medium" round>
</p> <template #icon><n-icon size="18"><pulse-icon /></n-icon></template>
交易中
</n-tag>
<n-tag v-else type="default" size="medium" round>
<template #icon><n-icon size="18"><time-icon /></n-icon></template>
已休市
</n-tag>
</div>
<p class="time-counter">{{ marketInfo.usMarket.nextTime }}</p> <p class="time-counter">{{ marketInfo.usMarket.nextTime }}</p>
</div> </div>
</n-grid-item> </n-grid-item>
@@ -47,7 +68,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'; import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { NCard, NGrid, NGridItem } from 'naive-ui'; import { NCard, NGrid, NGridItem, NTag, NIcon } from 'naive-ui';
import {
PulseOutline as PulseIcon,
TimeOutline as TimeIcon
} from '@vicons/ionicons5';
import { updateMarketTimeInfo } from '@/utils'; import { updateMarketTimeInfo } from '@/utils';
import type { MarketTimeInfo } from '@/types'; import type { MarketTimeInfo } from '@/types';
@@ -91,6 +116,7 @@ onBeforeUnmount(() => {
<style scoped> <style scoped>
.market-time-card { .market-time-card {
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
padding: 0.5rem;
} }
.time-block { .time-block {
@@ -98,36 +124,65 @@ onBeforeUnmount(() => {
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
padding: 0.5rem;
} }
.time-label { .time-label {
font-size: 0.875rem; font-size: 1rem;
color: var(--n-text-color-3); color: var(--n-text-color-3);
margin-bottom: 0.5rem; margin-bottom: 0.75rem;
font-weight: 500;
} }
.current-time { .current-time {
font-size: 1.5rem; font-size: 1.75rem;
font-weight: bold; font-weight: bold;
color: var(--n-text-color); color: var(--n-text-color);
} }
.market-status { .market-status {
font-size: 1.125rem; margin-bottom: 0.5rem;
font-weight: 500; display: flex;
margin-bottom: 0.25rem; align-items: center;
justify-content: center;
min-height: 32px;
} }
.status-open { .market-status :deep(.n-tag) {
color: var(--n-success-color); padding: 0 12px;
height: 32px;
font-size: 1rem;
} }
.status-closed { .market-status :deep(.n-tag__icon) {
color: var(--n-text-color-3); margin-right: 6px;
}
.status-open :deep(.n-tag) {
background-color: rgba(var(--success-color), 0.15);
border: 1px solid var(--n-success-color);
animation: pulse 2s infinite;
}
.status-closed :deep(.n-tag) {
background-color: rgba(var(--n-text-color-3), 0.1);
} }
.time-counter { .time-counter {
font-size: 0.75rem; font-size: 0.875rem;
color: var(--n-text-color-3); color: var(--n-text-color-3);
margin-top: 0.5rem;
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(var(--success-color), 0.4);
}
70% {
box-shadow: 0 0 0 6px rgba(var(--success-color), 0);
}
100% {
box-shadow: 0 0 0 0 rgba(var(--success-color), 0);
}
} }
</style> </style>

View File

@@ -1,15 +1,7 @@
<template> <template>
<div class="app-container"> <div class="app-container">
<!-- 公告栏 -->
<AnnouncementBanner v-if="announcement" :content="announcement" :auto-close-time="5" />
<n-layout class="main-layout"> <n-layout class="main-layout">
<n-layout-content class="main-content"> <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 /> <MarketTimeDisplay />
@@ -24,9 +16,6 @@
<!-- 主要内容 --> <!-- 主要内容 -->
<n-card class="analysis-container"> <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 :cols="24" :x-gap="16" :y-gap="16">
<!-- 左侧配置区域 --> <!-- 左侧配置区域 -->
@@ -152,7 +141,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'; import { ref, onMounted, onBeforeUnmount, h } from 'vue';
import { import {
NLayout, NLayout,
NLayoutContent, NLayoutContent,
@@ -167,6 +156,7 @@ import {
NButton, NButton,
NEmpty, NEmpty,
useMessage, useMessage,
useNotification,
NSpace, NSpace,
NText, NText,
NDataTable, NDataTable,
@@ -177,10 +167,10 @@ import { useClipboard } from '@vueuse/core'
import { import {
BarChartOutline as BarChartIcon, BarChartOutline as BarChartIcon,
DocumentTextOutline as DocumentTextIcon, DocumentTextOutline as DocumentTextIcon,
DownloadOutline as DownloadIcon DownloadOutline as DownloadIcon,
NotificationsOutline as NotificationsIcon
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import AnnouncementBanner from './AnnouncementBanner.vue';
import MarketTimeDisplay from './MarketTimeDisplay.vue'; import MarketTimeDisplay from './MarketTimeDisplay.vue';
import ApiConfigPanel from './ApiConfigPanel.vue'; import ApiConfigPanel from './ApiConfigPanel.vue';
import StockSearch from './StockSearch.vue'; import StockSearch from './StockSearch.vue';
@@ -189,14 +179,16 @@ import StockCard from './StockCard.vue';
import { apiService } from '@/services/api'; import { apiService } from '@/services/api';
import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types'; import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types';
import { loadApiConfig } from '@/utils'; import { loadApiConfig } from '@/utils';
import { validateMultipleStockCodes, MarketType } from '@/utils/stockValidator';
// 使用Naive UI的消息组件 // 使用Naive UI的组件API
const message = useMessage(); const message = useMessage();
const notification = useNotification();
const { copy } = useClipboard(); const { copy } = useClipboard();
// 从环境变量获取的默认配置 // 从环境变量获取的默认配置
const defaultApiUrl = ref(''); const defaultApiUrl = ref('');
const defaultApiModel = ref('gpt-3.5-turbo'); const defaultApiModel = ref('');
const defaultApiTimeout = ref('60'); const defaultApiTimeout = ref('60');
const announcement = ref(''); const announcement = ref('');
@@ -211,11 +203,27 @@ const displayMode = ref<'card' | 'table'>('card');
const apiConfig = ref<ApiConfig>({ const apiConfig = ref<ApiConfig>({
apiUrl: '', apiUrl: '',
apiKey: '', apiKey: '',
apiModel: 'gpt-3.5-turbo', apiModel: '',
apiTimeout: '60', apiTimeout: '60',
saveApiConfig: false saveApiConfig: false
}); });
// 显示系统公告
const showAnnouncement = (content: string) => {
if (!content) return;
notification.info({
title: '系统公告',
content: () => h('div', { style: 'display: flex; align-items: center;' }, [
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }),
h('span', null, content)
]),
duration: 0, // 设置为0表示不会自动关闭
keepAliveOnHover: true,
closable: true
});
};
// 市场选项 // 市场选项
const marketOptions = [ const marketOptions = [
{ label: 'A股', value: 'A' }, { label: 'A股', value: 'A' },
@@ -255,12 +263,33 @@ const stockTableColumns = ref<DataTableColumns<StockInfo>>([
return row.price !== undefined ? row.price.toFixed(2) : '--'; return row.price !== undefined ? row.price.toFixed(2) : '--';
} }
}, },
{
title: '涨跌额',
key: 'price_change',
width: 100,
render(row: StockInfo) {
if (row.price_change === undefined) return '--';
const sign = row.price_change > 0 ? '+' : '';
return `${sign}${row.price_change.toFixed(2)}`;
}
},
{ {
title: '涨跌幅', title: '涨跌幅',
key: 'changePercent', key: 'changePercent',
width: 100, width: 100,
render(row: StockInfo) { render(row: StockInfo) {
if (row.changePercent === undefined) return '--'; if (row.changePercent === undefined) {
// 如果没有changePercent但有price_change和price尝试计算
if (row.price_change !== undefined && row.price !== undefined) {
const basePrice = row.price - row.price_change;
if (basePrice !== 0) {
const calculatedPercent = (row.price_change / basePrice) * 100;
const sign = calculatedPercent > 0 ? '+' : '';
return `${sign}${calculatedPercent.toFixed(2)}%`;
}
}
return '--';
}
const sign = row.changePercent > 0 ? '+' : ''; const sign = row.changePercent > 0 ? '+' : '';
return `${sign}${row.changePercent.toFixed(2)}%`; return `${sign}${row.changePercent.toFixed(2)}%`;
} }
@@ -335,7 +364,9 @@ const stockTableColumns = ref<DataTableColumns<StockInfo>>([
key: 'analysis', key: 'analysis',
ellipsis: { ellipsis: {
tooltip: true tooltip: true
} },
width: 300,
className: 'analysis-cell'
} }
]); ]);
@@ -393,14 +424,14 @@ function processStreamData(text: string) {
message.success(`分析完成,共扫描 ${data.total_scanned} 只股票,符合条件 ${data.total_matched} 只`); message.success(`分析完成,共扫描 ${data.total_scanned} 只股票,符合条件 ${data.total_matched} 只`);
// 将所有分析中的股票状态更新为已完成 // 将所有分析中的股票状态更新为已完成
analyzedStocks.value.forEach((stock, index) => { analyzedStocks.value = analyzedStocks.value.map(stock => {
if (stock.analysisStatus === 'analyzing') { if (stock.analysisStatus === 'analyzing') {
const updatedStock = { return {
...stock, ...stock,
analysisStatus: 'completed' as const analysisStatus: 'completed' as const
}; };
analyzedStocks.value[index] = updatedStock;
} }
return stock;
}); });
isAnalyzing.value = false; isAnalyzing.value = false;
@@ -438,6 +469,23 @@ function handleStreamUpdate(data: StreamAnalysisUpdate) {
if (stockIndex >= 0) { if (stockIndex >= 0) {
const stock = { ...analyzedStocks.value[stockIndex] }; const stock = { ...analyzedStocks.value[stockIndex] };
// 确保所有数值类型的字段都有默认值
stock.price = data.price ?? stock.price ?? undefined;
stock.price_change = data.price_change ?? stock.price_change ?? undefined;
// 使用change_percent作为涨跌幅
stock.changePercent = data.change_percent ?? stock.changePercent ?? undefined;
stock.marketValue = data.market_value ?? stock.marketValue ?? undefined;
stock.score = data.score ?? stock.score ?? undefined;
stock.rsi = data.rsi ?? stock.rsi ?? undefined;
// 如果没有change_percent但有price_change和price尝试计算changePercent
if (stock.changePercent === undefined && stock.price_change !== undefined && stock.price !== undefined) {
const basePrice = stock.price - stock.price_change;
if (basePrice !== 0) {
stock.changePercent = (stock.price_change / basePrice) * 100;
}
}
// 更新分析状态 // 更新分析状态
if (data.status) { if (data.status) {
stock.analysisStatus = data.status; stock.analysisStatus = data.status;
@@ -450,13 +498,7 @@ function handleStreamUpdate(data: StreamAnalysisUpdate) {
// 处理AI分析片段 // 处理AI分析片段
if (data.ai_analysis_chunk !== undefined) { if (data.ai_analysis_chunk !== undefined) {
// 如果之前没有分析内容,则初始化 stock.analysis = (stock.analysis || '') + data.ai_analysis_chunk;
if (!stock.analysis) {
stock.analysis = '';
}
// 追加新的分析片段
stock.analysis += data.ai_analysis_chunk;
// 确保分析状态为正在分析
stock.analysisStatus = 'analyzing'; stock.analysisStatus = 'analyzing';
} }
@@ -466,41 +508,15 @@ function handleStreamUpdate(data: StreamAnalysisUpdate) {
stock.analysisStatus = 'error'; stock.analysisStatus = 'error';
} }
// 更新股票名称、价格等信息 // 更新其他字段
if (data.name !== undefined) { if (data.name !== undefined) {
stock.name = data.name; 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) { if (data.recommendation !== undefined) {
stock.recommendation = data.recommendation; 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) { if (data.ma_trend !== undefined) {
stock.ma_trend = data.ma_trend; stock.ma_trend = data.ma_trend;
} }
@@ -513,12 +529,11 @@ function handleStreamUpdate(data: StreamAnalysisUpdate) {
stock.volume_status = data.volume_status; stock.volume_status = data.volume_status;
} }
// 添加分析日期字段的处理
if (data.analysis_date !== undefined) { if (data.analysis_date !== undefined) {
stock.analysis_date = data.analysis_date; stock.analysis_date = data.analysis_date;
} }
// 更新数组中的股票信息 // 使用Vue的响应式API更新数组
analyzedStocks.value[stockIndex] = stock; analyzedStocks.value[stockIndex] = stock;
} }
} }
@@ -530,9 +545,6 @@ async function analyzeStocks() {
return; return;
} }
isAnalyzing.value = true;
analyzedStocks.value = [];
// 解析股票代码 // 解析股票代码
const codes = stockCodes.value const codes = stockCodes.value
.split(/[,\s\n]+/) .split(/[,\s\n]+/)
@@ -541,14 +553,38 @@ async function analyzeStocks() {
if (codes.length === 0) { if (codes.length === 0) {
message.warning('未找到有效的股票代码'); message.warning('未找到有效的股票代码');
isAnalyzing.value = false;
return; return;
} }
// 去除重复的股票代码
const uniqueCodes = Array.from(new Set(codes));
// 检查是否有重复代码被移除
if (uniqueCodes.length < codes.length) {
message.info(`已自动去除${codes.length - uniqueCodes.length}个重复的股票代码`);
}
// 在前端验证股票代码
const marketTypeEnum = marketType.value as keyof typeof MarketType;
const invalidCodes = validateMultipleStockCodes(
uniqueCodes,
MarketType[marketTypeEnum]
);
// 如果有无效代码,显示错误信息并返回
if (invalidCodes.length > 0) {
const errorMessages = invalidCodes.map(item => item.errorMessage).join('\n');
message.error(`股票代码验证失败:${errorMessages}`);
return;
}
isAnalyzing.value = true;
analyzedStocks.value = [];
try { try {
// 构建请求参数 // 构建请求参数
const requestData = { const requestData = {
stock_codes: codes, stock_codes: uniqueCodes,
market_type: marketType.value market_type: marketType.value
} as any; } as any;
@@ -579,7 +615,10 @@ async function analyzeStocks() {
}); });
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error ${response.status}`); if (response.status === 404) {
throw new Error('服务器接口未找到,请检查服务是否正常运行');
}
throw new Error(`服务器响应错误: ${response.status}`);
} }
// 处理流式响应 // 处理流式响应
@@ -608,22 +647,39 @@ async function analyzeStocks() {
for (const line of lines) { for (const line of lines) {
if (line.trim()) { if (line.trim()) {
processStreamData(line); try {
processStreamData(line);
} catch (e: Error | unknown) {
console.error('处理数据流时出错:', e);
message.error(`处理数据时出错: ${e instanceof Error ? e.message : '未知错误'}`);
}
} }
} }
} }
// 处理最后可能剩余的数据 // 处理最后可能剩余的数据
if (buffer.trim()) { if (buffer.trim()) {
processStreamData(buffer); try {
processStreamData(buffer);
} catch (e: Error | unknown) {
console.error('处理最后的数据块时出错:', e);
message.error(`处理数据时出错: ${e instanceof Error ? e.message : '未知错误'}`);
}
} }
// 注意不再需要在这里更新状态因为已经在processStreamData中处理了scan_completed消息
message.success('分析完成'); message.success('分析完成');
} catch (error: any) { } catch (error: any) {
message.error(`分析出错: ${error.message || '未知错误'}`); let errorMessage = '分析出错: ';
if (error.message.includes('404')) {
errorMessage += '服务器接口未找到,请确保后端服务正常运行';
} else {
errorMessage += error.message || '未知错误';
}
message.error(errorMessage);
console.error('分析股票时出错:', error); console.error('分析股票时出错:', error);
// 清空分析状态
analyzedStocks.value = [];
} finally { } finally {
isAnalyzing.value = false; isAnalyzing.value = false;
} }
@@ -673,7 +729,7 @@ async function copyAnalysisResults() {
if (stock.price_change !== undefined) { if (stock.price_change !== undefined) {
const sign = stock.price_change > 0 ? '+' : ''; const sign = stock.price_change > 0 ? '+' : '';
result += `价格变动: ${sign}${stock.price_change.toFixed(2)}\n`; result += `涨跌额: ${sign}${stock.price_change.toFixed(2)}\n`;
} }
if (stock.ma_trend) { if (stock.ma_trend) {
@@ -867,6 +923,8 @@ onMounted(async () => {
if (config.announcement) { if (config.announcement) {
announcement.value = config.announcement; announcement.value = config.announcement;
// 使用通知显示公告
showAnnouncement(config.announcement);
} }
// 初始化后恢复本地保存的配置 // 初始化后恢复本地保存的配置
@@ -880,16 +938,24 @@ onMounted(async () => {
<style scoped> <style scoped>
.app-container { .app-container {
min-height: 100vh; min-height: 100vh;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
} }
.main-layout { .main-layout {
background-color: #f6f6f6; background-color: #f6f6f6;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
} }
.main-content { .main-content {
max-width: 1200px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
width: 100%;
box-sizing: border-box;
} }
.card-title { .card-title {
@@ -922,8 +988,9 @@ onMounted(async () => {
.n-data-table .analysis-cell { .n-data-table .analysis-cell {
max-width: 300px; max-width: 300px;
white-space: nowrap; white-space: normal;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
word-break: break-word;
} }
</style> </style>

View File

@@ -1,17 +1,57 @@
<template> <template>
<n-card class="stock-card" :bordered="false" :class="{ 'is-analyzing': isAnalyzing }"> <n-card class="stock-card" :bordered="false" :class="{ 'is-analyzing': isAnalyzing }">
<div class="card-header"> <div class="card-header">
<div class="stock-info"> <div class="header-main">
<div class="stock-code">{{ stock.code }}</div> <div class="header-left">
</div> <div class="stock-info">
<div class="stock-price-info" v-if="stock.price !== undefined"> <div class="stock-code">{{ stock.code }}</div>
<div class="stock-price">当前价格: {{ stock.price.toFixed(2) }}</div> <div class="stock-name" v-if="stock.name">{{ stock.name }}</div>
<div class="stock-change" :class="{ </div>
'up': calculatedChangePercent && calculatedChangePercent > 0, <div class="stock-price-info" v-if="stock.price !== undefined">
'down': calculatedChangePercent && calculatedChangePercent < 0 <div class="stock-price">
}"> <span class="label">当前价格:</span>
涨跌幅: {{ formatChangePercent(calculatedChangePercent) }} <span class="value">{{ stock.price.toFixed(2) }}</span>
</div>
<div class="stock-change" :class="{
'up': calculatedChangePercent && calculatedChangePercent > 0,
'down': calculatedChangePercent && calculatedChangePercent < 0
}">
<span class="label">涨跌幅:</span>
<span class="value">{{ formatChangePercent(calculatedChangePercent) }}</span>
</div>
</div>
</div> </div>
<div class="header-right">
<n-button
size="small"
v-if="stock.analysisStatus === 'completed'"
@click="copyStockAnalysis"
class="copy-button"
type="primary"
secondary
round
>
<template #icon>
<n-icon><CopyOutline /></n-icon>
</template>
复制结果
</n-button>
</div>
</div>
<div class="analysis-status" v-if="stock.analysisStatus !== 'completed'">
<n-tag
:type="getStatusType"
size="small"
round
:bordered="false"
>
<template #icon>
<n-icon>
<component :is="getStatusIcon" />
</n-icon>
</template>
{{ getStatusText }}
</n-tag>
</div> </div>
</div> </div>
@@ -49,7 +89,7 @@
'up': stock.price_change > 0, 'up': stock.price_change > 0,
'down': stock.price_change < 0 'down': stock.price_change < 0
}">{{ formatPriceChange(stock.price_change) }}</div> }">{{ formatPriceChange(stock.price_change) }}</div>
<div class="indicator-label">价格变动</div> <div class="indicator-label">涨跌额</div>
</div> </div>
<div class="indicator-item" v-if="stock.ma_trend"> <div class="indicator-item" v-if="stock.ma_trend">
@@ -78,28 +118,17 @@
<n-divider /> <n-divider />
<div class="card-content"> <div class="card-content">
<template v-if="stock.analysisStatus === 'waiting'"> <template v-if="stock.analysisStatus === 'error'">
<div class="waiting-status"> <div class="error-status">
<n-spin size="small" /> <n-icon :component="AlertCircleIcon" class="error-icon" />
<span>等待分析...</span> <span>{{ stock.error || '未知错误' }}</span>
</div> </div>
</template> </template>
<template v-else-if="stock.analysisStatus === 'analyzing'"> <template v-else-if="stock.analysisStatus === 'analyzing'">
<div class="analyzing-status">
<n-spin size="small" />
<span>正在分析...</span>
</div>
<div class="analysis-result analysis-streaming" v-if="parsedAnalysis" v-html="parsedAnalysis" :key="analysisContentKey"></div> <div class="analysis-result analysis-streaming" v-if="parsedAnalysis" v-html="parsedAnalysis" :key="analysisContentKey"></div>
</template> </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'"> <template v-else-if="stock.analysisStatus === 'completed'">
<div class="analysis-result analysis-completed" v-html="parsedAnalysis"></div> <div class="analysis-result analysis-completed" v-html="parsedAnalysis"></div>
</template> </template>
@@ -110,10 +139,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, watch, ref } from 'vue'; import { computed, watch, ref } from 'vue';
import { NCard, NDivider, NSpin, NIcon, NTag } from 'naive-ui'; import { NCard, NDivider, NSpin, NIcon, NTag, NButton, useMessage } from 'naive-ui';
import { import {
AlertCircleOutline as AlertCircleIcon, AlertCircleOutline as AlertCircleIcon,
CalendarOutline CalendarOutline,
CopyOutline,
HourglassOutline,
ReloadOutline
} from '@vicons/ionicons5'; } from '@vicons/ionicons5';
import { parseMarkdown, formatMarketValue as formatMarketValueFn } from '@/utils'; import { parseMarkdown, formatMarketValue as formatMarketValueFn } from '@/utils';
import type { StockInfo } from '@/types'; import type { StockInfo } from '@/types';
@@ -214,22 +246,24 @@ function formatChangePercent(percent: number | undefined): string {
return `${sign}${percent.toFixed(2)}%`; return `${sign}${percent.toFixed(2)}%`;
} }
function formatPriceChange(change: number): string { function formatPriceChange(change: number | undefined | null): string {
if (change === undefined || change === null) return '--';
const sign = change > 0 ? '+' : ''; const sign = change > 0 ? '+' : '';
return `${sign}${change.toFixed(2)}`; return `${sign}${change.toFixed(2)}`;
} }
function formatMarketValue(value: number): string { function formatMarketValue(value: number | undefined | null): string {
if (value === undefined || value === null) return '--';
return formatMarketValueFn(value); return formatMarketValueFn(value);
} }
function formatDate(dateStr: string): string { function formatDate(dateStr: string | undefined | null): string {
if (!dateStr) return '--';
try { try {
const date = new Date(dateStr); const date = new Date(dateStr);
if (isNaN(date.getTime())) { if (isNaN(date.getTime())) {
return dateStr; return dateStr;
} }
return date.toISOString().split('T')[0]; return date.toISOString().split('T')[0];
} catch (e) { } catch (e) {
return dateStr; return dateStr;
@@ -308,6 +342,105 @@ function getChineseVolumeStatus(status: string): string {
return statusMap[status] || status; return statusMap[status] || status;
} }
const message = useMessage();
// 添加复制功能
async function copyStockAnalysis() {
if (!props.stock.analysis) {
message.warning('暂无分析结果可复制');
return;
}
try {
let result = `${props.stock.code} ${props.stock.name || ''}\n`;
// 添加分析日期
if (props.stock.analysis_date) {
result += `分析日期: ${formatDate(props.stock.analysis_date)}\n`;
}
// 添加评分和推荐信息
if (props.stock.score !== undefined) {
result += `评分: ${props.stock.score}\n`;
}
if (props.stock.recommendation) {
result += `推荐: ${props.stock.recommendation}\n`;
}
// 添加技术指标信息
if (props.stock.rsi !== undefined) {
result += `RSI: ${props.stock.rsi.toFixed(2)}\n`;
}
if (props.stock.price_change !== undefined) {
const sign = props.stock.price_change > 0 ? '+' : '';
result += `涨跌额: ${sign}${props.stock.price_change.toFixed(2)}\n`;
}
if (props.stock.ma_trend) {
result += `均线趋势: ${getChineseTrend(props.stock.ma_trend)}\n`;
}
if (props.stock.macd_signal) {
result += `MACD信号: ${getChineseSignal(props.stock.macd_signal)}\n`;
}
if (props.stock.volume_status) {
result += `成交量: ${getChineseVolumeStatus(props.stock.volume_status)}\n`;
}
// 添加分析结果
result += `\n${props.stock.analysis}\n`;
await navigator.clipboard.writeText(result);
message.success('已复制分析结果到剪贴板');
} catch (error) {
message.error('复制失败,请手动复制');
console.error('复制分析结果时出错:', error);
}
}
// 添加状态相关的计算属性
const getStatusType = computed(() => {
switch (props.stock.analysisStatus) {
case 'waiting':
return 'default';
case 'analyzing':
return 'info';
case 'error':
return 'error';
default:
return 'default';
}
});
const getStatusIcon = computed(() => {
switch (props.stock.analysisStatus) {
case 'waiting':
return HourglassOutline;
case 'analyzing':
return ReloadOutline;
case 'error':
return AlertCircleIcon;
default:
return HourglassOutline;
}
});
const getStatusText = computed(() => {
switch (props.stock.analysisStatus) {
case 'waiting':
return '等待分析';
case 'analyzing':
return '正在分析';
case 'error':
return '分析出错';
default:
return '';
}
});
</script> </script>
<style scoped> <style scoped>
@@ -324,42 +457,167 @@ function getChineseVolumeStatus(status: string): string {
} }
.card-header { .card-header {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 8px 8px;
margin-bottom: 1rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.09);
position: relative;
background: linear-gradient(to bottom, rgba(240, 240, 245, 0.3), transparent);
border-radius: 8px 8px 0 0;
}
.header-main {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: center;
margin-bottom: 0.5rem; }
.header-left {
display: flex;
gap: 16px;
align-items: center;
} }
.stock-info { .stock-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px;
min-width: 100px;
} }
.stock-code { .stock-code {
font-size: 1.125rem; font-size: 1.35rem;
font-weight: bold; font-weight: 700;
color: var(--n-text-color); color: var(--n-text-color);
line-height: 1.2;
letter-spacing: -0.01em;
}
.stock-name {
font-size: 0.875rem;
color: var(--n-text-color-3);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 100%; max-width: 150px;
} }
.stock-price-info { .stock-price-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; gap: 6px;
padding-left: 8px;
border-left: 1px dashed rgba(0, 0, 0, 0.09);
} }
.stock-price { .stock-price, .stock-change {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.stock-price .label,
.stock-change .label {
font-size: 0.875rem;
color: var(--n-text-color-3);
}
.stock-price .value {
font-size: 1.125rem; font-size: 1.125rem;
font-weight: bold; font-weight: 600;
color: var(--n-text-color); color: var(--n-text-color);
} }
.stock-change { .stock-change .value {
font-size: 0.875rem; font-size: 1rem;
margin-top: 0.25rem; font-weight: 600;
padding: 2px 6px;
border-radius: 4px;
background-color: rgba(0, 0, 0, 0.03);
}
.up .value {
color: var(--n-error-color);
background-color: rgba(208, 48, 80, 0.08);
}
.down .value {
color: var(--n-success-color);
background-color: rgba(24, 160, 88, 0.08);
}
.header-right {
display: flex;
align-items: center;
}
.copy-button {
transition: all 0.3s ease;
font-weight: 500;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.copy-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.copy-button:active {
transform: translateY(0);
}
.analysis-status {
display: flex;
align-items: center;
margin-top: 4px;
}
.analysis-status :deep(.n-tag) {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.analysis-status :deep(.n-tag .n-icon) {
margin-right: 4px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.copy-button:active {
transform: translateY(0);
}
.analysis-status {
display: flex;
align-items: center;
}
.analysis-status :deep(.n-tag) {
display: flex;
align-items: center;
gap: 4px;
}
.analysis-status :deep(.n-tag .n-icon) {
margin-right: 4px;
}
.up .value {
color: var(--n-error-color);
}
.down .value {
color: var(--n-success-color);
} }
.stock-summary { .stock-summary {
@@ -509,15 +767,16 @@ function getChineseVolumeStatus(status: string): string {
flex-direction: column; flex-direction: column;
} }
.waiting-status,
.analyzing-status,
.error-status { .error-status {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
color: var(--n-text-color-3); color: var(--n-error-color);
font-size: 0.875rem; font-size: 0.875rem;
margin-bottom: 0.75rem; margin: 0.75rem 1rem;
padding: 0.5rem;
background-color: rgba(208, 48, 80, 0.1);
border-radius: 4px;
} }
.error-icon { .error-icon {
@@ -536,6 +795,9 @@ function getChineseVolumeStatus(status: string): string {
overflow-y: auto; overflow-y: auto;
word-break: break-word; word-break: break-word;
hyphens: auto; hyphens: auto;
width: 100%;
max-width: 100%;
overflow-x: hidden;
/* 自定义滚动条样式 */ /* 自定义滚动条样式 */
scrollbar-width: thin; /* Firefox */ scrollbar-width: thin; /* Firefox */
@@ -660,6 +922,8 @@ function getChineseVolumeStatus(status: string): string {
overflow-x: auto; overflow-x: auto;
margin: 0.75rem 0; margin: 0.75rem 0;
border-left: 3px solid #2080f0; border-left: 3px solid #2080f0;
max-width: 100%;
white-space: pre-wrap; /* 允许代码块自动换行 */
} }
.analysis-result :deep(pre code) { .analysis-result :deep(pre code) {
@@ -684,20 +948,23 @@ function getChineseVolumeStatus(status: string): string {
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
table-layout: fixed; /* 固定表格布局 */
max-width: 100%;
}
.analysis-result :deep(th), .analysis-result :deep(td) {
padding: 0.6rem;
border: 1px solid rgba(0, 0, 0, 0.1);
word-break: break-word;
overflow-wrap: break-word;
max-width: 100%;
} }
.analysis-result :deep(th) { .analysis-result :deep(th) {
background-color: rgba(32, 128, 240, 0.1); background-color: rgba(32, 128, 240, 0.1);
color: #2080f0; color: #2080f0;
font-weight: 600; font-weight: 600;
padding: 0.6rem;
text-align: left; text-align: left;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.analysis-result :deep(td) {
padding: 0.6rem;
border: 1px solid rgba(0, 0, 0, 0.1);
} }
.analysis-result :deep(tr:nth-child(even)) { .analysis-result :deep(tr:nth-child(even)) {
@@ -760,4 +1027,14 @@ function getChineseVolumeStatus(status: string): string {
color: #36ad6a; color: #36ad6a;
border-bottom: 1px solid #36ad6a; border-bottom: 1px solid #36ad6a;
} }
/* 优化图片样式 */
.analysis-result :deep(img) {
max-width: 100%;
height: auto;
display: block;
margin: 0.75rem auto;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
</style> </style>

View File

@@ -68,7 +68,7 @@ const searchKeyword = ref('');
const results = ref<SearchResult[]>([]); const results = ref<SearchResult[]>([]);
const loading = ref(false); const loading = ref(false);
const showResults = ref(false); const showResults = ref(false);
const searchInputRef = ref<HTMLElement | null>(null); const searchInputRef = ref<any>(null);
// 创建防抖搜索函数 // 创建防抖搜索函数
const debouncedSearch = debounce(async (keyword: string) => { const debouncedSearch = debounce(async (keyword: string) => {
@@ -83,7 +83,9 @@ const debouncedSearch = debounce(async (keyword: string) => {
try { try {
if (props.marketType === 'US') { if (props.marketType === 'US') {
// 美股搜索 // 美股搜索
results.value = await apiService.searchUsStocks(keyword); const searchResults = await apiService.searchUsStocks(keyword);
// 限制只显示前10个结果
results.value = searchResults.slice(0, 10);
} else { } else {
// 其他市场搜索 (后端需要实现对应的接口) // 其他市场搜索 (后端需要实现对应的接口)
results.value = []; results.value = [];
@@ -128,7 +130,7 @@ function formatMarketValue(value: number): string {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if ( if (
searchInputRef.value && searchInputRef.value &&
!searchInputRef.value.contains(event.target as Node) !searchInputRef.value.$el.contains(event.target as Node)
) { ) {
showResults.value = false; showResults.value = false;
} }

View File

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

View File

@@ -0,0 +1,89 @@
import { createRouter, createWebHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import { apiService } from '@/services/api';
import StockAnalysisApp from '@/components/StockAnalysisApp.vue';
import LoginPage from '@/components/LoginPage.vue';
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'Home',
component: StockAnalysisApp,
meta: { requiresAuth: true }
},
{
path: '/login',
name: 'Login',
component: LoginPage,
meta: { requiresAuth: false }
},
{
path: '/:pathMatch(.*)*',
redirect: '/'
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
// 全局前置守卫
router.beforeEach(async (to, from, next) => {
console.log(`路由跳转: 从 ${from.path}${to.path}`);
// 如果已经在登录页面,直接通过
if (to.path === '/login') {
next();
return;
}
// 检查路由是否需要认证
if (to.matched.some(record => record.meta.requiresAuth)) {
console.log('当前路由需要认证');
try {
// 先检查系统是否需要登录
const requireLogin = await apiService.checkNeedLogin();
console.log('系统是否需要登录:', requireLogin);
if (!requireLogin) {
// 系统不需要登录,直接通过
console.log('系统不需要登录,允许访问');
next();
return;
}
// 系统需要登录检查本地是否有token
const token = localStorage.getItem('token');
if (!token) {
console.log('本地没有token跳转到登录页');
next({ name: 'Login' });
return;
}
const isAuthenticated = await apiService.checkAuth();
console.log('认证检查结果:', isAuthenticated);
if (!isAuthenticated) {
// 未登录,重定向到登录页
console.log('认证失败,跳转到登录页');
next({ name: 'Login' });
} else {
// 已登录,允许访问
console.log('认证成功,允许访问');
next();
}
} catch (error) {
console.error('认证检查失败:', error);
// 认证检查失败,重定向到登录页
next({ name: 'Login' });
}
} else {
// 不需要认证的路由,直接访问
console.log('当前路由不需要认证,直接访问');
next();
}
});
export default router;

View File

@@ -1,13 +1,88 @@
import axios from 'axios'; import axios from 'axios';
import type { AnalyzeRequest, TestApiRequest, TestApiResponse, SearchResult } from '@/types'; import type { AnalyzeRequest, TestApiRequest, TestApiResponse, SearchResult, LoginRequest, LoginResponse } from '@/types';
// 在开发环境中前缀为空因为已经在vite.config.ts中配置了代理 // 在开发环境中使用完整URL
const API_PREFIX = ''; const API_PREFIX = '';
// 创建axios实例
const axiosInstance = axios.create({
baseURL: API_PREFIX
});
// 请求拦截器添加token
axiosInstance.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器处理401错误
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
// 清除token
localStorage.removeItem('token');
// 不要在这里跳转,避免循环重定向
}
return Promise.reject(error);
}
);
export const apiService = { export const apiService = {
// 用户登录
login: async (request: LoginRequest): Promise<LoginResponse> => {
try {
const response = await axios.post(`${API_PREFIX}/login`, request);
if (response.data.access_token) {
localStorage.setItem('token', response.data.access_token);
}
return response.data;
} catch (error: any) {
if (error.response) {
return {
success: false,
message: error.response.data.detail || '登录失败',
};
}
return {
success: false,
message: error.message || '登录失败'
};
}
},
// 检查认证状态
checkAuth: async (): Promise<boolean> => {
try {
const response = await axiosInstance.get(`${API_PREFIX}/check_auth`);
return response.data.authenticated === true;
} catch (error) {
// 认证失败清除token
localStorage.removeItem('token');
return false;
}
},
// 登出
logout: () => {
localStorage.removeItem('token');
// 简化登出逻辑
window.location.href = '/login';
},
// 分析股票 // 分析股票
analyzeStocks: async (request: AnalyzeRequest) => { analyzeStocks: async (request: AnalyzeRequest) => {
return axios.post(`${API_PREFIX}/analyze`, request, { return axiosInstance.post(`${API_PREFIX}/analyze`, request, {
responseType: 'stream' responseType: 'stream'
}); });
}, },
@@ -15,7 +90,7 @@ export const apiService = {
// 测试API连接 // 测试API连接
testApiConnection: async (request: TestApiRequest): Promise<TestApiResponse> => { testApiConnection: async (request: TestApiRequest): Promise<TestApiResponse> => {
try { try {
const response = await axios.post(`${API_PREFIX}/test_api_connection`, request); const response = await axiosInstance.post(`${API_PREFIX}/test_api_connection`, request);
return response.data; return response.data;
} catch (error: any) { } catch (error: any) {
if (error.response) { if (error.response) {
@@ -31,7 +106,7 @@ export const apiService = {
// 搜索美股 // 搜索美股
searchUsStocks: async (keyword: string): Promise<SearchResult[]> => { searchUsStocks: async (keyword: string): Promise<SearchResult[]> => {
try { try {
const response = await axios.get(`${API_PREFIX}/search_us_stocks`, { const response = await axiosInstance.get(`${API_PREFIX}/search_us_stocks`, {
params: { keyword } params: { keyword }
}); });
return response.data.results || []; return response.data.results || [];
@@ -55,5 +130,17 @@ export const apiService = {
default_api_timeout: '60' default_api_timeout: '60'
}; };
} }
},
// 检查是否需要登录
checkNeedLogin: async (): Promise<boolean> => {
try {
const response = await axios.get(`${API_PREFIX}/need_login`);
return response.data.require_login;
} catch (error) {
console.error('检查是否需要登录时出错:', error);
// 默认为需要登录,确保安全
return true;
}
} }
}; };

View File

@@ -7,6 +7,18 @@ export interface ApiConfig {
saveApiConfig: boolean; saveApiConfig: boolean;
} }
// 登录相关类型
export interface LoginRequest {
password: string;
}
export interface LoginResponse {
access_token?: string;
token_type?: string;
success?: boolean;
message?: string;
}
export interface StockInfo { export interface StockInfo {
code: string; code: string;
name: string; name: string;

View File

@@ -0,0 +1,172 @@
/**
* 股票代码验证工具
* 用于验证不同市场类型的股票代码格式
*/
/**
* 市场类型枚举
*/
export enum MarketType {
A = 'A', // A股
HK = 'HK', // 港股
US = 'US', // 美股
ETF = 'ETF', // ETF基金
LOF = 'LOF' // LOF基金
}
/**
* 验证A股股票代码
* @param code 股票代码
* @returns 是否为有效的A股代码
*/
export const validateAStock = (code: string): boolean => {
// 上海证券交易所股票代码以6开头6位数字
// 深圳证券交易所股票代码以0或3开头6位数字
// 科创板股票代码以688开头6位数字
// 北京证券交易所股票代码以8开头一般为5位数字如80XXX
// 注意:中小板、创业板代码格式已合并处理
// 验证上海证券交易所以6开头的6位数字
if (code.startsWith('6') && /^\d{6}$/.test(code)) {
return true;
}
// 验证深圳证券交易所以0或3开头的6位数字
if ((code.startsWith('0') || code.startsWith('3')) && /^\d{6}$/.test(code)) {
return true;
}
// 验证科创板以688开头的6位数字
if (code.startsWith('688') && /^\d{6}$/.test(code)) {
return true;
}
// 验证北京证券交易所以8开头的股票
// 北交所股票一般是5位数字格式为8xxxx
if (code.startsWith('8') && /^\d{5}$/.test(code)) {
return true;
}
return false;
};
/**
* 验证港股股票代码
* @param code 股票代码
* @returns 是否为有效的港股代码
*/
export const validateHKStock = (code: string): boolean => {
// 港股通常是5位数字
return /^\d{5}$/.test(code);
};
/**
* 验证美股股票代码
* @param code 股票代码
* @returns 是否为有效的美股代码
*/
export const validateUSStock = (code: string): boolean => {
// 美股代码通常由字母组成长度在1-5之间
return /^[A-Za-z]{1,5}$/.test(code);
};
/**
* 验证ETF/LOF基金代码
* @param code 基金代码
* @returns 是否为有效的基金代码
*/
export const validateFund = (code: string): boolean => {
// 基金代码通常为6位数字
return /^\d{6}$/.test(code);
};
/**
* 根据市场类型验证股票代码
* @param code 股票代码
* @param marketType 市场类型
* @returns 包含验证结果和错误信息的对象
*/
export const validateStockCode = (
code: string,
marketType: MarketType
): { valid: boolean; errorMessage?: string } => {
if (!code || code.trim() === '') {
return {
valid: false,
errorMessage: '股票代码不能为空'
};
}
switch (marketType) {
case MarketType.A:
if (!validateAStock(code)) {
return {
valid: false,
errorMessage: `无效的A股股票代码格式: ${code}。A股代码应以0、3、6、688或8开头且为6位数字或5位数字`
};
}
break;
case MarketType.HK:
if (!validateHKStock(code)) {
return {
valid: false,
errorMessage: `无效的港股代码格式: ${code}。港股代码应为5位数字`
};
}
break;
case MarketType.US:
if (!validateUSStock(code)) {
return {
valid: false,
errorMessage: `无效的美股代码格式: ${code}。美股代码应为1-5位字母`
};
}
break;
case MarketType.ETF:
case MarketType.LOF:
if (!validateFund(code)) {
return {
valid: false,
errorMessage: `无效的${marketType}基金代码格式: ${code}。基金代码应为6位数字`
};
}
break;
default:
return {
valid: false,
errorMessage: `不支持的市场类型: ${marketType}`
};
}
return { valid: true };
};
/**
* 批量验证多个股票代码
* @param codes 股票代码数组
* @param marketType 市场类型
* @returns 包含所有无效代码及其错误信息的数组
*/
export const validateMultipleStockCodes = (
codes: string[],
marketType: MarketType
): { code: string; errorMessage: string }[] => {
const invalidCodes: { code: string; errorMessage: string }[] = [];
for (const code of codes) {
const result = validateStockCode(code, marketType);
if (!result.valid && result.errorMessage) {
invalidCodes.push({
code,
errorMessage: result.errorMessage
});
}
}
return invalidCodes;
};

View File

@@ -16,6 +16,7 @@ export default defineConfig({
}, },
}, },
server: { server: {
cors: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:8888', target: 'http://127.0.0.1:8888',
@@ -38,6 +39,30 @@ export default defineConfig({
target: 'http://127.0.0.1:8888', target: 'http://127.0.0.1:8888',
changeOrigin: true, changeOrigin: true,
}, },
'/login': {
target: 'http://127.0.0.1:8888',
changeOrigin: true,
},
'/check_auth': {
target: 'http://127.0.0.1:8888',
changeOrigin: true,
},
'/need_login': {
target: 'http://127.0.0.1:8888',
changeOrigin: true,
},
'/us_stock_detail': {
target: 'http://127.0.0.1:8888',
changeOrigin: true,
},
'/fund_detail': {
target: 'http://127.0.0.1:8888',
changeOrigin: true,
},
'/search_funds': {
target: 'http://127.0.0.1:8888',
changeOrigin: true,
},
}, },
}, },
}) })

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +0,0 @@
import akshare as ak
import pandas as pd
class FundService:
def search_funds(self, keyword, market_type='ETF'):
"""
搜索基金代码
:param keyword: 搜索关键词
:return: 匹配的基金列表
"""
try:
# 获取ETF和LOF数据
if market_type == 'ETF':
df = ak.fund_etf_spot_em()
else:
df = ak.fund_lof_spot_em()
# 转换列名
df = df.rename(columns={
"代码": "symbol",
"名称": "name",
"最新价": "price",
"涨跌额": "price_change",
"涨跌幅": "price_change_percent",
"成交量": "volume",
"流通市值": "market_value",
"总市值": "total_value",
"基金折价率": "discount_rate",
})
# 模糊匹配搜索(同时匹配代码和名称)
mask = (df['name'].str.contains(keyword, case=False, na=False) |
df['symbol'].str.contains(keyword, case=False, na=False))
results = df[mask]
# 格式化返回结果并处理 NaN 值
formatted_results = []
for _, row in results.iterrows():
formatted_results.append({
'name': row['name'] if pd.notna(row['name']) else '',
'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '',
'price': float(row['price']) if pd.notna(row['price']) else 0.0,
'volume': float(row['volume']) if pd.notna(row['volume']) else 0.0,
'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0,
'total_value': float(row['total_value']) if pd.notna(row['total_value']) else 0.0,
})
return formatted_results
except Exception as e:
raise Exception(f"搜索基金代码失败: {str(e)}")

View File

@@ -35,3 +35,5 @@ html5lib==1.1
lxml==4.9.4 lxml==4.9.4
jsonpath==0.82.2 jsonpath==0.82.2
openpyxl==3.1.5 openpyxl==3.1.5
python-jose[cryptography]==3.4.0
passlib==1.7.4

View File

@@ -231,35 +231,83 @@ class AIAnalyzer:
async for chunk in response.aiter_text(): async for chunk in response.aiter_text():
if chunk: if chunk:
chunk_str = chunk.strip() # 分割多行响应处理某些API可能在一个chunk中返回多行
if chunk_str.startswith("data: "): lines = chunk.strip().split('\n')
chunk_str = chunk_str[6:] # 去除"data: "前缀 for line in lines:
line = line.strip()
if not line:
continue
if chunk_str == "[DONE]": # 处理以data:开头的行
logger.debug("收到流结束标记 [DONE]") if line.startswith("data: "):
continue line = line[6:] # 去除"data: "前缀
try: if line == "[DONE]":
# 解析数据块 logger.debug("收到流结束标记 [DONE]")
chunk_data = json.loads(chunk_str) continue
delta = chunk_data.get("choices", [{}])[0].get("delta", {})
content = delta.get("content", "")
if content: try:
chunk_count += 1 # 处理特殊错误情况
buffer += content if "error" in line.lower():
collected_messages.append(content) error_msg = line
try:
error_data = json.loads(line)
error_msg = error_data.get("error", line)
except:
pass
# 直接发送每个内容片段,不累积 logger.error(f"流式响应中收到错误: {error_msg}")
yield json.dumps({ yield json.dumps({
"stock_code": stock_code, "stock_code": stock_code,
"ai_analysis_chunk": content, "error": f"流式响应错误: {error_msg}",
"status": "analyzing" "status": "error"
}) })
except json.JSONDecodeError: continue
# 忽略无法解析的块
logger.error(f"JSON解析错误块内容: {chunk_str[:100]}...") # 尝试解析JSON
continue chunk_data = json.loads(line)
# 检查是否有finish_reason
finish_reason = chunk_data.get("choices", [{}])[0].get("finish_reason")
if finish_reason == "stop":
logger.debug("收到finish_reason=stop流结束")
continue
# 获取delta内容
delta = chunk_data.get("choices", [{}])[0].get("delta", {})
# 检查delta是否为空对象
if not delta or delta == {}:
logger.debug("收到空的delta对象跳过")
continue
content = delta.get("content", "")
if content:
chunk_count += 1
buffer += content
collected_messages.append(content)
# 直接发送每个内容片段,不累积
yield json.dumps({
"stock_code": stock_code,
"ai_analysis_chunk": content,
"status": "analyzing"
})
except json.JSONDecodeError:
# 记录解析错误并尝试恢复
logger.error(f"JSON解析错误块内容: {line}")
# 如果是特定错误模式,处理它
if "streaming failed after retries" in line.lower():
logger.error("检测到流式传输失败")
yield json.dumps({
"stock_code": stock_code,
"error": "流式传输失败,请稍后重试",
"status": "error"
})
return
continue
logger.info(f"AI流式处理完成共收到 {chunk_count} 个内容片段,总长度: {len(buffer)}") logger.info(f"AI流式处理完成共收到 {chunk_count} 个内容片段,总长度: {len(buffer)}")

View File

@@ -56,8 +56,11 @@ class FundServiceAsync:
'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0, 'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0,
'total_value': float(row['total_value']) if pd.notna(row['total_value']) else 0.0, 'total_value': float(row['total_value']) if pd.notna(row['total_value']) else 0.0,
}) })
# 限制只返回前10个结果
if len(formatted_results) >= 10:
break
logger.info(f"基金搜索完成,找到 {len(formatted_results)} 个匹配项") logger.info(f"基金搜索完成,找到 {len(formatted_results)} 个匹配项限制显示前10个")
return formatted_results return formatted_results
except Exception as e: except Exception as e:

View File

@@ -60,6 +60,30 @@ class StockAnalyzerService:
# 获取股票数据 # 获取股票数据
df = await self.data_provider.get_stock_data(stock_code, market_type) df = await self.data_provider.get_stock_data(stock_code, market_type)
# 检查是否有错误
if hasattr(df, 'error'):
error_msg = df.error
logger.error(f"获取股票数据时出错: {error_msg}")
yield json.dumps({
"stock_code": stock_code,
"market_type": market_type,
"error": error_msg,
"status": "error"
})
return
# 检查数据是否为空
if df.empty:
error_msg = f"获取到的股票 {stock_code} 数据为空"
logger.error(error_msg)
yield json.dumps({
"stock_code": stock_code,
"market_type": market_type,
"error": error_msg,
"status": "error"
})
return
# 计算技术指标 # 计算技术指标
df_with_indicators = self.indicator.calculate_indicators(df) df_with_indicators = self.indicator.calculate_indicators(df)

View File

@@ -5,6 +5,7 @@ import asyncio
import os import os
from typing import Dict, List, Optional, Tuple, Any from typing import Dict, List, Optional, Tuple, Any
from logger import get_logger from logger import get_logger
import re
# 获取日志器 # 获取日志器
logger = get_logger() logger = get_logger()
@@ -57,27 +58,16 @@ class StockDataProvider:
if end_date is None: if end_date is None:
end_date = datetime.now().strftime('%Y%m%d') end_date = datetime.now().strftime('%Y%m%d')
# 确保日期格式统一(移除可能的'-'符号)
if isinstance(start_date, str) and '-' in start_date:
start_date = start_date.replace('-', '')
if isinstance(end_date, str) and '-' in end_date:
end_date = end_date.replace('-', '')
try: try:
# 验证股票代码格式
if market_type == 'A': if market_type == 'A':
# 上海证券交易所股票代码以6开头
# 深圳证券交易所股票代码以0或3开头
# 科创板股票代码以688开头
# 北京证券交易所股票代码以8开头
valid_prefixes = ['0', '3', '6', '688', '8']
valid_format = False
for prefix in valid_prefixes:
if stock_code.startswith(prefix):
valid_format = True
break
if not valid_format:
error_msg = f"无效的A股股票代码格式: {stock_code}。A股代码应以0、3、6、688或8开头"
logger.error(f"[股票代码格式错误] {error_msg}")
raise ValueError(error_msg)
logger.debug(f"获取A股数据: {stock_code}") logger.debug(f"获取A股数据: {stock_code}")
df = ak.stock_zh_a_hist( df = ak.stock_zh_a_hist(
symbol=stock_code, symbol=stock_code,
start_date=start_date, start_date=start_date,
@@ -96,12 +86,71 @@ class StockDataProvider:
elif market_type in ['US']: elif market_type in ['US']:
logger.debug(f"获取美股数据: {stock_code}") logger.debug(f"获取美股数据: {stock_code}")
df = ak.stock_us_daily( try:
symbol=stock_code, df = ak.stock_us_daily(
adjust="qfq" symbol=stock_code,
) adjust="qfq"
)
logger.debug(f"美股数据原始列: {df.columns.tolist()}")
logger.debug(f"美股数据形状: {df.shape}")
# 确保索引是日期时间类型
if not isinstance(df.index, pd.DatetimeIndex):
# 如果存在命名为'date'的列,将其设为索引
if 'date' in df.columns:
df['date'] = pd.to_datetime(df['date'])
df.set_index('date', inplace=True)
logger.debug("已将'date'列设置为索引")
else:
# 否则将当前索引转换为日期类型
df.index = pd.to_datetime(df.index)
logger.debug("已将索引转换为DatetimeIndex")
# 计算美股的成交额Amount= 成交量Volume× 收盘价Close
volume_col = next((col for col in df.columns if col.lower() == 'volume'), None)
close_col = next((col for col in df.columns if col.lower() == 'close'), None)
if volume_col and close_col:
df['amount'] = df[volume_col] * df[close_col]
logger.debug("已为美股数据计算成交额(amount)字段")
else:
logger.warning(f"美股数据缺少volume或close列无法计算amount。当前列: {df.columns.tolist()}")
# 添加空的amount列避免后续处理错误
df['amount'] = 0.0
# 将所有列名转为小写以进行统一处理
df.columns = [col.lower() for col in df.columns]
except Exception as e:
logger.error(f"获取美股数据失败 {stock_code}: {str(e)}")
raise ValueError(f"获取美股数据失败 {stock_code}: {str(e)}")
# 将字符串日期转换为日期时间对象进行比较
try:
# 尝试多种格式解析日期
# 如果日期是数字格式20220101使用适当的格式
if start_date.isdigit() and len(start_date) == 8:
start_date_dt = pd.to_datetime(start_date, format='%Y%m%d')
else:
# 否则让pandas自动推断格式
start_date_dt = pd.to_datetime(start_date)
if end_date.isdigit() and len(end_date) == 8:
end_date_dt = pd.to_datetime(end_date, format='%Y%m%d')
else:
end_date_dt = pd.to_datetime(end_date)
except Exception as e:
logger.warning(f"日期转换出错: {str(e)},使用默认值")
# 如果转换失败,使用合理的默认值
start_date_dt = pd.to_datetime('20000101', format='%Y%m%d')
end_date_dt = pd.to_datetime(datetime.now().strftime('%Y%m%d'), format='%Y%m%d')
# 过滤日期 # 过滤日期
df = df[(df.index >= start_date) & (df.index <= end_date)] try:
df = df[(df.index >= start_date_dt) & (df.index <= end_date_dt)]
logger.debug(f"日期过滤后数据点数: {len(df)}")
except Exception as e:
logger.warning(f"日期过滤出错: {str(e)},返回原始数据")
elif market_type in ['ETF', 'LOF']: elif market_type in ['ETF', 'LOF']:
logger.debug(f"获取{market_type}基金数据: {stock_code}") logger.debug(f"获取{market_type}基金数据: {stock_code}")
@@ -122,8 +171,31 @@ class StockDataProvider:
# 实际数据列:['日期', '股票代码', '开盘', '收盘', '最高', '最低', '成交量', '成交额', '振幅', '涨跌幅', '涨跌额', '换手率'] # 实际数据列:['日期', '股票代码', '开盘', '收盘', '最高', '最低', '成交量', '成交额', '振幅', '涨跌幅', '涨跌额', '换手率']
df.columns = ['Date', 'Code', 'Open', 'Close', 'High', 'Low', 'Volume', 'Amount', 'Amplitude', 'Change_pct', 'Change', 'Turnover'] df.columns = ['Date', 'Code', 'Open', 'Close', 'High', 'Low', 'Volume', 'Amount', 'Amplitude', 'Change_pct', 'Change', 'Turnover']
elif market_type in ['HK', 'US']: elif market_type in ['HK', 'US']:
# 根据实际情况调整 # 美股数据列可能不同,需要通过映射处理
df.columns = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume', 'Amount'] columns_mapping = {
'open': 'Open',
'high': 'High',
'low': 'Low',
'close': 'Close',
'volume': 'Volume',
'amount': 'Amount'
}
# 创建新的DataFrame以确保列顺序和存在性
new_df = pd.DataFrame(index=df.index)
# 遍历映射填充新DataFrame
for orig_col, new_col in columns_mapping.items():
if orig_col in df.columns:
new_df[new_col] = df[orig_col]
else:
# 如果原始列不存在创建一个填充0的列
logger.warning(f"数据中缺少{orig_col}使用0值填充")
new_df[new_col] = 0.0
# 替换原始df
df = new_df
elif market_type in ['ETF', 'LOF']: elif market_type in ['ETF', 'LOF']:
# 基金数据可能有不同的列 # 基金数据可能有不同的列
df.columns = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume', 'Amount'] df.columns = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume', 'Amount']
@@ -143,7 +215,11 @@ class StockDataProvider:
error_msg = f"获取{market_type}数据失败 {stock_code}: {str(e)}" error_msg = f"获取{market_type}数据失败 {stock_code}: {str(e)}"
logger.error(error_msg) logger.error(error_msg)
logger.exception(e) logger.exception(e)
raise Exception(error_msg) # 使用空的DataFrame并添加错误信息而不是抛出异常
# 这样上层调用者可以检查是否有错误并适当处理
df = pd.DataFrame()
df.error = error_msg # 添加错误属性
return df
async def get_multiple_stocks_data(self, stock_codes: List[str], async def get_multiple_stocks_data(self, stock_codes: List[str],
market_type: str = 'A', market_type: str = 'A',

View File

@@ -49,8 +49,11 @@ class USStockServiceAsync:
'price': float(row['price']) if pd.notna(row['price']) else 0.0, 'price': float(row['price']) if pd.notna(row['price']) else 0.0,
'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0 'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0
}) })
# 限制只返回前10个结果
if len(formatted_results) >= 10:
break
logger.info(f"美股搜索完成,找到 {len(formatted_results)} 个匹配项") logger.info(f"美股搜索完成,找到 {len(formatted_results)} 个匹配项限制显示前10个")
return formatted_results return formatted_results
except Exception as e: except Exception as e:

View File

@@ -1,758 +0,0 @@
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import os
import requests
from typing import Dict, List, Optional, Tuple, Generator
from dotenv import load_dotenv
import json
from logger import get_logger
from utils.api_utils import APIUtils
# 获取日志器
logger = get_logger()
class StockAnalyzer:
def __init__(self, initial_cash=1000000, custom_api_url=None, custom_api_key=None, custom_api_model=None, custom_api_timeout=None):
# 加载环境变量
load_dotenv()
# 设置 API 配置,优先使用自定义配置,否则使用环境变量
self.API_URL = custom_api_url or os.getenv('API_URL')
self.API_KEY = custom_api_key or os.getenv('API_KEY')
self.API_MODEL = custom_api_model or os.getenv('API_MODEL', 'gpt-3.5-turbo')
self.API_TIMEOUT = int(custom_api_timeout or os.getenv('API_TIMEOUT', 60))
logger.debug(f"初始化StockAnalyzer: API_URL={self.API_URL}, API_MODEL={self.API_MODEL}, API_KEY={'已提供' if self.API_KEY else '未提供'}, API_TIMEOUT={self.API_TIMEOUT}")
# 配置参数
self.params = {
'ma_periods': {'short': 5, 'medium': 20, 'long': 60},
'rsi_period': 14,
'bollinger_period': 20,
'bollinger_std': 2,
'volume_ma_period': 20,
'atr_period': 14
}
def get_stock_data(self, stock_code, market_type='A', start_date=None, end_date=None):
"""获取股票或基金数据"""
import akshare as ak
if start_date is None:
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y%m%d')
if end_date is None:
end_date = datetime.now().strftime('%Y%m%d')
try:
# 验证股票代码格式
if market_type == 'A':
# 上海证券交易所股票代码以6开头
# 深圳证券交易所股票代码以0或3开头
# 科创板股票代码以688开头
# 北京证券交易所股票代码以8开头
valid_prefixes = ['0', '3', '6', '688', '8']
valid_format = False
for prefix in valid_prefixes:
if stock_code.startswith(prefix):
valid_format = True
break
if not valid_format:
error_msg = f"无效的A股股票代码格式: {stock_code}。A股代码应以0、3、6、688或8开头"
logger.error(f"[股票代码格式错误] {error_msg}")
raise ValueError(error_msg)
df = ak.stock_zh_a_hist(
symbol=stock_code,
start_date=start_date,
end_date=end_date,
adjust="qfq"
)
elif market_type == 'HK':
df = ak.stock_hk_daily(
symbol=stock_code,
adjust="qfq"
)
elif market_type == 'US':
df = ak.stock_us_hist(
symbol=stock_code,
start_date=start_date,
end_date=end_date,
adjust="qfq"
)
elif market_type == 'ETF':
df = ak.fund_etf_hist_em(
symbol=stock_code,
period="daily",
start_date=start_date,
end_date=end_date,
adjust="qfq"
)
elif market_type == 'LOF':
df = ak.fund_lof_hist_em(
symbol=stock_code,
period="daily",
start_date=start_date,
end_date=end_date,
adjust="qfq"
)
else:
raise ValueError(f"不支持的市场类型: {market_type}")
# 重命名列名以匹配分析需求
df = df.rename(columns={
"日期": "date",
"开盘": "open",
"收盘": "close",
"最高": "high",
"最低": "low",
"成交量": "volume"
})
# 确保日期格式正确
df['date'] = pd.to_datetime(df['date'])
# 数据类型转换
numeric_columns = ['open', 'close', 'high', 'low', 'volume']
df[numeric_columns] = df[numeric_columns].apply(pd.to_numeric, errors='coerce')
# 删除空值
df = df.dropna()
return df.sort_values('date')
# except ValueError as ve:
# # 捕获格式验证错误
# logger.error(f"[股票代码格式错误] {str(ve)}")
# raise Exception(f"股票代码格式错误: {str(ve)}")
except Exception as e:
logger.error(f"[获取数据失败] {str(e)}")
raise Exception(f"获取数据失败: {str(e)}")
def calculate_ema(self, series, period):
"""计算指数移动平均线"""
return series.ewm(span=period, adjust=False).mean()
def calculate_rsi(self, series, period):
"""计算RSI指标"""
delta = series.diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
return 100 - (100 / (1 + rs))
def calculate_macd(self, series):
"""计算MACD指标"""
exp1 = series.ewm(span=12, adjust=False).mean()
exp2 = series.ewm(span=26, adjust=False).mean()
macd = exp1 - exp2
signal = macd.ewm(span=9, adjust=False).mean()
hist = macd - signal
return macd, signal, hist
def calculate_bollinger_bands(self, series, period, std_dev):
"""计算布林带"""
middle = series.rolling(window=period).mean()
std = series.rolling(window=period).std()
upper = middle + (std * std_dev)
lower = middle - (std * std_dev)
return upper, middle, lower
def calculate_atr(self, df, period):
"""计算ATR指标"""
high = df['high']
low = df['low']
close = df['close'].shift(1)
tr1 = high - low
tr2 = abs(high - close)
tr3 = abs(low - close)
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
return tr.rolling(window=period).mean()
def calculate_indicators(self, df):
"""计算技术指标"""
try:
# 计算移动平均线
df['MA5'] = self.calculate_ema(df['close'], self.params['ma_periods']['short'])
df['MA20'] = self.calculate_ema(df['close'], self.params['ma_periods']['medium'])
df['MA60'] = self.calculate_ema(df['close'], self.params['ma_periods']['long'])
# 计算RSI
df['RSI'] = self.calculate_rsi(df['close'], self.params['rsi_period'])
# 计算MACD
df['MACD'], df['Signal'], df['MACD_hist'] = self.calculate_macd(df['close'])
# 计算布林带
df['BB_upper'], df['BB_middle'], df['BB_lower'] = self.calculate_bollinger_bands(
df['close'],
self.params['bollinger_period'],
self.params['bollinger_std']
)
# 成交量分析
df['Volume_MA'] = df['volume'].rolling(window=self.params['volume_ma_period']).mean()
df['Volume_Ratio'] = df['volume'] / df['Volume_MA']
# 计算ATR和波动率
df['ATR'] = self.calculate_atr(df, self.params['atr_period'])
df['Volatility'] = df['ATR'] / df['close'] * 100
# 动量指标
df['ROC'] = df['close'].pct_change(periods=10) * 100
return df
except Exception as e:
print(f"计算技术指标时出错: {str(e)}")
raise
def calculate_score(self, df):
"""计算评分"""
try:
score = 0
latest = df.iloc[-1]
# 趋势得分 (30分)
if latest['MA5'] > latest['MA20']:
score += 15
if latest['MA20'] > latest['MA60']:
score += 15
# RSI得分 (20分)
if 30 <= latest['RSI'] <= 70:
score += 20
elif latest['RSI'] < 30: # 超卖
score += 15
# MACD得分 (20分)
if latest['MACD'] > latest['Signal']:
score += 20
# 成交量得分 (30分)
if latest['Volume_Ratio'] > 1.5:
score += 30
elif latest['Volume_Ratio'] > 1:
score += 15
return score
except Exception as e:
print(f"计算评分时出错: {str(e)}")
raise
def get_ai_analysis(self, df, stock_code, market_type='A', stream=False):
"""使用 OpenAI 进行 AI 分析"""
try:
logger.info(f"开始AI分析 {stock_code}, 流式模式: {stream}")
recent_data = df.tail(14).to_dict('records')
technical_summary = {
'trend': 'upward' if df.iloc[-1]['MA5'] > df.iloc[-1]['MA20'] else 'downward',
'volatility': f"{df.iloc[-1]['Volatility']:.2f}%",
'volume_trend': 'increasing' if df.iloc[-1]['Volume_Ratio'] > 1 else 'decreasing',
'rsi_level': df.iloc[-1]['RSI']
}
# 根据市场类型调整分析提示
if market_type in ['ETF', 'LOF']:
prompt = f"""
分析基金 {stock_code}
技术指标概要:
{technical_summary}
近14日交易数据
{recent_data}
请提供:
1. 净值走势分析(包含支撑位和压力位)
2. 成交量分析及其对净值的影响
3. 风险评估(包含波动率和折溢价分析)
4. 短期和中期净值预测
5. 关键价格位分析
6. 申购赎回建议(包含止损位)
请基于技术指标和市场表现进行分析,给出具体数据支持。
"""
elif market_type == 'US':
prompt = f"""
分析美股 {stock_code}
技术指标概要:
{technical_summary}
近14日交易数据
{recent_data}
请提供:
1. 趋势分析(包含支撑位和压力位,美元计价)
2. 成交量分析及其含义
3. 风险评估(包含波动率和美股市场特有风险)
4. 短期和中期目标价位(美元)
5. 关键技术位分析
6. 具体交易建议(包含止损位)
请基于技术指标和美股市场特点进行分析,给出具体数据支持。
"""
elif market_type == 'HK':
prompt = f"""
分析港股 {stock_code}
技术指标概要:
{technical_summary}
近14日交易数据
{recent_data}
请提供:
1. 趋势分析(包含支撑位和压力位,港币计价)
2. 成交量分析及其含义
3. 风险评估(包含波动率和港股市场特有风险)
4. 短期和中期目标价位(港币)
5. 关键技术位分析
6. 具体交易建议(包含止损位)
请基于技术指标和港股市场特点进行分析,给出具体数据支持。
"""
else: # A股
prompt = f"""
分析A股 {stock_code}
技术指标概要:
{technical_summary}
近14日交易数据
{recent_data}
请提供:
1. 趋势分析(包含支撑位和压力位)
2. 成交量分析及其含义
3. 风险评估(包含波动率分析)
4. 短期和中期目标价位
5. 关键技术位分析
6. 具体交易建议(包含止损位)
请基于技术指标和A股市场特点进行分析给出具体数据支持。
"""
logger.debug(f"生成的AI分析提示词: {self._truncate_json_for_logging(prompt, 100)}...")
# 检查API配置
if not self.API_URL:
error_msg = "API URL未配置无法进行AI分析"
logger.error(f"[API配置错误] {error_msg}")
return error_msg if not stream else (yield json.dumps({"error": error_msg}))
if not self.API_KEY:
error_msg = "API Key未配置无法进行AI分析"
logger.error(f"[API配置错误] {error_msg}")
return error_msg if not stream else (yield json.dumps({"error": error_msg}))
# 标准化API URL
api_url = APIUtils.format_api_url(self.API_URL)
logger.debug(f"标准化后的API URL: {api_url}")
# 构建请求头和请求体
headers = {
"Authorization": f"Bearer {self.API_KEY}",
"Content-Type": "application/json"
}
payload = {
"model": self.API_MODEL,
"messages": [{"role": "user", "content": prompt}]
}
# 流式处理设置
if stream:
logger.debug(f"配置流式参数使用API URL: {api_url}")
payload["stream"] = True # 明确设置stream参数为True
try:
logger.debug(f"发起流式API请求: {api_url}")
logger.debug(f"请求载荷: {self._truncate_json_for_logging(payload)}")
response = requests.post(
api_url,
headers=headers,
json=payload,
timeout=self.API_TIMEOUT, # 增加超时时间
stream=True
)
logger.debug(f"API流式响应状态码: {response.status_code}")
if response.status_code == 200:
logger.info(f"成功获取API流式响应开始处理")
yield from self._process_ai_stream(response, stock_code)
else:
try:
error_response = response.json()
error_text = self._truncate_json_for_logging(error_response)
except:
error_text = response.text[:500] if response.text else "无响应内容"
error_msg = f"API请求失败: 状态码 {response.status_code}, 响应: {error_text}"
logger.error(f"[API请求失败] {error_msg}")
yield json.dumps({"stock_code": stock_code, "error": error_msg})
except Exception as e:
error_msg = f"流式API请求异常: {str(e)}"
logger.error(f"[流式API异常] {error_msg}")
logger.exception(e)
yield json.dumps({"stock_code": stock_code, "error": error_msg})
else:
# 非流式处理
logger.debug(f"发起非流式API请求: {api_url}")
try:
response = requests.post(
api_url,
headers=headers,
json=payload,
timeout=self.API_TIMEOUT
)
logger.debug(f"API非流式响应状态码: {response.status_code}")
if response.status_code == 200:
api_response = response.json()
content = api_response['choices'][0]['message']['content']
logger.info(f"成功获取AI分析结果长度: {len(content)}")
logger.debug(f"AI分析结果前100字符: {content[:100]}...")
return content
else:
try:
error_response = response.json()
error_text = self._truncate_json_for_logging(error_response)
except:
error_text = response.text[:500] if response.text else "无响应内容"
error_msg = f"API请求失败: 状态码 {response.status_code}, 响应: {error_text}"
logger.error(f"[API请求失败] {error_msg}")
return error_msg
except Exception as e:
error_msg = f"非流式API请求异常: {str(e)}"
logger.error(f"[非流式API异常] {error_msg}")
logger.exception(e)
return error_msg
except Exception as e:
error_msg = f"AI 分析过程中发生错误: {str(e)}"
logger.error(f"[AI分析异常] {error_msg}")
logger.exception(e)
if stream:
logger.debug("在流式模式下返回异常信息")
error_json = json.dumps({"stock_code": stock_code, "error": error_msg})
logger.info(f"流式异常输出: {error_json}")
yield error_json
else:
return error_msg
def _truncate_json_for_logging(self, json_obj, max_length=500):
"""截断JSON对象用于日志记录避免日志过大
Args:
json_obj: 要截断的JSON对象
max_length: 最大字符长度默认500
Returns:
str: 截断后的JSON字符串
"""
json_str = json.dumps(json_obj, ensure_ascii=False)
if len(json_str) <= max_length:
return json_str
return json_str[:max_length] + f"... [截断,总长度: {len(json_str)}字符]"
def _process_ai_stream(self, response, stock_code) -> Generator[str, None, None]:
"""处理AI流式响应"""
logger.info(f"开始处理股票 {stock_code} 的AI流式响应\n")
buffer = ""
chunk_count = 0
try:
for line in response.iter_lines():
if line:
line = line.decode('utf-8')
# 跳过保持连接的空行
if line.strip() == '':
logger.debug("跳过空行")
continue
# 数据行通常以"data: "开头
if line.startswith('data: '):
data_content = line[6:] # 移除 "data: " 前缀
# 检查是否为流的结束
if data_content.strip() == '[DONE]':
logger.debug("收到流结束标记 [DONE]")
break
try:
json_data = json.loads(data_content)
# 检查 choices 列表是否为空
if 'choices' in json_data and json_data['choices']:
delta = json_data['choices'][0].get('delta', {})
content = delta.get('content', '')
if content:
chunk_count += 1
buffer += content
# 创建包含AI分析片段的JSON
chunk_json = json.dumps({
"stock_code": stock_code,
"ai_analysis_chunk": content
})
yield chunk_json
except json.JSONDecodeError as e:
logger.error(f"[JSON解析错误] {str(e)}, 行内容: {data_content}")
# 忽略无法解析的JSON
pass
else:
logger.warning(f"收到非'data:'开头的行: {line}")
logger.info(f"AI流式处理完成共收到 {chunk_count} 个内容片段,总长度: {len(buffer)}")
# 如果buffer不为空最后一次发送完整内容
if buffer and not buffer.endswith('\n'):
logger.debug("发送换行符")
yield json.dumps({"stock_code": stock_code, "ai_analysis_chunk": "\n"})
except Exception as e:
error_msg = f"处理AI流式响应时出错: {str(e)}"
logger.error(f"[流式响应异常] {error_msg}")
logger.exception(e)
yield json.dumps({"stock_code": stock_code, "error": error_msg})
def get_recommendation(self, score):
"""根据得分给出建议"""
logger.debug(f"根据评分 {score} 生成投资建议")
if score >= 80:
return '强烈推荐买入'
elif score >= 60:
return '建议买入'
elif score >= 40:
return '观望'
elif score >= 20:
return '建议卖出'
else:
return '强烈建议卖出'
def analyze_stock(self, stock_code, market_type='A', stream=False):
"""分析单只"""
logger.info(f"开始分析 {stock_code}, 市场类型: {market_type}, 流式模式: {stream}")
try:
# 获取股票数据
try:
df = self.get_stock_data(stock_code, market_type)
except Exception as e:
# 捕获股票数据获取异常
error_msg = str(e)
logger.error(f"[数据获取异常] {error_msg}")
# 格式化错误响应
error_response = {
'stock_code': stock_code,
'error': error_msg,
'status': 'error',
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
if stream:
return (yield json.dumps(error_response))
else:
return error_response
# 检查数据是否为空
if df.empty:
error_msg = f" {stock_code} 数据为空"
logger.error(f"[空数据] {error_msg}")
# 格式化错误响应
error_response = {
'stock_code': stock_code,
'error': error_msg,
'status': 'error',
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
if stream:
return (yield json.dumps(error_response))
else:
return error_response
# 计算技术指标
logger.debug(f"计算 {stock_code} 技术指标")
df = self.calculate_indicators(df)
# 评分系统
logger.debug(f"计算 {stock_code} 评分")
score = self.calculate_score(df)
logger.info(f"{stock_code} 评分结果: {score}")
# 获取最新数据
latest = df.iloc[-1]
prev = df.iloc[-2]
# 生成报告
report = {
'stock_code': stock_code,
'market_type': market_type, # 添加市场类型
'analysis_date': datetime.now().strftime('%Y-%m-%d'),
'score': score,
'price': latest['close'],
'price_change': (latest['close'] - prev['close']) / prev['close'] * 100,
'ma_trend': 'UP' if latest['MA5'] > latest['MA20'] else 'DOWN',
'rsi': latest['RSI'] if not pd.isna(latest['RSI']) else None,
'macd_signal': 'BUY' if latest['MACD'] > latest['Signal'] else 'SELL',
'volume_status': 'HIGH' if latest['Volume_Ratio'] > 1.5 else 'NORMAL',
'recommendation': self.get_recommendation(score)
}
logger.debug(f"生成 {stock_code} 基础报告: {self._truncate_json_for_logging(report, 100)}...")
if stream:
logger.info(f"以流式模式返回 {stock_code} 分析结果")
# 先返回基本报告结构
base_report = dict(report)
base_report['ai_analysis'] = ''
base_report_json = json.dumps(base_report)
logger.debug(f"基础报告JSON: {self._truncate_json_for_logging(base_report_json, 100)}...")
logger.info(f"发送基础报告: {base_report_json}")
yield base_report_json
# 然后流式返回AI分析部分
logger.debug(f"开始获取 {stock_code} 的流式AI分析")
ai_chunks_count = 0
for ai_chunk in self.get_ai_analysis(df, stock_code, market_type, stream=True):
ai_chunks_count += 1
yield ai_chunk
logger.info(f" {stock_code} 流式AI分析完成共发送 {ai_chunks_count} 个块")
else:
logger.info(f"以非流式模式返回 {stock_code} 分析结果")
logger.debug(f"开始获取 {stock_code} 的AI分析")
report['ai_analysis'] = self.get_ai_analysis(df, stock_code, market_type)
logger.debug(f"AI分析结果长度: {len(report['ai_analysis'])}")
return report
except Exception as e:
error_msg = f"分析 {stock_code} 时出错: {str(e)}\n"
logger.error(f"[分析异常] {error_msg}")
logger.exception(e)
# 格式化错误响应
error_response = {
'stock_code': stock_code,
'error': error_msg,
'status': 'error',
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
if stream:
return (yield json.dumps(error_response))
else:
return error_response
def scan_stocks(self, stock_codes, market_type='A', min_score=60, stream=False):
"""扫描多只"""
logger.info(f"开始扫描 {len(stock_codes)} 只, 市场类型: {market_type}, 最低评分: {min_score}, 流式模式: {stream}")
if not stream:
# 非流式模式
recommended_stocks = []
stock_count = 0
error_count = 0
for stock_code in stock_codes:
stock_count += 1
logger.info(f"扫描进度: {stock_count}/{len(stock_codes)}, 当前: {stock_code}")
try:
logger.debug(f"分析: {stock_code}")
report = self.analyze_stock(stock_code, market_type)
# 检查是否有错误
if isinstance(report, dict) and 'error' in report:
error_count += 1
logger.warning(f"[扫描错误] {stock_code}: {report['error']}")
continue
# 检查评分是否达到最低要求
if report['score'] >= min_score:
logger.info(f" {stock_code} 评分 {report['score']} >= {min_score},添加到推荐列表")
recommended_stocks.append(report)
else:
logger.debug(f" {stock_code} 评分 {report['score']} < {min_score},不添加到推荐列表")
except Exception as e:
error_count += 1
error_msg = f"分析 {stock_code} 时出错: {str(e)}"
logger.error(f"[扫描异常] {error_msg}")
logger.exception(e)
# 添加错误信息到推荐列表,确保前端能看到错误
error_response = {
'stock_code': stock_code,
'error': error_msg,
'status': 'error',
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
recommended_stocks.append(error_response)
continue
logger.info(f"扫描完成,共 {stock_count} 只,{error_count} 只出错,{len(recommended_stocks)} 只推荐")
return recommended_stocks
else:
# 流式模式
stock_count = 0
error_count = 0
for stock_code in stock_codes:
stock_count += 1
logger.info(f"流式扫描进度: {stock_count}/{len(stock_codes)}, 当前: {stock_code}")
try:
chunk_count = 0
for chunk in self.analyze_stock(stock_code, market_type, stream=True):
chunk_count += 1
# 检查是否有错误信息
try:
chunk_data = json.loads(chunk)
if 'error' in chunk_data:
error_count += 1
logger.warning(f"[流式扫描错误] {stock_code}: {chunk_data['error']}")
except:
pass
yield chunk
logger.debug(f" {stock_code} 流式分析完成,共 {chunk_count} 个块")
except Exception as e:
error_count += 1
error_msg = f"分析 {stock_code} 时出错: {str(e)}"
logger.error(f"[流式扫描异常] {error_msg}")
logger.exception(e)
# 格式化错误响应
error_response = {
'stock_code': stock_code,
'error': error_msg,
'status': 'error',
'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
}
error_json = json.dumps(error_response)
logger.info(f"流式错误输出: {error_json}")
yield error_json
logger.info(f"流式扫描完成,共处理 {stock_count} {error_count} 只出错")

View File

@@ -1,53 +0,0 @@
import akshare as ak
import pandas as pd
class USStockService:
def search_us_stocks(self, keyword):
"""
搜索美股代码
:param keyword: 搜索关键词
:return: 匹配的股票列表
"""
try:
# 获取美股数据
df = ak.stock_us_spot_em()
# 转换列名
df = df.rename(columns={
"序号": "index",
"名称": "name",
"最新价": "price",
"涨跌额": "price_change",
"涨跌幅": "price_change_percent",
"开盘价": "open",
"最高价": "high",
"最低价": "low",
"昨收价": "pre_close",
"总市值": "market_value",
"市盈率": "pe_ratio",
"成交量": "volume",
"成交额": "turnover",
"振幅": "amplitude",
"换手率": "turnover_rate",
"代码": "symbol"
})
# 模糊匹配搜索
mask = df['name'].str.contains(keyword, case=False, na=False)
results = df[mask]
# 格式化返回结果并处理 NaN 值
formatted_results = []
for _, row in results.iterrows():
formatted_results.append({
'name': row['name'] if pd.notna(row['name']) else '',
'symbol': str(row['symbol']) if pd.notna(row['symbol']) else '',
'price': float(row['price']) if pd.notna(row['price']) else 0.0,
'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0
})
return formatted_results
except Exception as e:
raise Exception(f"搜索美股代码失败: {str(e)}")

View File

@@ -1,43 +0,0 @@
#!/usr/bin/env python
# -*- encoding: utf-8 -*-
from typing import Optional, Any
from fastapi import Response
from pydantic import BaseModel
class ResponseModel(BaseModel):
"""
统一返回模型
"""
code: int = 200
msg: str = "Success"
data: Optional[Any] = None
class ApiResponse:
@staticmethod
def __response(code: int, msg: str, data: Optional[Any] = None) -> ResponseModel:
return ResponseModel(code=code, msg=msg, data=data)
@classmethod
def success(cls, *, code: int = 200, msg: str = 'Success', data: Optional[Any] = None) -> Response:
response_model = cls.__response(code=code, msg=msg, data=data)
return cls(content=response_model.model_dump())
@classmethod
def fail(cls, *, code: int = 400, msg: str = 'Bad Request', data: Optional[Any] = None) -> Response:
response_model = cls.__response(code=code, msg=msg, data=data)
return cls(content=response_model.model_dump())
response_api = ApiResponse()
""" 示例
@app.get("/example-success")
async def example_success():
return response_api.success(data={"key": "value"})
@app.get("/example-fail")
async def example_fail():
return response_api.fail(msg="Something went wrong", data={"error": "details"})
"""

View File

@@ -2,28 +2,40 @@ from fastapi import FastAPI, Request, Response, Depends, HTTPException, Backgrou
from fastapi.responses import JSONResponse, StreamingResponse, FileResponse, RedirectResponse from fastapi.responses import JSONResponse, StreamingResponse, FileResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any, Generator from typing import List, Optional, Dict, Any, Generator
from services.stock_analyzer_service import StockAnalyzerService from services.stock_analyzer_service import StockAnalyzerService
# 导入新的异步服务
from services.us_stock_service_async import USStockServiceAsync from services.us_stock_service_async import USStockServiceAsync
from services.fund_service_async import FundServiceAsync from services.fund_service_async import FundServiceAsync
import asyncio
import threading
import os import os
import traceback
import httpx import httpx
from logger import get_logger from logger import get_logger
from utils.api_utils import APIUtils from utils.api_utils import APIUtils
# 加载环境变量 from dotenv import load_dotenv, dotenv_values
from dotenv import load_dotenv
import uvicorn import uvicorn
import json
import secrets
from datetime import datetime, timedelta
from jose import JWTError, jwt
load_dotenv() load_dotenv()
# 获取日志器 # 获取日志器
logger = get_logger() logger = get_logger()
# JWT相关配置
SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_hex(32))
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 10080 # Token过期时间一周
LOGIN_PASSWORD = os.getenv("LOGIN_PASSWORD", "")
print(LOGIN_PASSWORD)
# 是否需要登录
REQUIRE_LOGIN = bool(LOGIN_PASSWORD.strip())
app = FastAPI( app = FastAPI(
title="Stock Scanner API", title="Stock Scanner API",
description="异步股票分析API", description="异步股票分析API",
@@ -42,7 +54,7 @@ app.add_middleware(
# 设置静态文件 # 设置静态文件
frontend_dist = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'frontend', 'dist') frontend_dist = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'frontend', 'dist')
if os.path.exists(frontend_dist): if os.path.exists(frontend_dist):
app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="frontend") app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets")
# 初始化异步服务 # 初始化异步服务
# StockAnalyzerService 不需要全局初始化,在 /analyze 接口中按需创建 # StockAnalyzerService 不需要全局初始化,在 /analyze 接口中按需创建
@@ -64,35 +76,121 @@ class TestAPIRequest(BaseModel):
api_model: Optional[str] = None api_model: Optional[str] = None
api_timeout: Optional[int] = 10 api_timeout: Optional[int] = 10
@app.get("/") class LoginRequest(BaseModel):
async def index(request: Request): password: str
# 检查是否使用前端构建版本
if os.path.exists(frontend_dist):
index_file = os.path.join(frontend_dist, 'index.html')
return FileResponse(index_file)
else:
# 不再使用模板渲染而是重定向到API文档页面
logger.warning("前端构建目录不存在重定向到API文档页面")
return RedirectResponse(url="/docs")
class Token(BaseModel):
access_token: str
token_type: str
# 自定义依赖项在REQUIRE_LOGIN=False时不要求token
class OptionalOAuth2PasswordBearer(OAuth2PasswordBearer):
async def __call__(self, request: Request) -> Optional[str]:
if not REQUIRE_LOGIN:
return None
try:
return await super().__call__(request)
except HTTPException:
if not REQUIRE_LOGIN:
return None
raise
# 使用自定义的依赖项
optional_oauth2_scheme = OptionalOAuth2PasswordBearer(tokenUrl="login")
# 创建访问令牌
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
# 验证令牌
async def verify_token(token: Optional[str] = Depends(optional_oauth2_scheme)):
# 如果未设置密码,则不需要验证
if not REQUIRE_LOGIN:
return "guest"
# 如果没有token且不需要登录返回guest
if token is None and not REQUIRE_LOGIN:
return "guest"
credentials_exception = HTTPException(
status_code=401,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
# 如果需要登录但没有token抛出异常
if token is None:
raise credentials_exception
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
return username
except JWTError:
raise credentials_exception
# 用户登录接口
@app.post("/login")
async def login(request: LoginRequest):
"""用户登录接口"""
# 如果未设置密码,表示不需要登录
if not REQUIRE_LOGIN:
access_token = create_access_token(data={"sub": "guest"})
return {"access_token": access_token, "token_type": "bearer"}
if request.password != LOGIN_PASSWORD:
logger.warning("登录失败:密码错误")
raise HTTPException(status_code=401, detail="密码错误")
# 创建访问令牌
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": "user"}, expires_delta=access_token_expires
)
logger.info("用户登录成功")
return {"access_token": access_token, "token_type": "bearer"}
# 检查用户认证状态
@app.get("/check_auth")
async def check_auth(username: str = Depends(verify_token)):
"""检查用户认证状态"""
return {"authenticated": True, "username": username}
# 获取系统配置
@app.get("/config") @app.get("/config")
async def get_config(): async def get_config():
"""返回系统配置信息""" """返回系统配置信息"""
config = { config = {
'announcement': os.getenv('ANNOUNCEMENT_TEXT') or '', 'announcement': os.getenv('ANNOUNCEMENT_TEXT') or '',
'default_api_url': os.getenv('API_URL', ''), 'default_api_url': os.getenv('API_URL', ''),
'default_api_model': os.getenv('API_MODEL', 'gpt-3.5-turbo'), 'default_api_model': os.getenv('API_MODEL', ''),
'default_api_timeout': os.getenv('API_TIMEOUT', '60') 'default_api_timeout': os.getenv('API_TIMEOUT', '60')
} }
return config return config
# AI分析股票
@app.post("/analyze") @app.post("/analyze")
async def analyze(request: AnalyzeRequest): async def analyze(request: AnalyzeRequest, username: str = Depends(verify_token)):
try: try:
logger.info("开始处理分析请求") logger.info("开始处理分析请求")
stock_codes = request.stock_codes stock_codes = request.stock_codes
market_type = request.market_type market_type = request.market_type
# 后端再次去重,确保安全
original_count = len(stock_codes)
stock_codes = list(dict.fromkeys(stock_codes)) # 保持原有顺序的去重方法
if len(stock_codes) < original_count:
logger.info(f"后端去重: 从{original_count}个代码中移除了{original_count - len(stock_codes)}个重复项")
logger.debug(f"接收到分析请求: stock_codes={stock_codes}, market_type={market_type}") logger.debug(f"接收到分析请求: stock_codes={stock_codes}, market_type={market_type}")
# 获取自定义API配置 # 获取自定义API配置
@@ -122,7 +220,8 @@ async def analyze(request: AnalyzeRequest):
stock_code = stock_codes[0].strip() stock_code = stock_codes[0].strip()
logger.info(f"开始单股流式分析: {stock_code}") logger.info(f"开始单股流式分析: {stock_code}")
init_message = f'{{"stream_type": "single", "stock_code": "{stock_code}"}}\n' stock_code_json = json.dumps(stock_code)
init_message = f'{{"stream_type": "single", "stock_code": {stock_code_json}}}\n'
yield init_message yield init_message
logger.debug(f"开始处理股票 {stock_code} 的流式响应") logger.debug(f"开始处理股票 {stock_code} 的流式响应")
@@ -138,7 +237,8 @@ async def analyze(request: AnalyzeRequest):
# 批量分析流式处理 # 批量分析流式处理
logger.info(f"开始批量流式分析: {stock_codes}") logger.info(f"开始批量流式分析: {stock_codes}")
init_message = f'{{"stream_type": "batch", "stock_codes": {stock_codes}}}\n' stock_codes_json = json.dumps(stock_codes)
init_message = f'{{"stream_type": "batch", "stock_codes": {stock_codes_json}}}\n'
yield init_message yield init_message
logger.debug(f"开始处理批量股票的流式响应") logger.debug(f"开始处理批量股票的流式响应")
@@ -165,8 +265,9 @@ async def analyze(request: AnalyzeRequest):
logger.exception(e) logger.exception(e)
raise HTTPException(status_code=500, detail=error_msg) raise HTTPException(status_code=500, detail=error_msg)
# 搜索美股代码
@app.get("/search_us_stocks") @app.get("/search_us_stocks")
async def search_us_stocks(keyword: str = ""): async def search_us_stocks(keyword: str = "", username: str = Depends(verify_token)):
try: try:
if not keyword: if not keyword:
raise HTTPException(status_code=400, detail="请输入搜索关键词") raise HTTPException(status_code=400, detail="请输入搜索关键词")
@@ -179,8 +280,9 @@ async def search_us_stocks(keyword: str = ""):
logger.error(f"搜索美股代码时出错: {str(e)}") logger.error(f"搜索美股代码时出错: {str(e)}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# 搜索基金代码
@app.get("/search_funds") @app.get("/search_funds")
async def search_funds(keyword: str = "", market_type: str = ""): async def search_funds(keyword: str = "", market_type: str = "", username: str = Depends(verify_token)):
try: try:
if not keyword: if not keyword:
raise HTTPException(status_code=400, detail="请输入搜索关键词") raise HTTPException(status_code=400, detail="请输入搜索关键词")
@@ -193,8 +295,39 @@ async def search_funds(keyword: str = "", market_type: str = ""):
logger.error(f"搜索基金代码时出错: {str(e)}") logger.error(f"搜索基金代码时出错: {str(e)}")
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
# 获取美股详情
@app.get("/us_stock_detail/{symbol}")
async def get_us_stock_detail(symbol: str, username: str = Depends(verify_token)):
try:
if not symbol:
raise HTTPException(status_code=400, detail="请提供股票代码")
# 使用异步服务获取详情
detail = await us_stock_service.get_us_stock_detail(symbol)
return detail
except Exception as e:
logger.error(f"获取美股详情时出错: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
# 获取基金详情
@app.get("/fund_detail/{symbol}")
async def get_fund_detail(symbol: str, market_type: str = "ETF", username: str = Depends(verify_token)):
try:
if not symbol:
raise HTTPException(status_code=400, detail="请提供基金代码")
# 使用异步服务获取详情
detail = await fund_service.get_fund_detail(symbol, market_type)
return detail
except Exception as e:
logger.error(f"获取基金详情时出错: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
# 测试API连接
@app.post("/test_api_connection") @app.post("/test_api_connection")
async def test_api_connection(request: TestAPIRequest): async def test_api_connection(request: TestAPIRequest, username: str = Depends(verify_token)):
"""测试API连接""" """测试API连接"""
try: try:
logger.info("开始测试API连接") logger.info("开始测试API连接")
@@ -226,7 +359,7 @@ async def test_api_connection(request: TestAPIRequest):
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
json={ json={
"model": api_model or "gpt-3.5-turbo", "model": api_model or "",
"messages": [ "messages": [
{"role": "user", "content": "Hello, this is a test message. Please respond with 'API connection successful'."} {"role": "user", "content": "Hello, this is a test message. Please respond with 'API connection successful'."}
], ],
@@ -261,36 +394,34 @@ async def test_api_connection(request: TestAPIRequest):
content={"success": False, "message": f"API 测试连接时出错: {str(e)}"} content={"success": False, "message": f"API 测试连接时出错: {str(e)}"}
) )
# 新增 API 端点:获取美股详情 # 检查是否需要登录
@app.get("/us_stock_detail/{symbol}") @app.get("/need_login")
async def get_us_stock_detail(symbol: str): async def need_login():
try: """检查是否需要登录"""
if not symbol: return {"require_login": REQUIRE_LOGIN}
raise HTTPException(status_code=400, detail="请提供股票代码")
# 使用异步服务获取详情 # 前端路由处理必须放在所有API路由之后
detail = await us_stock_service.get_us_stock_detail(symbol) @app.get("/{full_path:path}")
return detail async def serve_frontend(full_path: str, request: Request):
"""处理所有前端路由请求返回index.html"""
# 排除API路径和静态资源
if full_path.startswith(("api/", "assets/", "docs", "openapi.json")) or \
full_path in ["check_auth", "config", "analyze",
"search_us_stocks", "search_funds",
"test_api_connection", "us_stock_detail",
"fund_detail"]:
# 对于API路径让FastAPI继续处理
raise HTTPException(status_code=404, detail="API路径不存在")
except Exception as e: # 检查是否使用前端构建版本
logger.error(f"获取美股详情时出错: {str(e)}") if os.path.exists(frontend_dist):
raise HTTPException(status_code=500, detail=str(e)) index_file = os.path.join(frontend_dist, 'index.html')
return FileResponse(index_file)
# 新增 API 端点:获取基金详情 else:
@app.get("/fund_detail/{symbol}") # 不再使用模板渲染而是重定向到API文档页面
async def get_fund_detail(symbol: str, market_type: str = "ETF"): logger.warning("前端构建目录不存在重定向到API文档页面")
try: return RedirectResponse(url="/docs")
if not symbol:
raise HTTPException(status_code=400, detail="请提供基金代码")
# 使用异步服务获取详情
detail = await fund_service.get_fund_detail(symbol, market_type)
return detail
except Exception as e:
logger.error(f"获取基金详情时出错: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
if __name__ == '__main__': if __name__ == '__main__':
logger.info("股票分析系统启动") logger.info("股票AI分析系统启动")
uvicorn.run("web_server:app", host="127.0.0.1", port=8888, reload=True) uvicorn.run("web_server:app", host="127.0.0.1", port=8888, reload=True)