Merge pull request #5 from Cassianvale/dev-1

vue3重构版本
This commit is contained in:
Cassianvale
2025-03-07 16:44:03 +08:00
committed by GitHub
50 changed files with 11528 additions and 2277 deletions

1
.env
View File

@@ -3,3 +3,4 @@ API_URL=
API_MODEL= API_MODEL=
ANNOUNCEMENT_TEXT= ANNOUNCEMENT_TEXT=
API_TIMEOUT=60 API_TIMEOUT=60
LOGIN_PASSWORD=

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# API配置
API_KEY=
API_URL=
API_MODEL=
API_TIMEOUT=60
# 公告文本
ANNOUNCEMENT_TEXT=欢迎使用!
# 登录配置(为空时不需要登录,否则需要经过登录接口验证)
LOGIN_PASSWORD=

View File

@@ -1,38 +1,190 @@
name: Docker Build and Push name: Docker Build and Deploy
on: on:
push: push:
branches: [ main ] # 只在 main 分支推送时触发 branches: [ main ]
paths-ignore:
- '**.md'
- 'docs/**'
workflow_dispatch:
inputs:
deploy:
description: '是否部署'
type: boolean
default: true
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
TIME: ${{ github.run_number }}-${{ github.run_attempt }}
jobs: jobs:
build: prepare:
runs-on: ubuntu-latest runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- name: Generate version
id: version
run: |
echo "version=$(date +'%Y%m%d%H%M')" >> $GITHUB_OUTPUT
build:
needs: prepare
runs-on: ubuntu-latest
strategy:
matrix:
service: [backend, frontend]
include:
- service: backend
context: .
- service: frontend
context: ./frontend
# 允许其中一个任务失败时,其他任务仍继续执行
fail-fast: false
steps: steps:
- uses: actions/checkout@v2 - name: Checkout code
uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Get current time
id: time
run: echo "TIME=$(date +'%Y%m%d%H%M')" >> $GITHUB_ENV
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v1 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1 uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ secrets.DOCKERHUB_USERNAME }}/stock-scanner-${{ matrix.service }}
tags: |
type=raw,value=latest
type=raw,value=${{ needs.prepare.outputs.version }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v2 uses: docker/build-push-action@v5
with: with:
context: . context: ${{ matrix.context }}
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: ${{ steps.meta.outputs.tags }}
${{ secrets.DOCKERHUB_USERNAME }}/stock-scanner:latest cache-from: type=gha,scope=${{ matrix.service }}
${{ secrets.DOCKERHUB_USERNAME }}/stock-scanner:${{ env.TIME }} cache-to: type=gha,scope=${{ matrix.service }},mode=max
deploy:
needs: [prepare, build]
runs-on: ubuntu-latest
if: |
success() &&
(github.ref == 'refs/heads/main' && github.event_name == 'push') ||
(github.event_name == 'workflow_dispatch' && inputs.deploy)
steps:
- name: Checkout code for prod compose file
uses: actions/checkout@v4
with:
sparse-checkout: |
docker-compose.prod.yml
- name: Create .env file for deployment
run: |
cat > .env << EOL
DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }}
TAG=${{ needs.prepare.outputs.version }}
API_KEY=${{ secrets.API_KEY }}
API_URL=${{ secrets.API_URL }}
API_MODEL=${{ secrets.API_MODEL }}
API_TIMEOUT=${{ secrets.API_TIMEOUT }}
LOGIN_PASSWORD=${{ secrets.LOGIN_PASSWORD }}
ANNOUNCEMENT_TEXT=${{ secrets.ANNOUNCEMENT_TEXT }}
EOL
- name: Deploy to server
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script_stop: true # 遇到错误时停止执行
envs: DEPLOY_PATH
script: |
# 创建备份目录(如果不存在)
mkdir -p ${DEPLOY_PATH}/backups
# 如果存在旧容器,备份当前的配置和数据
if [ -f ${DEPLOY_PATH}/docker-compose.prod.yml ]; then
cp ${DEPLOY_PATH}/docker-compose.prod.yml ${DEPLOY_PATH}/backups/docker-compose.prod.$(date +%Y%m%d%H%M%S).yml
if [ -f ${DEPLOY_PATH}/.env ]; then
cp ${DEPLOY_PATH}/.env ${DEPLOY_PATH}/backups/.env.$(date +%Y%m%d%H%M%S)
fi
fi
- name: Copy files to server
uses: appleboy/scp-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
source: "docker-compose.prod.yml,.env"
target: ${{ secrets.DEPLOY_PATH }}
overwrite: true
- name: Start services
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USERNAME }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script_stop: true
script: |
cd ${{ secrets.DEPLOY_PATH }}
# 拉取最新镜像并启动服务
docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
# 等待服务启动完成
echo "等待服务启动..."
sleep 10
# 验证服务是否正常运行
if ! curl -s http://localhost:80 > /dev/null; then
echo "前端服务未正常运行!"
exit 1
fi
if ! curl -s http://localhost:8888/config > /dev/null; then
echo "后端服务未正常运行!"
exit 1
fi
# 清理未使用的镜像和容器
docker system prune -af --volumes
echo "部署完成并验证成功!"
notify:
needs: [prepare, build, deploy]
runs-on: ubuntu-latest
if: always()
steps:
- name: Notify deployment status
uses: rtCamp/action-slack-notify@v2
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
SLACK_CHANNEL: deployments
SLACK_COLOR: ${{ needs.deploy.result == 'success' && 'good' || needs.deploy.result == 'skipped' && 'warning' || 'danger' }}
SLACK_TITLE: Stock Scanner Deployment Status
SLACK_MESSAGE: |
Build: ${{ needs.build.result == 'success' && '✅' || '❌' }}
Deploy: ${{ needs.deploy.result == 'success' && '✅' || needs.deploy.result == 'skipped' && '⏭️' || '❌' }}
Version: ${{ needs.prepare.outputs.version }}
SLACK_FOOTER: GitHub Actions

17
.gitignore vendored
View File

@@ -21,6 +21,23 @@ build_upload.log
*.spec *.spec
*.zip *.zip
# frontend
node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
dist/
dist-ssr
*.local
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View File

@@ -1,26 +1,52 @@
# 使用 Python 3.10 作为基础镜像 # 使用 Python 3.10 作为基础镜像
FROM python:3.10-slim as builder
# 设置工作目录
WORKDIR /app
# 安装系统依赖和构建依赖
RUN apt-get update && apt-get install -y \
libgl1-mesa-glx \
ca-certificates \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# 复制项目文件
COPY requirements.txt /app/
# 安装 Python 依赖
RUN pip install --no-cache-dir --user -r requirements.txt
# 第二阶段:运行阶段
FROM python:3.10-slim FROM python:3.10-slim
# 设置工作目录 # 设置工作目录
WORKDIR /app WORKDIR /app
# 安装系统依赖 # 安装运行时依赖
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
libgl1-mesa-glx \ libgl1-mesa-glx \
ca-certificates \ ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# 复制项目文件 # 从构建阶段复制Python依赖
COPY . /app/ COPY --from=builder /root/.local /root/.local
# 安装 Python 依赖 # 确保脚本路径在PATH中
RUN pip install --no-cache-dir -r requirements.txt ENV PATH=/root/.local/bin:$PATH
# 设置环境变量 # 设置环境变量
ENV PYTHONPATH=/app ENV PYTHONPATH=/app
# 暴露端口(如果需要) # 复制应用代码
COPY . /app/
# 暴露端口
EXPOSE 8888 EXPOSE 8888
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8888/config || exit 1
# 启动命令 # 启动命令
CMD ["python", "web_server.py"] CMD ["python", "web_server.py"]

View File

@@ -9,6 +9,8 @@
3. 完善Dockerfile、GitHub Actions 支持docker一键部署使用。 3. 完善Dockerfile、GitHub Actions 支持docker一键部署使用。
4. 支持x86_64 和 ARM64架构镜像 4. 支持x86_64 和 ARM64架构镜像
5. 支持流式输出支持前端传入Key(仅作为本地用户使用,日志等内容不会输出) 感谢@Cassianvale 5. 支持流式输出支持前端传入Key(仅作为本地用户使用,日志等内容不会输出) 感谢@Cassianvale
6. 重构为Vue3+Vite+TS+Naive UI支持响应式布局
7. 支持GitHub Actions 一键部署
## docker一键部署 ## docker一键部署
``` ```
@@ -19,9 +21,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 为空时,表示不需要登录,否则需要经过登录接口验证
注意⚠️: 环境变量名变更,更新版本后需要调整!!! 注意⚠️: 环境变量名变更,更新版本后需要调整!!!
@@ -44,6 +48,18 @@ API_URL 处理逻辑说明:
``` ```
默认8888端口部署完成后访问 http://127.0.0.1:8888 即可使用。 默认8888端口部署完成后访问 http://127.0.0.1:8888 即可使用。
## Github Actions 部署
| 环境变量 | 说明 |
| --- | --- |
| DOCKERHUB_USERNAME | Docker Hub用户名 |
| DOCKERHUB_TOKEN | Docker Hub访问令牌 |
| SERVER_HOST | 部署服务器地址 |
| SERVER_USERNAME | 服务器用户名 |
| SSH_PRIVATE_KEY | SSH私钥 |
| DEPLOY_PATH | 部署路径 |
| SLACK_WEBHOOK | Slack通知Webhook可选 |
## 注意事项 (Notes) ## 注意事项 (Notes)
- 股票分析仅供参考,不构成投资建议 - 股票分析仅供参考,不构成投资建议

48
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,48 @@
version: '3.8'
services:
backend:
image: ${DOCKERHUB_USERNAME}/stock-scanner-backend:${TAG:-latest}
container_name: stock-scanner-backend
ports:
- "8888:8888"
environment:
- API_KEY=${API_KEY}
- API_URL=${API_URL}
- API_MODEL=${API_MODEL}
- API_TIMEOUT=${API_TIMEOUT}
- LOGIN_PASSWORD=${LOGIN_PASSWORD}
- ANNOUNCEMENT_TEXT=${ANNOUNCEMENT_TEXT}
volumes:
- ./logs:/app/logs
- ./.env:/app/.env
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8888/config"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
- stock-scanner-network
frontend:
image: ${DOCKERHUB_USERNAME}/stock-scanner-frontend:${TAG:-latest}
container_name: stock-scanner-frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
- stock-scanner-network
networks:
stock-scanner-network:
driver: bridge

View File

@@ -1,8 +1,11 @@
version: '3.8' version: '3.8'
services: services:
stock-analyzer: backend:
build: . build:
context: .
dockerfile: Dockerfile
container_name: stock-scanner-backend
ports: ports:
- "8888:8888" - "8888:8888"
environment: environment:
@@ -10,6 +13,39 @@ services:
- API_URL=${API_URL} - API_URL=${API_URL}
- API_MODEL=${API_MODEL} - API_MODEL=${API_MODEL}
- API_TIMEOUT=${API_TIMEOUT} - API_TIMEOUT=${API_TIMEOUT}
- LOGIN_PASSWORD=${LOGIN_PASSWORD}
- ANNOUNCEMENT_TEXT=${ANNOUNCEMENT_TEXT}
volumes: volumes:
- .:/app - ./logs:/app/logs
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8888/config"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
- stock-scanner-network
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: stock-scanner-frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
start_period: 5s
networks:
- stock-scanner-network
networks:
stock-scanner-network:
driver: bridge

36
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# 构建阶段
FROM node:18-alpine as build-stage
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 package-lock.json如果有
COPY package*.json ./
# 安装依赖
RUN npm ci
# 复制项目文件
COPY . .
# 构建应用
RUN npm run build
# 生产阶段
FROM nginx:stable-alpine as production-stage
# 复制自定义nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 从构建阶段复制构建结果到nginx服务目录
COPY --from=build-stage /app/dist /usr/share/nginx/html
# 暴露80端口
EXPOSE 80
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:80 || exit 1
# 设定启动命令
CMD ["nginx", "-g", "daemon off;"]

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>股票AI分析系统</title>
<link rel="icon" href="/favicon.ico">
<meta name="description" content="股票AI分析系统 - 基于Vue 3 + TypeScript + Naive UI">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

85
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,85 @@
server {
listen 80;
server_name localhost;
#access_log /var/log/nginx/host.access.log main;
root /usr/share/nginx/html;
index index.html;
# 缓存静态资源
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# API请求代理到后端服务 - 使用相对路径
location /api/ {
proxy_pass http://backend:8888/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
}
location /login {
proxy_pass http://backend:8888/login;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
location /check_auth {
proxy_pass http://backend:8888/check_auth;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
location /need_login {
proxy_pass http://backend:8888/need_login;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
location /config {
proxy_pass http://backend:8888/config;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
location /analyze {
proxy_pass http://backend:8888/analyze;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
}
# 所有其他路由返回index.htmlSPA应用需要
location / {
try_files $uri $uri/ /index.html;
}
# 错误页面
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

4314
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@types/node": "^22.13.9",
"@vicons/ionicons5": "^0.13.0",
"@vueuse/core": "^12.8.2",
"axios": "^1.8.1",
"marked": "^15.0.7",
"naive-ui": "^2.41.0",
"npm": "^10.8.2",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/marked": "^5.0.2",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.7.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.4"
}
}

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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

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

View File

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

After

Width:  |  Height:  |  Size: 496 B

View File

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

View File

@@ -0,0 +1,642 @@
<template>
<div class="api-config-section">
<n-button
class="toggle-button"
size="small"
@click="toggleConfig"
:quaternary="true"
:type="expanded ? 'primary' : 'default'"
>
<template #icon>
<n-icon :component="expanded ? ChevronUpIcon : ChevronDownIcon" />
</template>
<span class="toggle-text">API配置 {{ expanded ? '收起' : '展开' }}</span>
</n-button>
<n-collapse-transition :show="expanded">
<n-card class="api-config-card" :bordered="false">
<n-alert title="OpenAI API配置" type="info" v-if="isApiInfoVisible" class="api-info-alert">
<template #icon>
<n-icon :component="InformationCircleIcon" />
</template>
<p>您可以配置自己的API也可以使用系统默认配置API密钥仅在您的浏览器中使用不会发送到服务器存储</p>
<div class="alert-actions">
<n-button text @click="isApiInfoVisible = false">
<template #icon>
<n-icon :component="CloseIcon" />
</template>
</n-button>
</div>
</n-alert>
<n-grid :cols="24" :x-gap="16" :y-gap="16">
<n-grid-item :span="24" :lg-span="14">
<n-form-item label="API URL" path="apiUrl">
<n-input
v-model:value="apiConfig.apiUrl"
placeholder="https://api.openai.com/v1/chat/completions"
@update:value="handleConfigChange"
round
>
<template #prefix>
<n-icon :component="GlobeIcon" />
</template>
</n-input>
<template #feedback>
<div class="url-feedback">
<span class="formatted-url">实际请求地址: {{ formattedUrl }}</span>
<div class="url-tips">
<div>提示: URL以/结尾将忽略v1路径</div>
<div>URL以#结尾将使用原始地址</div>
</div>
</div>
</template>
</n-form-item>
</n-grid-item>
<n-grid-item :span="24" :lg-span="10">
<n-form-item label="API Key" path="apiKey">
<n-input
v-model:value="apiConfig.apiKey"
type="password"
placeholder="sk-..."
show-password-on="click"
@update:value="handleConfigChange"
round
>
<template #prefix>
<n-icon :component="KeyIcon" />
</template>
</n-input>
</n-form-item>
</n-grid-item>
<n-grid-item :span="12" :lg-span="12">
<n-form-item label="模型" path="apiModel">
<n-input
v-model:value="apiConfig.apiModel"
placeholder="输入或选择模型名称"
@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-grid-item>
<n-grid-item :span="12" :lg-span="12">
<n-form-item label="超时时间(秒)" path="apiTimeout">
<n-input-number
v-model:value="apiTimeout"
placeholder="60"
:min="1"
:max="300"
@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-grid-item>
</n-grid>
<div class="api-actions">
<div class="api-save-option">
<n-switch
v-model:value="apiConfig.saveApiConfig"
@update:value="handleConfigChange"
/>
<span class="save-label">保存配置到本地</span>
</div>
<div class="api-buttons">
<n-button
type="primary"
:loading="testingConnection"
:disabled="!isConfigValid"
@click="testConnection"
round
>
<template #icon>
<n-icon :component="CheckmarkIcon" />
</template>
测试连接
</n-button>
<n-button @click="resetConfig" round>
<template #icon>
<n-icon :component="RefreshIcon" />
</template>
重置
</n-button>
</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-collapse-transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import {
NButton,
NIcon,
NCard,
NCollapseTransition,
NGrid,
NGridItem,
NFormItem,
NInput,
NInputNumber,
NSwitch,
NAlert,
NDivider,
NDropdown,
NTag,
useMessage
} from 'naive-ui';
import {
ChevronDown as ChevronDownIcon,
ChevronUp as ChevronUpIcon,
InformationCircleOutline as InformationCircleIcon,
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';
import { apiService } from '@/services/api';
import { saveApiConfigToLocalStorage, loadApiConfig } from '@/utils';
import type { ApiConfig } from '@/types';
const props = defineProps<{
defaultApiUrl?: string;
defaultApiModel?: string;
defaultApiTimeout?: string;
}>();
const emit = defineEmits<{
(e: 'update:apiConfig', value: ApiConfig): void;
}>();
const message = useMessage();
const expanded = ref(false);
const testingConnection = ref(false);
const isApiInfoVisible = ref(true);
// 连接状态
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配置
const apiConfig = ref<ApiConfig>({
apiUrl: props.defaultApiUrl || '',
apiKey: '',
apiModel: props.defaultApiModel || '',
apiTimeout: props.defaultApiTimeout || '60',
saveApiConfig: false
});
const apiTimeout = computed({
get: () => parseInt(apiConfig.value.apiTimeout) || 60,
set: (val: number) => {
apiConfig.value.apiTimeout = val.toString();
}
});
const isConfigValid = computed(() => {
return apiConfig.value.apiUrl && apiConfig.value.apiKey;
});
const formattedUrl = computed(() => {
return formatApiUrl(apiConfig.value.apiUrl);
});
function toggleConfig() {
expanded.value = !expanded.value;
}
function handleConfigChange() {
console.log('API配置变更:', apiConfig.value);
// 如果选择了保存配置,则自动保存
if (apiConfig.value.saveApiConfig) {
saveApiConfigToLocalStorage({
apiUrl: apiConfig.value.apiUrl,
apiKey: apiConfig.value.apiKey,
apiModel: apiConfig.value.apiModel,
apiTimeout: apiConfig.value.apiTimeout,
saveApiConfig: true
});
}
// 向父组件发送更新事件
emit('update:apiConfig', { ...apiConfig.value });
}
function handleTimeoutChange(value: number | null) {
if (value !== null) {
apiConfig.value.apiTimeout = value.toString();
handleConfigChange();
}
}
function 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 {
if (!url) return '';
try {
// 使用与后端一致的URL格式化逻辑
if (url.endsWith('/')) {
return `${url}chat/completions`;
} else if (url.endsWith('#')) {
return url.replace('#', '');
} else {
return `${url}/v1/chat/completions`;
}
} catch (e) {
// 如果URL格式错误则返回原始字符串
return url;
}
}
async function testConnection() {
if (!isConfigValid.value) {
message.error('请填写完整的API配置信息');
return;
}
testingConnection.value = true;
connectionStatus.value = null;
try {
const response = await apiService.testApiConnection({
api_url: apiConfig.value.apiUrl,
api_key: apiConfig.value.apiKey,
api_model: apiConfig.value.apiModel,
api_timeout: apiConfig.value.apiTimeout
});
if (response.success) {
message.success('API连接测试成功');
connectionStatus.value = {
type: 'success',
message: '连接成功API配置有效。',
icon: SuccessIcon
};
// 如果选择了保存配置,则保存
if (apiConfig.value.saveApiConfig) {
saveApiConfigToLocalStorage({
apiUrl: apiConfig.value.apiUrl,
apiKey: apiConfig.value.apiKey,
apiModel: apiConfig.value.apiModel,
apiTimeout: apiConfig.value.apiTimeout,
saveApiConfig: true
});
}
} else {
message.error(`API连接测试失败: ${response.message}`);
connectionStatus.value = {
type: 'error',
message: `连接失败: ${response.message}`,
icon: ErrorIcon
};
}
} catch (error: any) {
message.error(`测试连接出错: ${error.message || '未知错误'}`);
connectionStatus.value = {
type: 'error',
message: `连接错误: ${error.message || '未知错误'}`,
icon: ErrorIcon
};
} finally {
testingConnection.value = false;
}
}
function resetConfig() {
apiConfig.value = {
apiUrl: props.defaultApiUrl || '',
apiKey: '',
apiModel: props.defaultApiModel || '',
apiTimeout: props.defaultApiTimeout || '60',
saveApiConfig: false
};
// 清除本地存储
if (window.localStorage) {
localStorage.removeItem('apiConfig');
}
connectionStatus.value = null;
message.success('已重置API配置');
emit('update:apiConfig', { ...apiConfig.value });
}
// 选择模型
function selectModel(key: string) {
console.log('选择模型:', key);
apiConfig.value.apiModel = key;
handleConfigChange();
}
onMounted(() => {
// 加载保存的配置
const savedConfig = loadApiConfig();
if (savedConfig.saveApiConfig) {
apiConfig.value = {
apiUrl: savedConfig.apiUrl || props.defaultApiUrl || '',
apiKey: savedConfig.apiKey || '',
apiModel: savedConfig.apiModel || props.defaultApiModel || '',
apiTimeout: savedConfig.apiTimeout || props.defaultApiTimeout || '60',
saveApiConfig: true
};
// 通知父组件配置已加载
emit('update:apiConfig', { ...apiConfig.value });
}
});
</script>
<style scoped>
.api-config-section {
margin-bottom: 1.5rem;
position: relative;
}
.toggle-button {
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 {
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 {
color: var(--n-text-color-info);
font-size: 0.85rem;
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
.url-tips {
color: var(--n-text-color-info);
font-size: 0.75rem;
opacity: 0.8;
line-height: 1.4;
}
.alert-actions {
margin-top: 0.5rem;
text-align: right;
}
.api-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
flex-wrap: wrap;
gap: 12px;
}
.api-save-option {
display: flex;
align-items: center;
background-color: rgba(0, 0, 0, 0.02);
padding: 6px 12px;
border-radius: 16px;
}
.save-label {
margin-left: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
}
.api-buttons {
display: flex;
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>

View File

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

View File

@@ -0,0 +1,188 @@
<template>
<n-card class="market-time-card">
<n-grid :x-gap="16" :y-gap="16" :cols="gridCols">
<!-- 当前时间 -->
<n-grid-item>
<div class="time-block">
<p class="time-label">当前时间</p>
<p class="current-time">{{ marketInfo.currentTime }}</p>
</div>
</n-grid-item>
<!-- A股状态 -->
<n-grid-item>
<div class="time-block">
<p class="time-label">A股市场</p>
<div class="market-status" :class="marketInfo.cnMarket.isOpen ? 'status-open' : 'status-closed'">
<n-tag v-if="marketInfo.cnMarket.isOpen" type="success" size="medium" round>
<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>
</div>
</n-grid-item>
<!-- 港股状态 -->
<n-grid-item>
<div class="time-block">
<p class="time-label">港股市场</p>
<div class="market-status" :class="marketInfo.hkMarket.isOpen ? 'status-open' : 'status-closed'">
<n-tag v-if="marketInfo.hkMarket.isOpen" type="success" size="medium" round>
<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>
</div>
</n-grid-item>
<!-- 美股状态 -->
<n-grid-item>
<div class="time-block">
<p class="time-label">美股市场</p>
<div class="market-status" :class="marketInfo.usMarket.isOpen ? 'status-open' : 'status-closed'">
<n-tag v-if="marketInfo.usMarket.isOpen" type="success" size="medium" round>
<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>
</div>
</n-grid-item>
</n-grid>
</n-card>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { NCard, NGrid, NGridItem, NTag, NIcon } from 'naive-ui';
import {
PulseOutline as PulseIcon,
TimeOutline as TimeIcon
} from '@vicons/ionicons5';
import { updateMarketTimeInfo } from '@/utils';
import type { MarketTimeInfo } from '@/types';
const props = defineProps({
isMobile: {
type: Boolean,
default: false
}
});
const marketInfo = ref<MarketTimeInfo>({
currentTime: '',
cnMarket: { isOpen: false, nextTime: '' },
hkMarket: { isOpen: false, nextTime: '' },
usMarket: { isOpen: false, nextTime: '' }
});
const gridCols = computed(() => {
return props.isMobile ? 1 : 4;
});
let intervalId: number | null = null;
function updateMarketTime() {
marketInfo.value = updateMarketTimeInfo();
}
onMounted(() => {
updateMarketTime(); // 立即更新一次
intervalId = window.setInterval(updateMarketTime, 1000);
});
onBeforeUnmount(() => {
if (intervalId !== null) {
window.clearInterval(intervalId);
intervalId = null;
}
});
</script>
<style scoped>
.market-time-card {
margin-bottom: 1.5rem;
padding: 0.5rem;
}
.time-block {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 0.5rem;
}
.time-label {
font-size: 1rem;
color: var(--n-text-color-3);
margin-bottom: 0.75rem;
font-weight: 500;
}
.current-time {
font-size: 1.75rem;
font-weight: bold;
color: var(--n-text-color);
}
.market-status {
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
min-height: 32px;
}
.market-status :deep(.n-tag) {
padding: 0 12px;
height: 32px;
font-size: 1rem;
}
.market-status :deep(.n-tag__icon) {
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 {
font-size: 0.875rem;
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>

View File

@@ -0,0 +1,994 @@
<template>
<div class="app-container">
<n-layout class="main-layout">
<n-layout-content class="main-content">
<!-- 市场时间显示 -->
<MarketTimeDisplay />
<!-- API配置面板 -->
<ApiConfigPanel
:default-api-url="defaultApiUrl"
:default-api-model="defaultApiModel"
:default-api-timeout="defaultApiTimeout"
@update:api-config="updateApiConfig"
/>
<!-- 主要内容 -->
<n-card class="analysis-container">
<n-grid :cols="24" :x-gap="16" :y-gap="16">
<!-- 左侧配置区域 -->
<n-grid-item :span="24" :lg-span="8">
<div class="config-section">
<n-form-item label="选择市场类型">
<n-select
v-model:value="marketType"
:options="marketOptions"
@update:value="handleMarketTypeChange"
/>
</n-form-item>
<n-form-item label="股票搜索" v-if="marketType === 'US'">
<StockSearch :market-type="marketType" @select="addSelectedStock" />
</n-form-item>
<n-form-item label="输入代码">
<n-input
v-model:value="stockCodes"
type="textarea"
placeholder="输入股票代码,多个代码用逗号、空格或换行分隔"
:autosize="{ minRows: 3, maxRows: 6 }"
/>
</n-form-item>
<div class="action-buttons">
<n-button
type="primary"
:loading="isAnalyzing"
:disabled="!stockCodes.trim()"
@click="analyzeStocks"
>
{{ isAnalyzing ? '分析中...' : '开始分析' }}
</n-button>
<n-button
:disabled="analyzedStocks.length === 0"
@click="copyAnalysisResults"
>
复制结果
</n-button>
</div>
</div>
</n-grid-item>
<!-- 右侧结果区域 -->
<n-grid-item :span="24" :lg-span="16">
<div class="results-section">
<div class="results-header">
<n-space align="center" justify="space-between">
<n-text>分析结果 ({{ analyzedStocks.length }})</n-text>
<n-space>
<n-select
v-model:value="displayMode"
size="small"
style="width: 120px"
:options="[
{ label: '卡片视图', value: 'card' },
{ label: '表格视图', value: 'table' }
]"
/>
<n-button
size="small"
:disabled="analyzedStocks.length === 0"
@click="copyAnalysisResults"
>
复制结果
</n-button>
<n-dropdown
trigger="click"
:disabled="analyzedStocks.length === 0"
:options="exportOptions"
@select="handleExportSelect"
>
<n-button size="small" :disabled="analyzedStocks.length === 0">
导出
<template #icon>
<n-icon>
<DownloadIcon />
</n-icon>
</template>
</n-button>
</n-dropdown>
</n-space>
</n-space>
</div>
<template v-if="analyzedStocks.length === 0 && !isAnalyzing">
<n-empty description="尚未分析股票" size="large">
<template #icon>
<n-icon :component="DocumentTextIcon" />
</template>
</n-empty>
</template>
<template v-else-if="displayMode === 'card'">
<n-grid :cols="1" :x-gap="16" :y-gap="16" :lg-cols="2">
<n-grid-item v-for="stock in analyzedStocks" :key="stock.code">
<StockCard :stock="stock" />
</n-grid-item>
</n-grid>
</template>
<template v-else>
<n-data-table
:columns="stockTableColumns"
:data="analyzedStocks"
:pagination="{ pageSize: 10 }"
:row-key="(row: StockInfo) => row.code"
:bordered="false"
:single-line="false"
striped
/>
</template>
</div>
</n-grid-item>
</n-grid>
</n-card>
</n-layout-content>
</n-layout>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, h } from 'vue';
import {
NLayout,
NLayoutContent,
NCard,
NIcon,
NGrid,
NGridItem,
NFormItem,
NSelect,
NInput,
NButton,
NEmpty,
useMessage,
useNotification,
NSpace,
NText,
NDataTable,
NDropdown,
type DataTableColumns
} from 'naive-ui';
import { useClipboard } from '@vueuse/core'
import {
DocumentTextOutline as DocumentTextIcon,
DownloadOutline as DownloadIcon,
NotificationsOutline as NotificationsIcon
} from '@vicons/ionicons5';
import MarketTimeDisplay from './MarketTimeDisplay.vue';
import ApiConfigPanel from './ApiConfigPanel.vue';
import StockSearch from './StockSearch.vue';
import StockCard from './StockCard.vue';
import { apiService } from '@/services/api';
import type { StockInfo, ApiConfig, StreamInitMessage, StreamAnalysisUpdate } from '@/types';
import { loadApiConfig } from '@/utils';
import { validateMultipleStockCodes, MarketType } from '@/utils/stockValidator';
// 使用Naive UI的组件API
const message = useMessage();
const notification = useNotification();
const { copy } = useClipboard();
// 从环境变量获取的默认配置
const defaultApiUrl = ref('');
const defaultApiModel = ref('');
const defaultApiTimeout = ref('60');
const announcement = ref('');
// 股票分析配置
const marketType = ref('A');
const stockCodes = ref('');
const isAnalyzing = ref(false);
const analyzedStocks = ref<StockInfo[]>([]);
const displayMode = ref<'card' | 'table'>('card');
// API配置
const apiConfig = ref<ApiConfig>({
apiUrl: '',
apiKey: '',
apiModel: '',
apiTimeout: '60',
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 = [
{ label: 'A股', value: 'A' },
{ label: '港股', value: 'HK' },
{ label: '美股', value: 'US' },
{ label: 'ETF', value: 'ETF' },
{ label: 'LOF', value: 'LOF' }
];
// 表格列定义
const stockTableColumns = ref<DataTableColumns<StockInfo>>([
{
title: '代码',
key: 'code',
width: 100,
fixed: 'left'
},
{
title: '状态',
key: 'analysisStatus',
width: 100,
render(row: StockInfo) {
const statusMap = {
'waiting': '等待分析',
'analyzing': '分析中',
'completed': '已完成',
'error': '出错'
};
return statusMap[row.analysisStatus] || row.analysisStatus;
}
},
{
title: '价格',
key: 'price',
width: 100,
render(row: StockInfo) {
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: '涨跌幅',
key: 'changePercent',
width: 100,
render(row: StockInfo) {
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 ? '+' : '';
return `${sign}${row.changePercent.toFixed(2)}%`;
}
},
{
title: 'RSI',
key: 'rsi',
width: 80,
render(row: StockInfo) {
return row.rsi !== undefined ? row.rsi.toFixed(2) : '--';
}
},
{
title: '均线趋势',
key: 'ma_trend',
width: 100,
render(row: StockInfo) {
const trendMap: Record<string, string> = {
'UP': '上升',
'DOWN': '下降',
'NEUTRAL': '平稳'
};
return row.ma_trend ? trendMap[row.ma_trend] || row.ma_trend : '--';
}
},
{
title: 'MACD信号',
key: 'macd_signal',
width: 100,
render(row: StockInfo) {
const signalMap: Record<string, string> = {
'BUY': '买入',
'SELL': '卖出',
'HOLD': '持有',
'NEUTRAL': '中性'
};
return row.macd_signal ? signalMap[row.macd_signal] || row.macd_signal : '--';
}
},
{
title: '评分',
key: 'score',
width: 80,
render(row: StockInfo) {
return row.score !== undefined ? row.score : '--';
}
},
{
title: '推荐',
key: 'recommendation',
width: 100
},
{
title: '分析日期',
key: 'analysis_date',
width: 120,
render(row: StockInfo) {
if (!row.analysis_date) return '--';
try {
const date = new Date(row.analysis_date);
if (isNaN(date.getTime())) {
return row.analysis_date;
}
return date.toISOString().split('T')[0];
} catch (e) {
return row.analysis_date;
}
}
},
{
title: '分析结果',
key: 'analysis',
ellipsis: {
tooltip: true
},
width: 300,
className: 'analysis-cell'
}
]);
// 导出选项
const exportOptions = [
{
label: '导出为CSV',
key: 'csv'
},
{
label: '导出为Excel',
key: 'excel'
},
{
label: '导出为PDF',
key: 'pdf'
}
];
// 更新API配置
function updateApiConfig(config: ApiConfig) {
apiConfig.value = { ...config };
}
// 处理市场类型变更
function handleMarketTypeChange() {
stockCodes.value = '';
analyzedStocks.value = [];
}
// 添加选择的股票
function addSelectedStock(symbol: string) {
if (stockCodes.value) {
stockCodes.value += ', ' + symbol;
} else {
stockCodes.value = symbol;
}
}
// 处理流式响应的数据
function processStreamData(text: string) {
try {
// 尝试解析为JSON
const data = JSON.parse(text);
// 判断是初始消息还是更新消息
if (data.stream_type === 'single' || data.stream_type === 'batch') {
// 初始消息
handleStreamInit(data as StreamInitMessage);
} else if (data.stock_code) {
// 更新消息
handleStreamUpdate(data as StreamAnalysisUpdate);
} else if (data.scan_completed) {
// 扫描完成消息
message.success(`分析完成,共扫描 ${data.total_scanned} 只股票,符合条件 ${data.total_matched} 只`);
// 将所有分析中的股票状态更新为已完成
analyzedStocks.value = analyzedStocks.value.map(stock => {
if (stock.analysisStatus === 'analyzing') {
return {
...stock,
analysisStatus: 'completed' as const
};
}
return stock;
});
isAnalyzing.value = false;
}
} catch (e) {
console.error('解析流数据出错:', e);
}
}
// 处理流式初始化消息
function handleStreamInit(data: StreamInitMessage) {
if (data.stream_type === 'single' && data.stock_code) {
// 单个股票分析
analyzedStocks.value = [{
code: data.stock_code,
name: '',
marketType: marketType.value,
analysisStatus: 'waiting'
}];
} else if (data.stream_type === 'batch' && data.stock_codes) {
// 批量分析
analyzedStocks.value = data.stock_codes.map(code => ({
code,
name: '',
marketType: marketType.value,
analysisStatus: 'waiting'
}));
}
}
// 处理流式更新消息
function handleStreamUpdate(data: StreamAnalysisUpdate) {
const stockIndex = analyzedStocks.value.findIndex((s: StockInfo) => s.code === data.stock_code);
if (stockIndex >= 0) {
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) {
stock.analysisStatus = data.status;
}
// 如果有分析结果,则更新
if (data.analysis !== undefined) {
stock.analysis = data.analysis;
}
// 处理AI分析片段
if (data.ai_analysis_chunk !== undefined) {
stock.analysis = (stock.analysis || '') + data.ai_analysis_chunk;
stock.analysisStatus = 'analyzing';
}
// 如果有错误,则更新
if (data.error !== undefined) {
stock.error = data.error;
stock.analysisStatus = 'error';
}
// 更新其他字段
if (data.name !== undefined) {
stock.name = data.name;
}
if (data.recommendation !== undefined) {
stock.recommendation = data.recommendation;
}
if (data.ma_trend !== undefined) {
stock.ma_trend = data.ma_trend;
}
if (data.macd_signal !== undefined) {
stock.macd_signal = data.macd_signal;
}
if (data.volume_status !== undefined) {
stock.volume_status = data.volume_status;
}
if (data.analysis_date !== undefined) {
stock.analysis_date = data.analysis_date;
}
// 使用Vue的响应式API更新数组
analyzedStocks.value[stockIndex] = stock;
}
}
// 分析股票
async function analyzeStocks() {
if (!stockCodes.value.trim()) {
message.warning('请输入股票代码');
return;
}
// 解析股票代码
const codes = stockCodes.value
.split(/[,\s\n]+/)
.map((code: string) => code.trim())
.filter((code: string) => code);
if (codes.length === 0) {
message.warning('未找到有效的股票代码');
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 {
// 构建请求参数
const requestData = {
stock_codes: uniqueCodes,
market_type: marketType.value
} as any;
// 添加自定义API配置
if (apiConfig.value.apiUrl) {
requestData.api_url = apiConfig.value.apiUrl;
}
if (apiConfig.value.apiKey) {
requestData.api_key = apiConfig.value.apiKey;
}
if (apiConfig.value.apiModel) {
requestData.api_model = apiConfig.value.apiModel;
}
if (apiConfig.value.apiTimeout) {
requestData.api_timeout = apiConfig.value.apiTimeout;
}
// 发送分析请求
const response = await fetch('/analyze', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
if (response.status === 404) {
throw new Error('服务器接口未找到,请检查服务是否正常运行');
}
throw new Error(`服务器响应错误: ${response.status}`);
}
// 处理流式响应
const reader = response.body?.getReader();
if (!reader) {
throw new Error('无法读取响应流');
}
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
// 解码并处理数据
const text = decoder.decode(value, { stream: true });
buffer += text;
// 按行处理数据
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 最后一行可能不完整,保留到下一次
for (const line of lines) {
if (line.trim()) {
try {
processStreamData(line);
} catch (e: Error | unknown) {
console.error('处理数据流时出错:', e);
message.error(`处理数据时出错: ${e instanceof Error ? e.message : '未知错误'}`);
}
}
}
}
// 处理最后可能剩余的数据
if (buffer.trim()) {
try {
processStreamData(buffer);
} catch (e: Error | unknown) {
console.error('处理最后的数据块时出错:', e);
message.error(`处理数据时出错: ${e instanceof Error ? e.message : '未知错误'}`);
}
}
message.success('分析完成');
} catch (error: any) {
let errorMessage = '分析出错: ';
if (error.message.includes('404')) {
errorMessage += '服务器接口未找到,请确保后端服务正常运行';
} else {
errorMessage += error.message || '未知错误';
}
message.error(errorMessage);
console.error('分析股票时出错:', error);
// 清空分析状态
analyzedStocks.value = [];
} finally {
isAnalyzing.value = false;
}
}
// 复制分析结果
async function copyAnalysisResults() {
if (analyzedStocks.value.length === 0) {
message.warning('没有可复制的分析结果');
return;
}
try {
// 格式化分析结果
const formattedResults = analyzedStocks.value
.filter((stock: StockInfo) => stock.analysisStatus === 'completed')
.map((stock: StockInfo) => {
let result = `【${stock.code} ${stock.name || ''}】\n`;
// 添加分析日期
if (stock.analysis_date) {
try {
const date = new Date(stock.analysis_date);
if (!isNaN(date.getTime())) {
result += `分析日期: ${date.toISOString().split('T')[0]}\n`;
} else {
result += `分析日期: ${stock.analysis_date}\n`;
}
} catch (e) {
result += `分析日期: ${stock.analysis_date}\n`;
}
}
// 添加评分和推荐信息
if (stock.score !== undefined) {
result += `评分: ${stock.score}\n`;
}
if (stock.recommendation) {
result += `推荐: ${stock.recommendation}\n`;
}
// 添加技术指标信息
if (stock.rsi !== undefined) {
result += `RSI: ${stock.rsi.toFixed(2)}\n`;
}
if (stock.price_change !== undefined) {
const sign = stock.price_change > 0 ? '+' : '';
result += `涨跌额: ${sign}${stock.price_change.toFixed(2)}\n`;
}
if (stock.ma_trend) {
const trendMap: Record<string, string> = {
'UP': '上升',
'DOWN': '下降',
'NEUTRAL': '平稳'
};
const trend = trendMap[stock.ma_trend] || stock.ma_trend;
result += `均线趋势: ${trend}\n`;
}
if (stock.macd_signal) {
const signalMap: Record<string, string> = {
'BUY': '买入',
'SELL': '卖出',
'HOLD': '持有',
'NEUTRAL': '中性'
};
const signal = signalMap[stock.macd_signal] || stock.macd_signal;
result += `MACD信号: ${signal}\n`;
}
if (stock.volume_status) {
const statusMap: Record<string, string> = {
'HIGH': '放量',
'LOW': '缩量',
'NORMAL': '正常'
};
const status = statusMap[stock.volume_status] || stock.volume_status;
result += `成交量: ${status}\n`;
}
// 添加分析结果
result += `\n${stock.analysis || '无分析结果'}\n`;
return result;
})
.join('\n');
if (!formattedResults) {
message.warning('没有已完成的分析结果可复制');
return;
}
// 复制到剪贴板
await copy(formattedResults);
message.success('已复制分析结果到剪贴板');
} catch (error) {
message.error('复制失败,请手动复制');
console.error('复制分析结果时出错:', error);
}
}
// 从本地存储恢复API配置
function restoreLocalApiConfig() {
const savedConfig = loadApiConfig();
if (savedConfig && savedConfig.saveApiConfig) {
apiConfig.value = {
apiUrl: savedConfig.apiUrl || '',
apiKey: savedConfig.apiKey || '',
apiModel: savedConfig.apiModel || defaultApiModel.value,
apiTimeout: savedConfig.apiTimeout || defaultApiTimeout.value,
saveApiConfig: savedConfig.saveApiConfig
};
// 通知父组件配置已更新
updateApiConfig(apiConfig.value);
}
}
// 处理导出选择
function handleExportSelect(key: string) {
switch (key) {
case 'csv':
exportToCSV();
break;
case 'excel':
message.info('Excel导出功能即将推出');
break;
case 'pdf':
message.info('PDF导出功能即将推出');
break;
}
}
// 导出为CSV
function exportToCSV() {
if (analyzedStocks.value.length === 0) {
message.warning('没有可导出的分析结果');
return;
}
try {
// 创建CSV内容
const headers = ['代码', '名称', '价格', '涨跌幅', 'RSI', '均线趋势', 'MACD信号', '成交量状态', '评分', '推荐', '分析日期'];
let csvContent = headers.join(',') + '\n';
// 添加数据行
analyzedStocks.value.forEach(stock => {
const row = [
`"${stock.code}"`,
`"${stock.name || ''}"`,
stock.price !== undefined ? stock.price.toFixed(2) : '',
stock.changePercent !== undefined ? `${stock.changePercent > 0 ? '+' : ''}${stock.changePercent.toFixed(2)}%` : '',
stock.rsi !== undefined ? stock.rsi.toFixed(2) : '',
stock.ma_trend ? getChineseTrend(stock.ma_trend) : '',
stock.macd_signal ? getChineseSignal(stock.macd_signal) : '',
stock.volume_status ? getChineseVolumeStatus(stock.volume_status) : '',
stock.score !== undefined ? stock.score : '',
`"${stock.recommendation || ''}"`,
stock.analysis_date || ''
];
csvContent += row.join(',') + '\n';
});
// 创建Blob对象
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.setAttribute('href', url);
link.setAttribute('download', `股票分析结果_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
// 添加到文档并触发点击
document.body.appendChild(link);
link.click();
// 清理
document.body.removeChild(link);
URL.revokeObjectURL(url);
message.success('已导出CSV文件');
} catch (error) {
message.error('导出失败');
console.error('导出CSV时出错:', error);
}
}
// 辅助函数:获取中文趋势描述
function getChineseTrend(trend: string): string {
const trendMap: Record<string, string> = {
'UP': '上升',
'DOWN': '下降',
'NEUTRAL': '平稳'
};
return trendMap[trend] || trend;
}
// 辅助函数:获取中文信号描述
function getChineseSignal(signal: string): string {
const signalMap: Record<string, string> = {
'BUY': '买入',
'SELL': '卖出',
'HOLD': '持有',
'NEUTRAL': '中性'
};
return signalMap[signal] || signal;
}
// 辅助函数:获取中文成交量状态描述
function getChineseVolumeStatus(status: string): string {
const statusMap: Record<string, string> = {
'HIGH': '放量',
'LOW': '缩量',
'NORMAL': '正常'
};
return statusMap[status] || status;
}
// 页面加载时获取默认配置和公告
onMounted(async () => {
try {
// 从API获取配置信息
const config = await apiService.getConfig();
if (config.default_api_url) {
defaultApiUrl.value = config.default_api_url;
}
if (config.default_api_model) {
defaultApiModel.value = config.default_api_model;
}
if (config.default_api_timeout) {
defaultApiTimeout.value = config.default_api_timeout;
}
if (config.announcement) {
announcement.value = config.announcement;
// 使用通知显示公告
showAnnouncement(config.announcement);
}
// 初始化后恢复本地保存的配置
restoreLocalApiConfig();
} catch (error) {
console.error('获取默认配置时出错:', error);
}
});
</script>
<style scoped>
.app-container {
min-height: 100vh;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
.main-layout {
background-color: #f6f6f6;
width: 100%;
max-width: 100vw;
overflow-x: hidden;
}
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
width: 100%;
box-sizing: border-box;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
}
.analysis-container {
margin-bottom: 2rem;
}
.config-section {
padding: 0.5rem;
}
.action-buttons {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.results-section {
padding: 0.5rem;
min-height: 200px;
}
.results-header {
margin-bottom: 1rem;
}
.n-data-table .analysis-cell {
max-width: 300px;
white-space: normal;
overflow: hidden;
text-overflow: ellipsis;
word-break: break-word;
}
</style>

File diff suppressed because it is too large Load Diff

View File

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

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

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

View File

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

View File

@@ -0,0 +1,146 @@
import axios from 'axios';
import type { AnalyzeRequest, TestApiRequest, TestApiResponse, SearchResult, LoginRequest, LoginResponse } from '@/types';
// 在开发环境中使用完整URL
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 = {
// 用户登录
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) => {
return axiosInstance.post(`${API_PREFIX}/analyze`, request, {
responseType: 'stream'
});
},
// 测试API连接
testApiConnection: async (request: TestApiRequest): Promise<TestApiResponse> => {
try {
const response = await axiosInstance.post(`${API_PREFIX}/test_api_connection`, request);
return response.data;
} catch (error: any) {
if (error.response) {
return error.response.data;
}
return {
success: false,
message: error.message || '连接失败'
};
}
},
// 搜索美股
searchUsStocks: async (keyword: string): Promise<SearchResult[]> => {
try {
const response = await axiosInstance.get(`${API_PREFIX}/search_us_stocks`, {
params: { keyword }
});
return response.data.results || [];
} catch (error) {
console.error('搜索美股时出错:', error);
return [];
}
},
// 获取配置
getConfig: async () => {
try {
const response = await axios.get(`${API_PREFIX}/config`);
return response.data;
} catch (error) {
console.error('获取配置时出错:', error);
return {
announcement: '',
default_api_url: '',
default_api_model: 'gpt-3.5-turbo',
default_api_timeout: '60'
};
}
},
// 检查是否需要登录
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;
}
}
};

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

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

109
frontend/src/types/index.ts Normal file
View File

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

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

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

View File

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

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

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

View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"incremental": true, // 启用增量编译
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"incremental": true, // 启用增量编译
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

68
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,68 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'url'
import { dirname, resolve } from 'path'
// 获取当前文件的目录路径在ESM中替代__dirname
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
server: {
cors: true,
proxy: {
'/api': {
target: 'http://127.0.0.1:8888',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/analyze': {
target: 'http://127.0.0.1:8888',
changeOrigin: true,
},
'/test_api_connection': {
target: 'http://127.0.0.1:8888',
changeOrigin: true,
},
'/search_us_stocks': {
target: 'http://127.0.0.1:8888',
changeOrigin: true,
},
'/config': {
target: 'http://127.0.0.1:8888',
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,
},
},
},
})

View File

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

View File

@@ -10,11 +10,14 @@ scipy==1.15.1
akshare==1.16.22 akshare==1.16.22
tqdm==4.67.1 tqdm==4.67.1
# Web框架与异步处理
fastapi==0.115.11
uvicorn[standard]==0.34.0
pydantic==2.10.6
httpx==0.28.1
# 网络和API请求 # 环境配置
requests==2.32.3
python-dotenv==1.0.1 python-dotenv==1.0.1
flask==3.1.0
# 日志和系统工具 # 日志和系统工具
loguru==0.7.2 loguru==0.7.2
@@ -32,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

2
services/__init__.py Normal file
View File

@@ -0,0 +1,2 @@
# services包初始化文件
# 用于组织股票分析服务的各个模块

456
services/ai_analyzer.py Normal file
View File

@@ -0,0 +1,456 @@
import pandas as pd
import numpy as np
import os
import json
import asyncio
import httpx
import re
from typing import Dict, List, Optional, Any, Generator, AsyncGenerator
from dotenv import load_dotenv
from logger import get_logger
from utils.api_utils import APIUtils
from datetime import datetime
# 获取日志器
logger = get_logger()
class AIAnalyzer:
"""
异步AI分析服务
负责调用AI API对股票数据进行分析
"""
def __init__(self, custom_api_url=None, custom_api_key=None, custom_api_model=None, custom_api_timeout=None):
"""
初始化AI分析服务
Args:
custom_api_url: 自定义API URL
custom_api_key: 自定义API密钥
custom_api_model: 自定义API模型
custom_api_timeout: 自定义API超时时间
"""
# 加载环境变量
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"初始化AIAnalyzer: API_URL={self.API_URL}, API_MODEL={self.API_MODEL}, API_KEY={'已提供' if self.API_KEY else '未提供'}, API_TIMEOUT={self.API_TIMEOUT}")
async def get_ai_analysis(self, df: pd.DataFrame, stock_code: str, market_type: str = 'A', stream: bool = False) -> AsyncGenerator[str, None]:
"""
对股票数据进行AI分析
Args:
df: 包含技术指标的DataFrame
stock_code: 股票代码
market_type: 市场类型,默认为'A'
stream: 是否使用流式响应
Returns:
异步生成器,生成分析结果字符串
"""
try:
logger.info(f"开始AI分析 {stock_code}, 流式模式: {stream}")
# 提取关键技术指标
latest_data = df.iloc[-1]
# 计算技术指标
rsi = latest_data.get('RSI')
price = latest_data.get('Close')
price_change = latest_data.get('Change')
# 确定MA趋势
ma_trend = 'UP' if latest_data.get('MA5', 0) > latest_data.get('MA20', 0) else 'DOWN'
# 确定MACD信号
macd = latest_data.get('MACD', 0)
macd_signal = latest_data.get('MACD_Signal', 0)
macd_signal_type = 'BUY' if macd > macd_signal else 'SELL'
# 确定成交量状态
volume_ratio = latest_data.get('Volume_Ratio', 1)
volume_status = 'HIGH' if volume_ratio > 1.5 else ('LOW' if volume_ratio < 0.5 else 'NORMAL')
# AI 分析内容
# 最近14天的股票数据记录
recent_data = df.tail(14).to_dict('records')
# 包含trend, volatility, volume_trend, rsi_level的字典
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股市场特点进行分析给出具体数据支持。
"""
# 格式化API URL
api_url = APIUtils.format_api_url(self.API_URL)
# 准备请求数据
request_data = {
"model": self.API_MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.7,
"stream": stream
}
# 准备请求头
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.API_KEY}"
}
# 获取当前日期作为分析日期
analysis_date = datetime.now().strftime("%Y-%m-%d")
# 异步请求API
async with httpx.AsyncClient(timeout=self.API_TIMEOUT) as client:
# 记录请求
logger.debug(f"发送AI请求: URL={api_url}, MODEL={self.API_MODEL}, STREAM={stream}")
# 先发送技术指标数据
yield json.dumps({
"stock_code": stock_code,
"status": "analyzing",
"rsi": rsi,
"price": price,
"price_change": price_change,
"ma_trend": ma_trend,
"macd_signal": macd_signal_type,
"volume_status": volume_status,
"analysis_date": analysis_date
})
if stream:
# 流式响应处理
async with client.stream("POST", api_url, json=request_data, headers=headers) as response:
if response.status_code != 200:
error_text = await response.aread()
error_data = json.loads(error_text)
error_message = error_data.get('error', {}).get('message', '未知错误')
logger.error(f"AI API请求失败: {response.status_code} - {error_message}")
yield json.dumps({
"stock_code": stock_code,
"error": f"API请求失败: {error_message}",
"status": "error"
})
return
# 处理流式响应
buffer = ""
collected_messages = []
chunk_count = 0
async for chunk in response.aiter_text():
if chunk:
# 分割多行响应处理某些API可能在一个chunk中返回多行
lines = chunk.strip().split('\n')
for line in lines:
line = line.strip()
if not line:
continue
# 处理以data:开头的行
if line.startswith("data: "):
line = line[6:] # 去除"data: "前缀
if line == "[DONE]":
logger.debug("收到流结束标记 [DONE]")
continue
try:
# 处理特殊错误情况
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)}")
# 如果buffer不为空且不以换行符结束发送一个换行符
if buffer and not buffer.endswith('\n'):
logger.debug("发送换行符")
yield json.dumps({
"stock_code": stock_code,
"ai_analysis_chunk": "\n",
"status": "analyzing"
})
# 完整的分析内容
full_content = buffer
# 尝试从分析内容中提取投资建议
recommendation = self._extract_recommendation(full_content)
# 计算分析评分
score = self._calculate_analysis_score(full_content, technical_summary)
# 发送完成状态和评分、建议
yield json.dumps({
"stock_code": stock_code,
"status": "completed",
"score": score,
"recommendation": recommendation
})
else:
# 非流式响应处理
response = await client.post(api_url, json=request_data, headers=headers)
if response.status_code != 200:
error_data = response.json()
error_message = error_data.get('error', {}).get('message', '未知错误')
logger.error(f"AI API请求失败: {response.status_code} - {error_message}")
yield json.dumps({
"stock_code": stock_code,
"error": f"API请求失败: {error_message}",
"status": "error"
})
return
response_data = response.json()
analysis_text = response_data.get("choices", [{}])[0].get("message", {}).get("content", "")
# 尝试从分析内容中提取投资建议
recommendation = self._extract_recommendation(analysis_text)
# 计算分析评分
score = self._calculate_analysis_score(analysis_text, technical_summary)
# 发送完整的分析结果
yield json.dumps({
"stock_code": stock_code,
"status": "completed",
"analysis": analysis_text,
"score": score,
"recommendation": recommendation,
"rsi": rsi,
"price": price,
"price_change": price_change,
"ma_trend": ma_trend,
"macd_signal": macd_signal_type,
"volume_status": volume_status,
"analysis_date": analysis_date
})
except Exception as e:
logger.error(f"AI分析出错: {str(e)}", exc_info=True)
yield json.dumps({
"stock_code": stock_code,
"error": f"分析出错: {str(e)}",
"status": "error"
})
def _extract_recommendation(self, analysis_text: str) -> str:
"""从分析文本中提取投资建议"""
# 查找投资建议部分
investment_advice_pattern = r"##\s*投资建议\s*\n(.*?)(?:\n##|\Z)"
match = re.search(investment_advice_pattern, analysis_text, re.DOTALL)
if match:
advice_text = match.group(1).strip()
# 提取关键建议
if "买入" in advice_text or "增持" in advice_text:
return "买入"
elif "卖出" in advice_text or "减持" in advice_text:
return "卖出"
elif "持有" in advice_text:
return "持有"
else:
return "观望"
return "观望" # 默认建议
def _calculate_analysis_score(self, analysis_text: str, technical_summary: dict) -> int:
"""计算分析评分"""
score = 50 # 基础分数
# 根据技术指标调整分数
if technical_summary['trend'] == 'upward':
score += 10
else:
score -= 10
if technical_summary['volume_trend'] == 'increasing':
score += 5
else:
score -= 5
rsi = technical_summary['rsi_level']
if rsi < 30: # 超卖
score += 15
elif rsi > 70: # 超买
score -= 15
# 根据分析文本中的关键词调整分数
if "强烈买入" in analysis_text or "显著上涨" in analysis_text:
score += 20
elif "买入" in analysis_text or "看涨" in analysis_text:
score += 10
elif "强烈卖出" in analysis_text or "显著下跌" in analysis_text:
score -= 20
elif "卖出" in analysis_text or "看跌" in analysis_text:
score -= 10
# 确保分数在0-100范围内
return max(0, min(100, score))
def _truncate_json_for_logging(self, json_obj, max_length=500):
"""
截断JSON对象以便记录日志
Args:
json_obj: JSON对象
max_length: 最大长度
Returns:
截断后的字符串
"""
json_str = json.dumps(json_obj, ensure_ascii=False)
if len(json_str) <= max_length:
return json_str
return json_str[:max_length] + "..."

View File

@@ -0,0 +1,228 @@
import asyncio
import pandas as pd
from typing import List, Dict, Any, Optional
from logger import get_logger
from datetime import datetime, timedelta
# 获取日志器
logger = get_logger()
class FundServiceAsync:
"""
异步基金服务
提供基金数据的异步搜索和获取功能
"""
def __init__(self):
"""初始化异步基金服务"""
logger.debug("初始化FundServiceAsync")
# 添加缓存
self._etf_cache = None
self._lof_cache = None
self._cache_timestamp = None
self._cache_duration = timedelta(minutes=30) # 缓存30分钟
async def search_funds(self, keyword: str, market_type: str = 'ETF') -> List[Dict[str, Any]]:
"""
异步搜索基金代码
Args:
keyword: 搜索关键词
market_type: 市场类型,'ETF''LOF'
Returns:
匹配的基金列表
"""
try:
logger.info(f"异步搜索基金: {keyword}, 类型: {market_type}")
# 获取基金数据
df = await self._get_funds_data(market_type)
# 模糊匹配搜索(同时匹配代码和名称)
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,
})
# 限制只返回前10个结果
if len(formatted_results) >= 10:
break
logger.info(f"基金搜索完成,找到 {len(formatted_results)} 个匹配项限制显示前10个")
return formatted_results
except Exception as e:
error_msg = f"搜索基金代码失败: {str(e)}"
logger.error(error_msg)
logger.exception(e)
raise Exception(error_msg)
async def _get_funds_data(self, market_type: str = 'ETF') -> pd.DataFrame:
"""
异步获取基金数据,支持缓存
Args:
market_type: 市场类型,'ETF''LOF'
Returns:
包含基金数据的DataFrame
"""
# 检查缓存是否有效
now = datetime.now()
cache_valid = (
self._cache_timestamp is not None and
(now - self._cache_timestamp) < self._cache_duration
)
if market_type == 'ETF' and cache_valid and self._etf_cache is not None:
logger.debug("使用ETF缓存数据")
return self._etf_cache
elif market_type == 'LOF' and cache_valid and self._lof_cache is not None:
logger.debug("使用LOF缓存数据")
return self._lof_cache
# 缓存无效,重新获取数据
try:
logger.debug(f"从API获取{market_type}数据")
# 使用线程池执行同步的akshare调用
if market_type == 'ETF':
df = await asyncio.to_thread(self._get_etf_data)
self._etf_cache = df
else:
df = await asyncio.to_thread(self._get_lof_data)
self._lof_cache = df
self._cache_timestamp = now
return df
except Exception as e:
logger.error(f"获取{market_type}数据失败: {str(e)}")
logger.exception(e)
raise
def _get_etf_data(self) -> pd.DataFrame:
"""
获取ETF数据同步方法将被异步方法调用
Returns:
包含ETF数据的DataFrame
"""
import akshare as ak
try:
# 获取ETF基金数据
df = ak.fund_etf_spot_em()
# 转换列名
df = df.rename(columns={
"代码": "symbol",
"名称": "name",
"最新价": "price",
"涨跌额": "price_change",
"涨跌幅": "price_change_percent",
"成交量": "volume",
"流通市值": "market_value",
"总市值": "total_value",
"基金折价率": "discount_rate",
})
return df
except Exception as e:
logger.error(f"获取ETF数据失败: {str(e)}")
logger.exception(e)
raise Exception(f"获取ETF数据失败: {str(e)}")
def _get_lof_data(self) -> pd.DataFrame:
"""
获取LOF数据同步方法将被异步方法调用
Returns:
包含LOF数据的DataFrame
"""
import akshare as ak
try:
# 获取LOF基金数据
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",
})
return df
except Exception as e:
logger.error(f"获取LOF数据失败: {str(e)}")
logger.exception(e)
raise Exception(f"获取LOF数据失败: {str(e)}")
async def get_fund_detail(self, symbol: str, market_type: str = 'ETF') -> Dict[str, Any]:
"""
异步获取单个基金详细信息
Args:
symbol: 基金代码
market_type: 市场类型,'ETF''LOF'
Returns:
基金详细信息
"""
try:
logger.info(f"获取{market_type}基金详情: {symbol}")
# 获取基金数据
df = await self._get_funds_data(market_type)
# 精确匹配基金代码
result = df[df['symbol'] == symbol]
if len(result) == 0:
raise Exception(f"未找到基金代码: {symbol}")
# 获取第一行数据
row = result.iloc[0]
# 格式化为字典
fund_detail = {
'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,
'price_change': float(row['price_change']) if pd.notna(row['price_change']) else 0.0,
'price_change_percent': float(row['price_change_percent'].strip('%'))/100 if pd.notna(row['price_change_percent']) 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,
'discount_rate': float(row['discount_rate'].strip('%'))/100 if pd.notna(row['discount_rate']) else 0.0
}
logger.info(f"获取基金详情成功: {symbol}")
return fund_detail
except Exception as e:
error_msg = f"获取基金详情失败: {str(e)}"
logger.error(error_msg)
logger.exception(e)
raise Exception(error_msg)

View File

@@ -0,0 +1,269 @@
import pandas as pd
import numpy as np
import asyncio
import json
from datetime import datetime
from typing import Dict, List, Optional, Tuple, Any, AsyncGenerator
from logger import get_logger
from services.stock_data_provider import StockDataProvider
from services.technical_indicator import TechnicalIndicator
from services.stock_scorer import StockScorer
from services.ai_analyzer import AIAnalyzer
# 获取日志器
logger = get_logger()
class StockAnalyzerService:
"""
股票分析服务
作为门面类协调数据提供、指标计算、评分和AI分析等组件
"""
def __init__(self, custom_api_url=None, custom_api_key=None, custom_api_model=None, custom_api_timeout=None):
"""
初始化股票分析服务
Args:
custom_api_url: 自定义API URL
custom_api_key: 自定义API密钥
custom_api_model: 自定义API模型
custom_api_timeout: 自定义API超时时间
"""
# 初始化各个组件
self.data_provider = StockDataProvider()
self.indicator = TechnicalIndicator()
self.scorer = StockScorer()
self.ai_analyzer = AIAnalyzer(
custom_api_url=custom_api_url,
custom_api_key=custom_api_key,
custom_api_model=custom_api_model,
custom_api_timeout=custom_api_timeout
)
logger.info("初始化StockAnalyzerService完成")
async def analyze_stock(self, stock_code: str, market_type: str = 'A', stream: bool = False) -> AsyncGenerator[str, None]:
"""
分析单只股票
Args:
stock_code: 股票代码
market_type: 市场类型,默认为'A'
stream: 是否使用流式响应
Returns:
异步生成器生成分析结果的JSON字符串
"""
try:
logger.info(f"开始分析股票: {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)
# 计算评分
score = self.scorer.calculate_score(df_with_indicators)
recommendation = self.scorer.get_recommendation(score)
# 获取最新数据
latest_data = df_with_indicators.iloc[-1]
previous_data = df_with_indicators.iloc[-2] if len(df_with_indicators) > 1 else latest_data
# 计算价格变化百分比
price_change = ((latest_data['Close'] - previous_data['Close']) / previous_data['Close']) * 100
# 确定MA趋势
ma_short = latest_data.get('MA5', 0)
ma_medium = latest_data.get('MA20', 0)
ma_long = latest_data.get('MA60', 0)
if ma_short > ma_medium > ma_long:
ma_trend = "UP"
elif ma_short < ma_medium < ma_long:
ma_trend = "DOWN"
else:
ma_trend = "FLAT"
# 确定MACD信号
macd = latest_data.get('MACD', 0)
signal = latest_data.get('Signal', 0)
if macd > signal:
macd_signal = "BUY"
elif macd < signal:
macd_signal = "SELL"
else:
macd_signal = "HOLD"
# 确定成交量状态
volume = latest_data.get('Volume', 0)
volume_ma = latest_data.get('Volume_MA', 0)
if volume > volume_ma * 1.5:
volume_status = "HIGH"
elif volume < volume_ma * 0.5:
volume_status = "LOW"
else:
volume_status = "NORMAL"
# 当前分析日期
analysis_date = datetime.now().strftime('%Y-%m-%d')
# 生成基本分析结果
basic_result = {
"stock_code": stock_code,
"market_type": market_type,
"analysis_date": analysis_date,
"score": score,
"price": latest_data['Close'],
"price_change": price_change,
"ma_trend": ma_trend,
"rsi": latest_data.get('RSI', 0),
"macd_signal": macd_signal,
"volume_status": volume_status,
"recommendation": recommendation,
"ai_analysis": ""
}
# 输出基本分析结果
logger.info(f"基本分析结果: {json.dumps(basic_result)}")
yield json.dumps(basic_result)
# 使用AI进行深入分析
async for analysis_chunk in self.ai_analyzer.get_ai_analysis(df_with_indicators, stock_code, market_type, stream):
yield analysis_chunk
logger.info(f"完成股票分析: {stock_code}")
except Exception as e:
error_msg = f"分析股票 {stock_code} 时出错: {str(e)}"
logger.error(error_msg)
logger.exception(e)
yield json.dumps({"error": error_msg})
async def scan_stocks(self, stock_codes: List[str], market_type: str = 'A', min_score: int = 0, stream: bool = False) -> AsyncGenerator[str, None]:
"""
批量扫描股票
Args:
stock_codes: 股票代码列表
market_type: 市场类型
min_score: 最低评分阈值
stream: 是否使用流式响应
Returns:
异步生成器生成扫描结果的JSON字符串
"""
try:
logger.info(f"开始批量扫描 {len(stock_codes)} 只股票, 市场: {market_type}")
# 输出初始状态 - 发送批量分析初始化消息
yield json.dumps({
"stream_type": "batch",
"stock_codes": stock_codes,
"market_type": market_type,
"min_score": min_score
})
# 批量获取股票数据
stock_data_dict = await self.data_provider.get_multiple_stocks_data(stock_codes, market_type)
# 计算技术指标
stock_with_indicators = {}
for code, df in stock_data_dict.items():
try:
stock_with_indicators[code] = self.indicator.calculate_indicators(df)
except Exception as e:
logger.error(f"计算 {code} 技术指标时出错: {str(e)}")
# 发送错误状态
yield json.dumps({
"stock_code": code,
"error": f"计算技术指标时出错: {str(e)}",
"status": "error"
})
# 评分股票
results = self.scorer.batch_score_stocks(stock_with_indicators)
# 过滤低于最低评分的股票
filtered_results = [r for r in results if r[1] >= min_score]
# 为每只股票发送基本评分和推荐信息
for code, score, rec in results:
df = stock_with_indicators.get(code)
if df is not None and len(df) > 0:
# 获取最新数据
latest_data = df.iloc[-1]
# 发送股票基本信息和评分
yield json.dumps({
"stock_code": code,
"score": score,
"recommendation": rec,
"price": float(latest_data.get('Close', 0)),
"price_change": float(latest_data.get('Change', 0)),
"rsi": float(latest_data.get('RSI', 0)) if 'RSI' in latest_data else None,
"ma_trend": "UP" if latest_data.get('MA5', 0) > latest_data.get('MA20', 0) else "DOWN",
"macd_signal": "BUY" if latest_data.get('MACD', 0) > latest_data.get('MACD_Signal', 0) else "SELL",
"volume_status": "HIGH" if latest_data.get('Volume_Ratio', 1) > 1.5 else ("LOW" if latest_data.get('Volume_Ratio', 1) < 0.5 else "NORMAL"),
"status": "completed" if score < min_score else "waiting"
})
# 如果需要进一步分析对评分较高的股票进行AI分析
if stream and filtered_results:
# 只分析前5只评分最高的股票避免分析过多导致前端卡顿
top_stocks = filtered_results[:5]
for stock_code, score, _ in top_stocks:
df = stock_with_indicators.get(stock_code)
if df is not None:
# 输出正在分析的股票信息
yield json.dumps({
"stock_code": stock_code,
"status": "analyzing"
})
# AI分析
async for analysis_chunk in self.ai_analyzer.get_ai_analysis(df, stock_code, market_type, stream):
yield analysis_chunk
# 输出扫描完成信息
yield json.dumps({
"scan_completed": True,
"total_scanned": len(results),
"total_matched": len(filtered_results)
})
logger.info(f"完成批量扫描 {len(stock_codes)} 只股票, 符合条件: {len(filtered_results)}")
except Exception as e:
error_msg = f"批量扫描股票时出错: {str(e)}"
logger.error(error_msg)
logger.exception(e)
yield json.dumps({"error": error_msg})

View File

@@ -0,0 +1,260 @@
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import asyncio
import os
from typing import Dict, List, Optional, Tuple, Any
from logger import get_logger
import re
# 获取日志器
logger = get_logger()
class StockDataProvider:
"""
异步股票数据提供服务
负责获取股票、基金等金融产品的历史数据
"""
def __init__(self):
"""初始化数据提供者服务"""
logger.debug("初始化StockDataProvider")
async def get_stock_data(self, stock_code: str, market_type: str = 'A',
start_date: Optional[str] = None,
end_date: Optional[str] = None) -> pd.DataFrame:
"""
异步获取股票或基金数据
Args:
stock_code: 股票代码
market_type: 市场类型,默认为'A'
start_date: 开始日期格式YYYYMMDD默认为一年前
end_date: 结束日期格式YYYYMMDD默认为今天
Returns:
包含历史数据的DataFrame
"""
# 使用线程池执行同步的akshare调用
return await asyncio.to_thread(
self._get_stock_data_sync,
stock_code,
market_type,
start_date,
end_date
)
def _get_stock_data_sync(self, stock_code: str, market_type: str = 'A',
start_date: Optional[str] = None,
end_date: Optional[str] = None) -> pd.DataFrame:
"""
同步获取股票数据的实现
将被异步方法调用
"""
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')
# 确保日期格式统一(移除可能的'-'符号)
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:
if market_type == 'A':
logger.debug(f"获取A股数据: {stock_code}")
df = ak.stock_zh_a_hist(
symbol=stock_code,
start_date=start_date,
end_date=end_date,
adjust="qfq"
)
elif market_type in ['HK']:
logger.debug(f"获取港股数据: {stock_code}")
df = ak.stock_hk_daily(
symbol=stock_code,
start_date=start_date,
end_date=end_date,
adjust="qfq"
)
elif market_type in ['US']:
logger.debug(f"获取美股数据: {stock_code}")
try:
df = ak.stock_us_daily(
symbol=stock_code,
adjust="qfq"
)
logger.debug(f"美股数据原始列: {df.columns.tolist()}")
logger.debug(f"美股数据形状: {df.shape}")
# 确保索引是日期时间类型
if not isinstance(df.index, pd.DatetimeIndex):
# 如果存在命名为'date'的列,将其设为索引
if 'date' in df.columns:
df['date'] = pd.to_datetime(df['date'])
df.set_index('date', inplace=True)
logger.debug("已将'date'列设置为索引")
else:
# 否则将当前索引转换为日期类型
df.index = pd.to_datetime(df.index)
logger.debug("已将索引转换为DatetimeIndex")
# 计算美股的成交额Amount= 成交量Volume× 收盘价Close
volume_col = next((col for col in df.columns if col.lower() == 'volume'), None)
close_col = next((col for col in df.columns if col.lower() == 'close'), None)
if volume_col and close_col:
df['amount'] = df[volume_col] * df[close_col]
logger.debug("已为美股数据计算成交额(amount)字段")
else:
logger.warning(f"美股数据缺少volume或close列无法计算amount。当前列: {df.columns.tolist()}")
# 添加空的amount列避免后续处理错误
df['amount'] = 0.0
# 将所有列名转为小写以进行统一处理
df.columns = [col.lower() for col in df.columns]
except Exception as e:
logger.error(f"获取美股数据失败 {stock_code}: {str(e)}")
raise ValueError(f"获取美股数据失败 {stock_code}: {str(e)}")
# 将字符串日期转换为日期时间对象进行比较
try:
# 尝试多种格式解析日期
# 如果日期是数字格式20220101使用适当的格式
if start_date.isdigit() and len(start_date) == 8:
start_date_dt = pd.to_datetime(start_date, format='%Y%m%d')
else:
# 否则让pandas自动推断格式
start_date_dt = pd.to_datetime(start_date)
if end_date.isdigit() and len(end_date) == 8:
end_date_dt = pd.to_datetime(end_date, format='%Y%m%d')
else:
end_date_dt = pd.to_datetime(end_date)
except Exception as e:
logger.warning(f"日期转换出错: {str(e)},使用默认值")
# 如果转换失败,使用合理的默认值
start_date_dt = pd.to_datetime('20000101', format='%Y%m%d')
end_date_dt = pd.to_datetime(datetime.now().strftime('%Y%m%d'), format='%Y%m%d')
# 过滤日期
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']:
logger.debug(f"获取{market_type}基金数据: {stock_code}")
df = ak.fund_etf_hist_sina(
symbol=stock_code,
start_date=start_date.replace('-', ''),
end_date=end_date.replace('-', '')
)
else:
error_msg = f"不支持的市场类型: {market_type}"
logger.error(f"[市场类型错误] {error_msg}")
raise ValueError(error_msg)
# 标准化列名
if market_type == 'A':
# 根据实际数据结构调整列名映射
# 实际数据列:['日期', '股票代码', '开盘', '收盘', '最高', '最低', '成交量', '成交额', '振幅', '涨跌幅', '涨跌额', '换手率']
df.columns = ['Date', 'Code', 'Open', 'Close', 'High', 'Low', 'Volume', 'Amount', 'Amplitude', 'Change_pct', 'Change', 'Turnover']
elif market_type in ['HK', 'US']:
# 美股数据列可能不同,需要通过映射处理
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']:
# 基金数据可能有不同的列
df.columns = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume', 'Amount']
# 确保日期列是日期类型
if 'Date' in df.columns:
df['Date'] = pd.to_datetime(df['Date'])
df.set_index('Date', inplace=True)
# 确保按日期升序排序
df.sort_index(inplace=True)
logger.info(f"成功获取{market_type}数据 {stock_code}, 数据点数: {len(df)}")
return df
except Exception as e:
error_msg = f"获取{market_type}数据失败 {stock_code}: {str(e)}"
logger.error(error_msg)
logger.exception(e)
# 使用空的DataFrame并添加错误信息而不是抛出异常
# 这样上层调用者可以检查是否有错误并适当处理
df = pd.DataFrame()
df.error = error_msg # 添加错误属性
return df
async def get_multiple_stocks_data(self, stock_codes: List[str],
market_type: str = 'A',
start_date: Optional[str] = None,
end_date: Optional[str] = None,
max_concurrency: int = 5) -> Dict[str, pd.DataFrame]:
"""
异步批量获取多只股票数据
Args:
stock_codes: 股票代码列表
market_type: 市场类型,默认为'A'
start_date: 开始日期格式YYYYMMDD
end_date: 结束日期格式YYYYMMDD
max_concurrency: 最大并发数默认为5
Returns:
字典键为股票代码值为对应的DataFrame
"""
# 使用信号量控制并发数
semaphore = asyncio.Semaphore(max_concurrency)
async def get_with_semaphore(code):
async with semaphore:
try:
return code, await self.get_stock_data(code, market_type, start_date, end_date)
except Exception as e:
logger.error(f"获取股票 {code} 数据时出错: {str(e)}")
return code, None
# 创建异步任务
tasks = [get_with_semaphore(code) for code in stock_codes]
# 等待所有任务完成
results = await asyncio.gather(*tasks)
# 构建结果字典,过滤掉失败的请求
return {code: df for code, df in results if df is not None}

128
services/stock_scorer.py Normal file
View File

@@ -0,0 +1,128 @@
import pandas as pd
import numpy as np
from typing import Dict, Optional, Any, List, Tuple
from logger import get_logger
# 获取日志器
logger = get_logger()
class StockScorer:
"""
股票评分服务
负责根据技术指标计算股票的综合评分
"""
def __init__(self):
"""初始化股票评分服务"""
logger.debug("初始化StockScorer")
def calculate_score(self, df: pd.DataFrame) -> int:
"""
计算股票评分满分100分
Args:
df: 包含技术指标的DataFrame
Returns:
股票评分0-100的整数
"""
try:
# 使用最新的数据点进行评分
latest = df.iloc[-1]
# 初始得分为0
score = 0
# 移动平均线评分25分
if latest['MA5'] > latest['MA20'] > latest['MA60']:
# 短期、中期和长期均线呈多头排列
score += 25
elif latest['MA5'] > latest['MA20']:
# 短期均线在中期均线之上
score += 15
elif latest['Close'] > latest['MA20']:
# 股价在中期均线之上
score += 10
# RSI评分25分
rsi = latest['RSI']
if 45 <= rsi <= 55:
# RSI在中间区域可能即将爆发
score += 15
elif 55 < rsi < 70:
# RSI在强势区域但未超买
score += 25
elif 30 < rsi < 45:
# RSI在弱势区域但未超卖
score += 10
elif rsi >= 70:
# RSI超买
score += 5
elif rsi <= 30:
# RSI超卖
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:
logger.error(f"计算评分时出错: {str(e)}")
logger.exception(e)
raise
def get_recommendation(self, score: int) -> str:
"""
根据评分获取投资建议
Args:
score: 股票评分0-100
Returns:
投资建议文本
"""
if score >= 80:
return "强烈推荐"
elif score >= 70:
return "推荐"
elif score >= 60:
return "谨慎推荐"
elif score >= 40:
return "观望"
elif score >= 20:
return "不推荐"
else:
return "强烈不推荐"
def batch_score_stocks(self, stock_dfs: Dict[str, pd.DataFrame]) -> List[Tuple[str, int, str]]:
"""
批量评分多只股票
Args:
stock_dfs: 字典键为股票代码值为DataFrame
Returns:
评分结果列表,每项为(股票代码, 评分, 推荐)的三元组
"""
results = []
for stock_code, df in stock_dfs.items():
try:
score = self.calculate_score(df)
recommendation = self.get_recommendation(score)
results.append((stock_code, score, recommendation))
except Exception as e:
logger.error(f"评分股票 {stock_code} 时出错: {str(e)}")
# 按评分降序排序
results.sort(key=lambda x: x[1], reverse=True)
return results

View File

@@ -0,0 +1,187 @@
import pandas as pd
import numpy as np
from typing import Dict, Optional, Any
from logger import get_logger
# 获取日志器
logger = get_logger()
class TechnicalIndicator:
"""
技术指标计算服务
负责计算常见的股票技术指标
"""
def __init__(self, params: Optional[Dict[str, Any]] = None):
"""
初始化技术指标计算服务
Args:
params: 技术指标参数配置
"""
# 默认参数设置
self.params = params or {
'ma_periods': {'short': 5, 'medium': 20, 'long': 60},
'rsi_period': 14,
'bollinger_period': 20,
'bollinger_std': 2,
'volume_ma_period': 20,
'atr_period': 14
}
logger.debug(f"初始化TechnicalIndicator参数: {self.params}")
def calculate_ema(self, series: pd.Series, period: int) -> pd.Series:
"""
计算指数移动平均线
Args:
series: 价格序列
period: 周期
Returns:
EMA序列
"""
return series.ewm(span=period, adjust=False).mean()
def calculate_rsi(self, series: pd.Series, period: int) -> pd.Series:
"""
计算相对强弱指标(RSI)
Args:
series: 价格序列
period: 周期
Returns:
RSI序列
"""
delta = series.diff()
gain = delta.where(delta > 0, 0)
loss = -delta.where(delta < 0, 0)
avg_gain = gain.rolling(window=period).mean()
avg_loss = loss.rolling(window=period).mean()
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return rsi
def calculate_macd(self, series: pd.Series) -> tuple:
"""
计算MACD指标
Args:
series: 价格序列
Returns:
(MACD线, 信号线, 柱状图)的元组
"""
ema12 = self.calculate_ema(series, 12)
ema26 = self.calculate_ema(series, 26)
macd = ema12 - ema26
signal = self.calculate_ema(macd, 9)
histogram = macd - signal
return macd, signal, histogram
def calculate_bollinger_bands(self, series: pd.Series, period: int, std_dev: float) -> tuple:
"""
计算布林带
Args:
series: 价格序列
period: 周期
std_dev: 标准差倍数
Returns:
(中轨, 上轨, 下轨)的元组
"""
middle = series.rolling(window=period).mean()
std = series.rolling(window=period).std()
upper = middle + std_dev * std
lower = middle - std_dev * std
return middle, upper, lower
def calculate_atr(self, df: pd.DataFrame, period: int) -> pd.Series:
"""
计算平均真实波幅(ATR)
Args:
df: 包含High, Low, Close列的DataFrame
period: 周期
Returns:
ATR序列
"""
high = df['High']
low = df['Low']
close = df['Close']
tr1 = high - low
tr2 = abs(high - close.shift())
tr3 = abs(low - close.shift())
tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
atr = tr.rolling(window=period).mean()
return atr
def calculate_indicators(self, df: pd.DataFrame) -> pd.DataFrame:
"""
计算所有技术指标
Args:
df: 原始价格数据包含Open, High, Low, Close, Volume列
Returns:
添加了技术指标的DataFrame
"""
try:
# 复制数据框
result_df = df.copy()
# 移动平均线
for name, period in self.params['ma_periods'].items():
result_df[f'MA{period}'] = result_df['Close'].rolling(window=period).mean()
# RSI
result_df['RSI'] = self.calculate_rsi(result_df['Close'], self.params['rsi_period'])
# MACD
macd, signal, histogram = self.calculate_macd(result_df['Close'])
result_df['MACD'] = macd
result_df['Signal'] = signal
result_df['Histogram'] = histogram
# 布林带
middle, upper, lower = self.calculate_bollinger_bands(
result_df['Close'],
self.params['bollinger_period'],
self.params['bollinger_std']
)
result_df['BB_Middle'] = middle
result_df['BB_Upper'] = upper
result_df['BB_Lower'] = lower
# 成交量移动平均
result_df['Volume_MA'] = result_df['Volume'].rolling(window=self.params['volume_ma_period']).mean()
# 成交量比率
result_df['Volume_Ratio'] = result_df['Volume'] / result_df['Volume_MA']
# ATR
result_df['ATR'] = self.calculate_atr(result_df, self.params['atr_period'])
# 波动率 (过去20天收盘价的标准差/均值)
result_df['Volatility'] = result_df['Close'].rolling(window=20).std() / result_df['Close'].rolling(window=20).mean() * 100
return result_df
except Exception as e:
logger.error(f"计算技术指标时出错: {str(e)}")
logger.exception(e)
raise

View File

@@ -0,0 +1,154 @@
import asyncio
import pandas as pd
from typing import List, Dict, Any, Optional
from logger import get_logger
# 获取日志器
logger = get_logger()
class USStockServiceAsync:
"""
异步美股服务
提供美股数据的异步搜索和获取功能
"""
def __init__(self):
"""初始化异步美股服务"""
logger.debug("初始化USStockServiceAsync")
# 可选:添加缓存以减少频繁请求
self._cache = None
self._cache_timestamp = None
async def search_us_stocks(self, keyword: str) -> List[Dict[str, Any]]:
"""
异步搜索美股代码
Args:
keyword: 搜索关键词
Returns:
匹配的股票列表
"""
try:
logger.info(f"异步搜索美股: {keyword}")
# 使用线程池执行同步的akshare调用
df = await asyncio.to_thread(self._get_us_stocks_data)
# 模糊匹配搜索
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
})
# 限制只返回前10个结果
if len(formatted_results) >= 10:
break
logger.info(f"美股搜索完成,找到 {len(formatted_results)} 个匹配项限制显示前10个")
return formatted_results
except Exception as e:
error_msg = f"搜索美股代码失败: {str(e)}"
logger.error(error_msg)
logger.exception(e)
raise Exception(error_msg)
def _get_us_stocks_data(self) -> pd.DataFrame:
"""
获取美股数据(同步方法,将被异步方法调用)
Returns:
包含美股数据的DataFrame
"""
import akshare as ak
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"
})
return df
except Exception as e:
logger.error(f"获取美股数据失败: {str(e)}")
logger.exception(e)
raise Exception(f"获取美股数据失败: {str(e)}")
async def get_us_stock_detail(self, symbol: str) -> Dict[str, Any]:
"""
异步获取单个美股详细信息
Args:
symbol: 股票代码
Returns:
股票详细信息
"""
try:
logger.info(f"获取美股详情: {symbol}")
# 使用线程池执行同步的akshare调用
df = await asyncio.to_thread(self._get_us_stocks_data)
# 精确匹配股票代码
result = df[df['symbol'] == symbol]
if len(result) == 0:
raise Exception(f"未找到股票代码: {symbol}")
# 获取第一行数据
row = result.iloc[0]
# 格式化为字典
stock_detail = {
'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,
'price_change': float(row['price_change']) if pd.notna(row['price_change']) else 0.0,
'price_change_percent': float(row['price_change_percent'].strip('%'))/100 if pd.notna(row['price_change_percent']) else 0.0,
'open': float(row['open']) if pd.notna(row['open']) else 0.0,
'high': float(row['high']) if pd.notna(row['high']) else 0.0,
'low': float(row['low']) if pd.notna(row['low']) else 0.0,
'pre_close': float(row['pre_close']) if pd.notna(row['pre_close']) else 0.0,
'market_value': float(row['market_value']) if pd.notna(row['market_value']) else 0.0,
'pe_ratio': float(row['pe_ratio']) if pd.notna(row['pe_ratio']) else 0.0,
'volume': float(row['volume']) if pd.notna(row['volume']) else 0.0,
'turnover': float(row['turnover']) if pd.notna(row['turnover']) else 0.0
}
logger.info(f"获取美股详情成功: {symbol}")
return stock_detail
except Exception as e:
error_msg = f"获取美股详情失败: {str(e)}"
logger.error(error_msg)
logger.exception(e)
raise Exception(error_msg)

View File

@@ -1,760 +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流式响应")
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
else:
logger.warning(f"收到空的 choices 列表: {data_content}")
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} 只出错")

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,60 +1,208 @@
from flask import Flask, render_template, request, jsonify, Response, stream_with_context from fastapi import FastAPI, Request, Response, Depends, HTTPException, BackgroundTasks
from stock_analyzer import StockAnalyzer from fastapi.responses import JSONResponse, StreamingResponse, FileResponse, RedirectResponse
from us_stock_service import USStockService from fastapi.staticfiles import StaticFiles
from fund_service import FundService # 新增导入 from fastapi.middleware.cors import CORSMiddleware
import threading from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any, Generator
from services.stock_analyzer_service import StockAnalyzerService
from services.us_stock_service_async import USStockServiceAsync
from services.fund_service_async import FundServiceAsync
import os import os
import traceback import httpx
import requests
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 json
import secrets
from datetime import datetime, timedelta
from jose import JWTError, jwt
load_dotenv() load_dotenv()
# 获取日志器 # 获取日志器
logger = get_logger() logger = get_logger()
app = Flask(__name__) # JWT相关配置
analyzer = StockAnalyzer() SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_hex(32))
us_stock_service = USStockService() ALGORITHM = "HS256"
fund_service = FundService() # 新增服务实例 ACCESS_TOKEN_EXPIRE_MINUTES = 10080 # Token过期时间一周
@app.route('/') LOGIN_PASSWORD = os.getenv("LOGIN_PASSWORD", "")
def index(): print(LOGIN_PASSWORD)
announcement = os.getenv('ANNOUNCEMENT_TEXT') or None
# 获取默认API配置信息
default_api_url = os.getenv('API_URL', '')
default_api_model = os.getenv('API_MODEL', 'gpt-3.5-turbo')
default_api_timeout = os.getenv('API_TIMEOUT', '60')
# 不传递API_KEY到前端出于安全考虑
return render_template('index.html',
announcement=announcement,
default_api_url=default_api_url,
default_api_model=default_api_model,
default_api_timeout=default_api_timeout)
@app.route('/analyze', methods=['POST']) # 是否需要登录
def analyze(): REQUIRE_LOGIN = bool(LOGIN_PASSWORD.strip())
app = FastAPI(
title="Stock Scanner API",
description="异步股票分析API",
version="1.0.0"
)
# 添加CORS中间件
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # 开发环境允许所有来源,生产环境应该限制
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 设置静态文件
frontend_dist = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'frontend', 'dist')
if os.path.exists(frontend_dist):
app.mount("/assets", StaticFiles(directory=os.path.join(frontend_dist, "assets")), name="assets")
# 初始化异步服务
# StockAnalyzerService 不需要全局初始化,在 /analyze 接口中按需创建
us_stock_service = USStockServiceAsync()
fund_service = FundServiceAsync()
# 定义请求和响应模型
class AnalyzeRequest(BaseModel):
stock_codes: List[str]
market_type: str = "A"
api_url: Optional[str] = None
api_key: Optional[str] = None
api_model: Optional[str] = None
api_timeout: Optional[str] = None
class TestAPIRequest(BaseModel):
api_url: str
api_key: str
api_model: Optional[str] = None
api_timeout: Optional[int] = 10
class LoginRequest(BaseModel):
password: str
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")
async def get_config():
"""返回系统配置信息"""
config = {
'announcement': os.getenv('ANNOUNCEMENT_TEXT') or '',
'default_api_url': os.getenv('API_URL', ''),
'default_api_model': os.getenv('API_MODEL', ''),
'default_api_timeout': os.getenv('API_TIMEOUT', '60')
}
return config
# AI分析股票
@app.post("/analyze")
async def analyze(request: AnalyzeRequest, username: str = Depends(verify_token)):
try: try:
logger.info("开始处理分析请求") logger.info("开始处理分析请求")
data = request.json stock_codes = request.stock_codes
stock_codes = data.get('stock_codes', []) market_type = request.market_type
market_type = data.get('market_type', 'A')
# 后端再次去重,确保安全
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配置
custom_api_url = data.get('api_url') custom_api_url = request.api_url
custom_api_key = data.get('api_key') custom_api_key = request.api_key
custom_api_model = data.get('api_model') custom_api_model = request.api_model
custom_api_timeout = data.get('api_timeout') custom_api_timeout = request.api_timeout
logger.debug(f"自定义API配置: URL={custom_api_url}, 模型={custom_api_model}, API Key={'已提供' if custom_api_key else '未提供'}, Timeout={custom_api_timeout}") logger.debug(f"自定义API配置: URL={custom_api_url}, 模型={custom_api_model}, API Key={'已提供' if custom_api_key else '未提供'}, Timeout={custom_api_timeout}")
# 创建新的分析器实例,使用自定义配置 # 创建新的分析器实例,使用自定义配置
custom_analyzer = StockAnalyzer( custom_analyzer = StockAnalyzerService(
custom_api_url=custom_api_url, custom_api_url=custom_api_url,
custom_api_key=custom_api_key, custom_api_key=custom_api_key,
custom_api_model=custom_api_model, custom_api_model=custom_api_model,
@@ -63,34 +211,41 @@ def analyze():
if not stock_codes: if not stock_codes:
logger.warning("未提供股票代码") logger.warning("未提供股票代码")
return jsonify({'error': '请输入代码'}), 400 raise HTTPException(status_code=400, detail="请输入代码")
# 使用流式响应 # 定义流式生成器
def generate(): async def generate_stream():
if len(stock_codes) == 1: if len(stock_codes) == 1:
# 单个股票分析流式处理 # 单个股票分析流式处理
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} 的流式响应")
chunk_count = 0 chunk_count = 0
for chunk in custom_analyzer.analyze_stock(stock_code, market_type, stream=True):
# 使用异步生成器
async for chunk in custom_analyzer.analyze_stock(stock_code, market_type, stream=True):
chunk_count += 1 chunk_count += 1
yield chunk + '\n' yield chunk + '\n'
logger.info(f"股票 {stock_code} 流式分析完成,共发送 {chunk_count} 个块") logger.info(f"股票 {stock_code} 流式分析完成,共发送 {chunk_count} 个块")
else: else:
# 批量分析流式处理 # 批量分析流式处理
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"开始处理批量股票的流式响应")
chunk_count = 0 chunk_count = 0
for chunk in custom_analyzer.scan_stocks(
# 使用异步生成器
async for chunk in custom_analyzer.scan_stocks(
[code.strip() for code in stock_codes], [code.strip() for code in stock_codes],
min_score=0, min_score=0,
market_type=market_type, market_type=market_type,
@@ -98,106 +253,175 @@ def analyze():
): ):
chunk_count += 1 chunk_count += 1
yield chunk + '\n' yield chunk + '\n'
logger.info(f"批量流式分析完成,共发送 {chunk_count} 个块") logger.info(f"批量流式分析完成,共发送 {chunk_count} 个块")
logger.info("成功创建流式响应生成器") logger.info("成功创建流式响应生成器")
return Response(stream_with_context(generate()), mimetype='application/json') return StreamingResponse(generate_stream(), media_type='application/json')
except Exception as e: except Exception as e:
error_msg = f"分析时出错: {str(e)}" error_msg = f"分析时出错: {str(e)}"
logger.error(error_msg) logger.error(error_msg)
logger.exception(e) logger.exception(e)
return jsonify({'error': error_msg}), 500 raise HTTPException(status_code=500, detail=error_msg)
@app.route('/search_us_stocks', methods=['GET']) # 搜索美股代码
def search_us_stocks(): @app.get("/search_us_stocks")
async def search_us_stocks(keyword: str = "", username: str = Depends(verify_token)):
try: try:
keyword = request.args.get('keyword', '')
if not keyword: if not keyword:
return jsonify({'error': '请输入搜索关键词'}), 400 raise HTTPException(status_code=400, detail="请输入搜索关键词")
results = us_stock_service.search_us_stocks(keyword) # 直接使用异步服务的异步方法
return jsonify({'results': results}) results = await us_stock_service.search_us_stocks(keyword)
return {"results": results}
except Exception as e: except Exception as e:
print(f"搜索美股代码时出错: {str(e)}") logger.error(f"搜索美股代码时出错: {str(e)}")
return jsonify({'error': str(e)}), 500 raise HTTPException(status_code=500, detail=str(e))
# 添加基金搜索路由 # 搜索基金代码
@app.route('/search_funds', methods=['GET']) @app.get("/search_funds")
def search_funds(): async def search_funds(keyword: str = "", market_type: str = "", username: str = Depends(verify_token)):
try: try:
keyword = request.args.get('keyword', '')
market_type = request.args.get('market_type', '')
if not keyword: if not keyword:
return jsonify({'error': '请输入搜索关键词'}), 400 raise HTTPException(status_code=400, detail="请输入搜索关键词")
results = fund_service.search_funds(keyword, market_type) # 直接使用异步服务的异步方法
return jsonify({'results': results}) results = await fund_service.search_funds(keyword, market_type)
return {"results": results}
except Exception as e: except Exception as e:
logger.error(f"搜索基金代码时出错: {str(e)}") logger.error(f"搜索基金代码时出错: {str(e)}")
return jsonify({'error': str(e)}), 500 raise HTTPException(status_code=500, detail=str(e))
@app.route('/test_api_connection', methods=['POST']) # 获取美股详情
def test_api_connection(): @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")
async def test_api_connection(request: TestAPIRequest, username: str = Depends(verify_token)):
"""测试API连接""" """测试API连接"""
try: try:
logger.info("开始测试API连接") logger.info("开始测试API连接")
data = request.json api_url = request.api_url
api_url = data.get('api_url') api_key = request.api_key
api_key = data.get('api_key') api_model = request.api_model
api_model = data.get('api_model') api_timeout = request.api_timeout
api_timeout = data.get('api_timeout', 10) # 默认测试连接超时为10秒
logger.debug(f"测试API连接: URL={api_url}, 模型={api_model}, API Key={'已提供' if api_key else '未提供'}, Timeout={api_timeout}") logger.debug(f"测试API连接: URL={api_url}, 模型={api_model}, API Key={'已提供' if api_key else '未提供'}, Timeout={api_timeout}")
if not api_url: if not api_url:
logger.warning("未提供API URL") logger.warning("未提供API URL")
return jsonify({'error': '请提供API URL'}), 400 raise HTTPException(status_code=400, detail="请提供API URL")
if not api_key: if not api_key:
logger.warning("未提供API Key") logger.warning("未提供API Key")
return jsonify({'error': '请提供API Key'}), 400 raise HTTPException(status_code=400, detail="请提供API Key")
# 构建API URL # 构建API URL
test_url = APIUtils.format_api_url(api_url) test_url = APIUtils.format_api_url(api_url)
logger.debug(f"完整API测试URL: {test_url}") logger.debug(f"完整API测试URL: {test_url}")
# 发送测试请求 # 使用异步HTTP客户端发送测试请求
response = requests.post( async with httpx.AsyncClient(timeout=float(api_timeout)) as client:
test_url, response = await client.post(
headers={ test_url,
"Authorization": f"Bearer {api_key}", headers={
"Content-Type": "application/json" "Authorization": f"Bearer {api_key}",
}, "Content-Type": "application/json"
json={ },
"model": api_model or "gpt-3.5-turbo", json={
"messages": [ "model": api_model or "",
{"role": "user", "content": "Hello, this is a test message. Please respond with 'API connection successful'."} "messages": [
], {"role": "user", "content": "Hello, this is a test message. Please respond with 'API connection successful'."}
"max_tokens": 20 ],
}, "max_tokens": 20
timeout=int(api_timeout) }
) )
# 检查响应 # 检查响应
if response.status_code == 200: if response.status_code == 200:
logger.info(f"API 连接测试成功: {response.status_code}") logger.info(f"API 连接测试成功: {response.status_code}")
return jsonify({'success': True, 'message': 'API 连接测试成功'}) return {"success": True, "message": "API 连接测试成功"}
else: else:
error_message = response.json().get('error', {}).get('message', '未知错误') error_data = response.json()
error_message = error_data.get('error', {}).get('message', '未知错误')
logger.warning(f"API连接测试失败: {response.status_code} - {error_message}") logger.warning(f"API连接测试失败: {response.status_code} - {error_message}")
return jsonify({'success': False, 'message': f'API 连接测试失败: {error_message}', 'status_code': response.status_code}), 400 return JSONResponse(
status_code=400,
content={"success": False, "message": f"API 连接测试失败: {error_message}", "status_code": response.status_code}
)
except requests.exceptions.RequestException as e: except httpx.RequestError as e:
logger.error(f"API 连接请求错误: {str(e)}") logger.error(f"API 连接请求错误: {str(e)}")
return jsonify({'success': False, 'message': f'请求错误: {str(e)}'}), 400 return JSONResponse(
status_code=400,
content={"success": False, "message": f"请求错误: {str(e)}"}
)
except Exception as e: except Exception as e:
logger.error(f"测试 API 连接时出错: {str(e)}") logger.error(f"测试 API 连接时出错: {str(e)}")
logger.exception(e) logger.exception(e)
return jsonify({'success': False, 'message': f'API 测试连接时出错: {str(e)}'}), 500 return JSONResponse(
status_code=500,
content={"success": False, "message": f"API 测试连接时出错: {str(e)}"}
)
# 检查是否需要登录
@app.get("/need_login")
async def need_login():
"""检查是否需要登录"""
return {"require_login": REQUIRE_LOGIN}
# 前端路由处理必须放在所有API路由之后
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str, request: Request):
"""处理所有前端路由请求返回index.html"""
# 排除API路径和静态资源
if full_path.startswith(("api/", "assets/", "docs", "openapi.json")) or \
full_path in ["check_auth", "config", "analyze",
"search_us_stocks", "search_funds",
"test_api_connection", "us_stock_detail",
"fund_detail"]:
# 对于API路径让FastAPI继续处理
raise HTTPException(status_code=404, detail="API路径不存在")
# 检查是否使用前端构建版本
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分析系统启动")
app.run(host='0.0.0.0', port=8888, debug=True) uvicorn.run("web_server:app", host="0.0.0.0", port=8888, reload=True)