feat: 优化前端显示&修复若干bug
This commit is contained in:
@@ -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 为空时,表示不需要登录,否则需要经过登录接口验证
|
||||||
|
|
||||||
注意⚠️: 环境变量名变更,更新版本后需要调整!!!
|
注意⚠️: 环境变量名变更,更新版本后需要调整!!!
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
24
frontend/package-lock.json
generated
24
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 以启用暗色模式
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
536
frontend/src/components/LoginPage.vue
Normal file
536
frontend/src/components/LoginPage.vue
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
89
frontend/src/router/index.ts
Normal file
89
frontend/src/router/index.ts
Normal 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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
172
frontend/src/utils/stockValidator.ts
Normal file
172
frontend/src/utils/stockValidator.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
1485
frontend/yarn.lock
1485
frontend/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -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)}")
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 chunk_str == "[DONE]":
|
if not line:
|
||||||
logger.debug("收到流结束标记 [DONE]")
|
continue
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 解析数据块
|
|
||||||
chunk_data = json.loads(chunk_str)
|
|
||||||
delta = chunk_data.get("choices", [{}])[0].get("delta", {})
|
|
||||||
content = delta.get("content", "")
|
|
||||||
|
|
||||||
if content:
|
|
||||||
chunk_count += 1
|
|
||||||
buffer += content
|
|
||||||
collected_messages.append(content)
|
|
||||||
|
|
||||||
# 直接发送每个内容片段,不累积
|
# 处理以data:开头的行
|
||||||
yield json.dumps({
|
if line.startswith("data: "):
|
||||||
"stock_code": stock_code,
|
line = line[6:] # 去除"data: "前缀
|
||||||
"ai_analysis_chunk": content,
|
|
||||||
"status": "analyzing"
|
if line == "[DONE]":
|
||||||
})
|
logger.debug("收到流结束标记 [DONE]")
|
||||||
except json.JSONDecodeError:
|
continue
|
||||||
# 忽略无法解析的块
|
|
||||||
logger.error(f"JSON解析错误,块内容: {chunk_str[:100]}...")
|
try:
|
||||||
continue
|
# 处理特殊错误情况
|
||||||
|
if "error" in line.lower():
|
||||||
|
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({
|
||||||
|
"stock_code": stock_code,
|
||||||
|
"error": f"流式响应错误: {error_msg}",
|
||||||
|
"status": "error"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 尝试解析JSON
|
||||||
|
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)}")
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +86,72 @@ 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"
|
||||||
# 过滤日期
|
)
|
||||||
df = df[(df.index >= start_date) & (df.index <= end_date)]
|
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')
|
||||||
|
|
||||||
|
# 过滤日期
|
||||||
|
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}")
|
||||||
df = ak.fund_etf_hist_sina(
|
df = ak.fund_etf_hist_sina(
|
||||||
@@ -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',
|
||||||
@@ -181,4 +257,4 @@ class StockDataProvider:
|
|||||||
results = await asyncio.gather(*tasks)
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
# 构建结果字典,过滤掉失败的请求
|
# 构建结果字典,过滤掉失败的请求
|
||||||
return {code: df for code, df in results if df is not None}
|
return {code: df for code, df in results if df is not None}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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} 只出错")
|
|
||||||
@@ -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)}")
|
|
||||||
@@ -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"})
|
|
||||||
"""
|
|
||||||
239
web_server.py
239
web_server.py
@@ -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="请提供股票代码")
|
|
||||||
|
|
||||||
# 使用异步服务获取详情
|
|
||||||
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))
|
|
||||||
|
|
||||||
# 新增 API 端点:获取基金详情
|
# 前端路由处理,必须放在所有API路由之后
|
||||||
@app.get("/fund_detail/{symbol}")
|
@app.get("/{full_path:path}")
|
||||||
async def get_fund_detail(symbol: str, market_type: str = "ETF"):
|
async def serve_frontend(full_path: str, request: Request):
|
||||||
try:
|
"""处理所有前端路由请求,返回index.html"""
|
||||||
if not symbol:
|
# 排除API路径和静态资源
|
||||||
raise HTTPException(status_code=400, detail="请提供基金代码")
|
if full_path.startswith(("api/", "assets/", "docs", "openapi.json")) or \
|
||||||
|
full_path in ["check_auth", "config", "analyze",
|
||||||
# 使用异步服务获取详情
|
"search_us_stocks", "search_funds",
|
||||||
detail = await fund_service.get_fund_detail(symbol, market_type)
|
"test_api_connection", "us_stock_detail",
|
||||||
return detail
|
"fund_detail"]:
|
||||||
|
# 对于API路径,让FastAPI继续处理
|
||||||
except Exception as e:
|
raise HTTPException(status_code=404, detail="API路径不存在")
|
||||||
logger.error(f"获取基金详情时出错: {str(e)}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
# 检查是否使用前端构建版本
|
||||||
|
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")
|
||||||
|
|
||||||
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)
|
||||||
Reference in New Issue
Block a user