1
.env
1
.env
@@ -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
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# API配置
|
||||||
|
API_KEY=
|
||||||
|
API_URL=
|
||||||
|
API_MODEL=
|
||||||
|
API_TIMEOUT=60
|
||||||
|
# 公告文本
|
||||||
|
ANNOUNCEMENT_TEXT=欢迎使用!
|
||||||
|
# 登录配置(为空时不需要登录,否则需要经过登录接口验证)
|
||||||
|
LOGIN_PASSWORD=
|
||||||
184
.github/workflows/docker-image.yml
vendored
184
.github/workflows/docker-image.yml
vendored
@@ -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
17
.gitignore
vendored
@@ -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]
|
||||||
|
|||||||
40
Dockerfile
40
Dockerfile
@@ -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"]
|
||||||
16
README.md
16
README.md
@@ -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
48
docker-compose.prod.yml
Normal 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
|
||||||
@@ -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
36
frontend/Dockerfile
Normal 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
5
frontend/README.md
Normal 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
14
frontend/index.html
Normal 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
85
frontend/nginx.conf
Normal 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.html(SPA应用需要)
|
||||||
|
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
4314
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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
1
frontend/public/vite.svg
Normal 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
40
frontend/src/App.vue
Normal 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>
|
||||||
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
128
frontend/src/components/AnnouncementBanner.vue
Normal file
128
frontend/src/components/AnnouncementBanner.vue
Normal 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>
|
||||||
642
frontend/src/components/ApiConfigPanel.vue
Normal file
642
frontend/src/components/ApiConfigPanel.vue
Normal 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>
|
||||||
536
frontend/src/components/LoginPage.vue
Normal file
536
frontend/src/components/LoginPage.vue
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
<template>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-background">
|
||||||
|
<div class="login-shape shape1"></div>
|
||||||
|
<div class="login-shape shape2"></div>
|
||||||
|
<div class="login-shape shape3"></div>
|
||||||
|
<div class="login-shape shape4"></div>
|
||||||
|
<div class="login-shape shape5"></div>
|
||||||
|
<div class="login-particle particle1"></div>
|
||||||
|
<div class="login-particle particle2"></div>
|
||||||
|
<div class="login-particle particle3"></div>
|
||||||
|
<div class="login-particle particle4"></div>
|
||||||
|
<div class="login-particle particle5"></div>
|
||||||
|
<div class="login-particle particle6"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-card class="login-card" :bordered="false">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="login-logo">
|
||||||
|
<n-icon :component="BarChartIcon" color="#2080f0" size="36" class="logo-icon" />
|
||||||
|
</div>
|
||||||
|
<h1 class="login-title">股票AI分析系统</h1>
|
||||||
|
<p class="login-subtitle">使用AI技术分析股票市场趋势</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<n-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formValue"
|
||||||
|
:rules="rules"
|
||||||
|
label-placement="left"
|
||||||
|
label-width="0"
|
||||||
|
require-mark-placement="right-hanging"
|
||||||
|
class="login-form"
|
||||||
|
>
|
||||||
|
<n-form-item path="password">
|
||||||
|
<n-input
|
||||||
|
v-model:value="formValue.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
@keyup.enter="handleLogin"
|
||||||
|
size="large"
|
||||||
|
class="login-input"
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<n-icon :component="LockClosedIcon" />
|
||||||
|
</template>
|
||||||
|
</n-input>
|
||||||
|
</n-form-item>
|
||||||
|
|
||||||
|
<div class="login-button-container">
|
||||||
|
<n-button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
block
|
||||||
|
:loading="loading"
|
||||||
|
@click="handleLogin"
|
||||||
|
class="login-button"
|
||||||
|
>
|
||||||
|
{{ loading ? '登录中...' : '登 录' }}
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
</n-form>
|
||||||
|
|
||||||
|
<div class="login-footer">
|
||||||
|
<n-text depth="3">© {{ new Date().getFullYear() }} 股票AI分析系统</n-text>
|
||||||
|
</div>
|
||||||
|
</n-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted, h } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import {
|
||||||
|
NCard,
|
||||||
|
NForm,
|
||||||
|
NFormItem,
|
||||||
|
NInput,
|
||||||
|
NButton,
|
||||||
|
NIcon,
|
||||||
|
NText,
|
||||||
|
useMessage,
|
||||||
|
useNotification
|
||||||
|
} from 'naive-ui';
|
||||||
|
import type { FormInst, FormRules } from 'naive-ui';
|
||||||
|
import {
|
||||||
|
BarChartOutline as BarChartIcon,
|
||||||
|
LockClosedOutline as LockClosedIcon,
|
||||||
|
NotificationsOutline as NotificationsIcon
|
||||||
|
} from '@vicons/ionicons5';
|
||||||
|
import { apiService } from '@/services/api';
|
||||||
|
import type { LoginRequest } from '@/types';
|
||||||
|
|
||||||
|
const message = useMessage();
|
||||||
|
const notification = useNotification();
|
||||||
|
const router = useRouter();
|
||||||
|
const formRef = ref<FormInst | null>(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const formValue = reactive({
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
password: [
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: '请输入密码'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示系统公告
|
||||||
|
const showAnnouncement = (content: string) => {
|
||||||
|
if (!content) return;
|
||||||
|
|
||||||
|
notification.info({
|
||||||
|
title: '系统公告',
|
||||||
|
content: () => h('div', { style: 'display: flex; align-items: center;' }, [
|
||||||
|
h(NIcon, { component: NotificationsIcon, style: 'margin-right: 8px; font-size: 18px;' }),
|
||||||
|
h('span', null, content)
|
||||||
|
]),
|
||||||
|
duration: 10000,
|
||||||
|
keepAliveOnHover: true,
|
||||||
|
closable: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 页面加载时检查是否已登录并获取系统公告
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// 获取系统配置
|
||||||
|
const config = await apiService.getConfig();
|
||||||
|
if (config.announcement) {
|
||||||
|
showAnnouncement(config.announcement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不重复检查是否需要登录,因为路由守卫已经做了这个检查
|
||||||
|
// 直接检查是否已登录
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
return; // 没有token,停留在登录页
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = await apiService.checkAuth();
|
||||||
|
console.log('登录页面认证检查结果:', isAuthenticated);
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// 已登录,跳转到主页
|
||||||
|
console.log('已登录,跳转到主页');
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('认证检查或获取配置失败:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
formRef.value?.validate(async (errors) => {
|
||||||
|
if (errors) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loginRequest: LoginRequest = {
|
||||||
|
password: formValue.password
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await apiService.login(loginRequest);
|
||||||
|
|
||||||
|
if (response.access_token) {
|
||||||
|
message.success('登录成功');
|
||||||
|
// 登录成功后跳转到主页
|
||||||
|
router.push('/');
|
||||||
|
} else {
|
||||||
|
message.error(response.message || '登录失败');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('登录失败:', error);
|
||||||
|
message.error(error.message || '登录失败');
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes float {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0px) rotate(0deg);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px) rotate(5deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px) rotate(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floatParticle {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0) translateX(0);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateY(-15px) translateX(15px);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(0) translateX(30px);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(15px) translateX(15px);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0) translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-background {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-shape {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: pulse 8s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape1 {
|
||||||
|
width: 50vw;
|
||||||
|
height: 50vw;
|
||||||
|
max-width: 600px;
|
||||||
|
max-height: 600px;
|
||||||
|
background: linear-gradient(135deg, rgba(32, 128, 240, 0.2) 0%, rgba(32, 128, 240, 0.1) 100%);
|
||||||
|
top: -15%;
|
||||||
|
right: -10%;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape2 {
|
||||||
|
width: 60vw;
|
||||||
|
height: 60vw;
|
||||||
|
max-width: 800px;
|
||||||
|
max-height: 800px;
|
||||||
|
background: linear-gradient(135deg, rgba(32, 128, 240, 0.1) 0%, rgba(32, 128, 240, 0.05) 100%);
|
||||||
|
bottom: -30%;
|
||||||
|
left: -15%;
|
||||||
|
animation-delay: 2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape3 {
|
||||||
|
width: 30vw;
|
||||||
|
height: 30vw;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 400px;
|
||||||
|
background: linear-gradient(135deg, rgba(32, 128, 240, 0.15) 0%, rgba(32, 128, 240, 0.05) 100%);
|
||||||
|
top: 20%;
|
||||||
|
right: 15%;
|
||||||
|
animation-delay: 4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape4 {
|
||||||
|
width: 25vw;
|
||||||
|
height: 25vw;
|
||||||
|
max-width: 300px;
|
||||||
|
max-height: 300px;
|
||||||
|
background: linear-gradient(135deg, rgba(32, 128, 240, 0.1) 0%, rgba(32, 128, 240, 0.05) 100%);
|
||||||
|
top: 60%;
|
||||||
|
left: 10%;
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape5 {
|
||||||
|
width: 15vw;
|
||||||
|
height: 15vw;
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
|
background: linear-gradient(135deg, rgba(32, 128, 240, 0.15) 0%, rgba(32, 128, 240, 0.1) 100%);
|
||||||
|
top: 30%;
|
||||||
|
left: 20%;
|
||||||
|
animation-delay: 3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-particle {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.6);
|
||||||
|
animation: floatParticle 15s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle1 {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
top: 20%;
|
||||||
|
left: 30%;
|
||||||
|
animation-duration: 20s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle2 {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
top: 40%;
|
||||||
|
left: 70%;
|
||||||
|
animation-duration: 25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle3 {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
top: 70%;
|
||||||
|
left: 40%;
|
||||||
|
animation-duration: 18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle4 {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
top: 30%;
|
||||||
|
left: 60%;
|
||||||
|
animation-duration: 22s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle5 {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
top: 60%;
|
||||||
|
left: 20%;
|
||||||
|
animation-duration: 15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.particle6 {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
top: 80%;
|
||||||
|
left: 80%;
|
||||||
|
animation-duration: 30s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
width: 420px;
|
||||||
|
max-width: 90%;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
z-index: 1;
|
||||||
|
padding: 30px;
|
||||||
|
animation: fadeIn 0.8s ease-out;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card:hover {
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-logo {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-icon {
|
||||||
|
animation: float 6s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
background: linear-gradient(90deg, #2080f0, #44a4ff);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
animation: fadeIn 0.8s ease-out 0.2s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-input:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button-container {
|
||||||
|
margin-top: 30px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
animation: fadeIn 0.8s ease-out 0.4s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
height: 48px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
background: linear-gradient(90deg, #2080f0, #44a4ff);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(32, 128, 240, 0.3);
|
||||||
|
background: linear-gradient(90deg, #1c72d9, #3b9aff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0 0;
|
||||||
|
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||||
|
margin-top: 20px;
|
||||||
|
animation: fadeIn 0.8s ease-out 0.6s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.login-card {
|
||||||
|
width: 90%;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-subtitle {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
height: 44px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动设备上的背景形状调整 */
|
||||||
|
.shape1 {
|
||||||
|
width: 70vw;
|
||||||
|
height: 70vw;
|
||||||
|
top: -30%;
|
||||||
|
right: -20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape2 {
|
||||||
|
width: 80vw;
|
||||||
|
height: 80vw;
|
||||||
|
bottom: -40%;
|
||||||
|
left: -30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape3 {
|
||||||
|
width: 50vw;
|
||||||
|
height: 50vw;
|
||||||
|
top: 50%;
|
||||||
|
right: -20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shape4, .shape5 {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-particle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
188
frontend/src/components/MarketTimeDisplay.vue
Normal file
188
frontend/src/components/MarketTimeDisplay.vue
Normal 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>
|
||||||
994
frontend/src/components/StockAnalysisApp.vue
Normal file
994
frontend/src/components/StockAnalysisApp.vue
Normal 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>
|
||||||
1025
frontend/src/components/StockCard.vue
Normal file
1025
frontend/src/components/StockCard.vue
Normal file
File diff suppressed because it is too large
Load Diff
222
frontend/src/components/StockSearch.vue
Normal file
222
frontend/src/components/StockSearch.vue
Normal 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
8
frontend/src/main.ts
Normal 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')
|
||||||
89
frontend/src/router/index.ts
Normal file
89
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router';
|
||||||
|
import type { RouteRecordRaw } from 'vue-router';
|
||||||
|
import { apiService } from '@/services/api';
|
||||||
|
import StockAnalysisApp from '@/components/StockAnalysisApp.vue';
|
||||||
|
import LoginPage from '@/components/LoginPage.vue';
|
||||||
|
|
||||||
|
const routes: Array<RouteRecordRaw> = [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
name: 'Home',
|
||||||
|
component: StockAnalysisApp,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'Login',
|
||||||
|
component: LoginPage,
|
||||||
|
meta: { requiresAuth: false }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
redirect: '/'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(),
|
||||||
|
routes
|
||||||
|
});
|
||||||
|
|
||||||
|
// 全局前置守卫
|
||||||
|
router.beforeEach(async (to, from, next) => {
|
||||||
|
console.log(`路由跳转: 从 ${from.path} 到 ${to.path}`);
|
||||||
|
|
||||||
|
// 如果已经在登录页面,直接通过
|
||||||
|
if (to.path === '/login') {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查路由是否需要认证
|
||||||
|
if (to.matched.some(record => record.meta.requiresAuth)) {
|
||||||
|
console.log('当前路由需要认证');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先检查系统是否需要登录
|
||||||
|
const requireLogin = await apiService.checkNeedLogin();
|
||||||
|
console.log('系统是否需要登录:', requireLogin);
|
||||||
|
|
||||||
|
if (!requireLogin) {
|
||||||
|
// 系统不需要登录,直接通过
|
||||||
|
console.log('系统不需要登录,允许访问');
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统需要登录,检查本地是否有token
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
if (!token) {
|
||||||
|
console.log('本地没有token,跳转到登录页');
|
||||||
|
next({ name: 'Login' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAuthenticated = await apiService.checkAuth();
|
||||||
|
console.log('认证检查结果:', isAuthenticated);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
// 未登录,重定向到登录页
|
||||||
|
console.log('认证失败,跳转到登录页');
|
||||||
|
next({ name: 'Login' });
|
||||||
|
} else {
|
||||||
|
// 已登录,允许访问
|
||||||
|
console.log('认证成功,允许访问');
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('认证检查失败:', error);
|
||||||
|
// 认证检查失败,重定向到登录页
|
||||||
|
next({ name: 'Login' });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 不需要认证的路由,直接访问
|
||||||
|
console.log('当前路由不需要认证,直接访问');
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
146
frontend/src/services/api.ts
Normal file
146
frontend/src/services/api.ts
Normal 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
79
frontend/src/style.css
Normal 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
109
frontend/src/types/index.ts
Normal 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
201
frontend/src/utils/index.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
172
frontend/src/utils/stockValidator.ts
Normal file
172
frontend/src/utils/stockValidator.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* 股票代码验证工具
|
||||||
|
* 用于验证不同市场类型的股票代码格式
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 市场类型枚举
|
||||||
|
*/
|
||||||
|
export enum MarketType {
|
||||||
|
A = 'A', // A股
|
||||||
|
HK = 'HK', // 港股
|
||||||
|
US = 'US', // 美股
|
||||||
|
ETF = 'ETF', // ETF基金
|
||||||
|
LOF = 'LOF' // LOF基金
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证A股股票代码
|
||||||
|
* @param code 股票代码
|
||||||
|
* @returns 是否为有效的A股代码
|
||||||
|
*/
|
||||||
|
export const validateAStock = (code: string): boolean => {
|
||||||
|
// 上海证券交易所股票代码以6开头,6位数字
|
||||||
|
// 深圳证券交易所股票代码以0或3开头,6位数字
|
||||||
|
// 科创板股票代码以688开头,6位数字
|
||||||
|
// 北京证券交易所股票代码以8开头,一般为5位数字(如80XXX)
|
||||||
|
// 注意:中小板、创业板代码格式已合并处理
|
||||||
|
|
||||||
|
// 验证上海证券交易所(以6开头的6位数字)
|
||||||
|
if (code.startsWith('6') && /^\d{6}$/.test(code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证深圳证券交易所(以0或3开头的6位数字)
|
||||||
|
if ((code.startsWith('0') || code.startsWith('3')) && /^\d{6}$/.test(code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证科创板(以688开头的6位数字)
|
||||||
|
if (code.startsWith('688') && /^\d{6}$/.test(code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证北京证券交易所(以8开头的股票)
|
||||||
|
// 北交所股票一般是5位数字,格式为8xxxx
|
||||||
|
if (code.startsWith('8') && /^\d{5}$/.test(code)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证港股股票代码
|
||||||
|
* @param code 股票代码
|
||||||
|
* @returns 是否为有效的港股代码
|
||||||
|
*/
|
||||||
|
export const validateHKStock = (code: string): boolean => {
|
||||||
|
// 港股通常是5位数字
|
||||||
|
return /^\d{5}$/.test(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证美股股票代码
|
||||||
|
* @param code 股票代码
|
||||||
|
* @returns 是否为有效的美股代码
|
||||||
|
*/
|
||||||
|
export const validateUSStock = (code: string): boolean => {
|
||||||
|
// 美股代码通常由字母组成,长度在1-5之间
|
||||||
|
return /^[A-Za-z]{1,5}$/.test(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证ETF/LOF基金代码
|
||||||
|
* @param code 基金代码
|
||||||
|
* @returns 是否为有效的基金代码
|
||||||
|
*/
|
||||||
|
export const validateFund = (code: string): boolean => {
|
||||||
|
// 基金代码通常为6位数字
|
||||||
|
return /^\d{6}$/.test(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据市场类型验证股票代码
|
||||||
|
* @param code 股票代码
|
||||||
|
* @param marketType 市场类型
|
||||||
|
* @returns 包含验证结果和错误信息的对象
|
||||||
|
*/
|
||||||
|
export const validateStockCode = (
|
||||||
|
code: string,
|
||||||
|
marketType: MarketType
|
||||||
|
): { valid: boolean; errorMessage?: string } => {
|
||||||
|
|
||||||
|
if (!code || code.trim() === '') {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorMessage: '股票代码不能为空'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (marketType) {
|
||||||
|
case MarketType.A:
|
||||||
|
if (!validateAStock(code)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorMessage: `无效的A股股票代码格式: ${code}。A股代码应以0、3、6、688或8开头,且为6位数字或5位数字`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MarketType.HK:
|
||||||
|
if (!validateHKStock(code)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorMessage: `无效的港股代码格式: ${code}。港股代码应为5位数字`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MarketType.US:
|
||||||
|
if (!validateUSStock(code)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorMessage: `无效的美股代码格式: ${code}。美股代码应为1-5位字母`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case MarketType.ETF:
|
||||||
|
case MarketType.LOF:
|
||||||
|
if (!validateFund(code)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorMessage: `无效的${marketType}基金代码格式: ${code}。基金代码应为6位数字`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
errorMessage: `不支持的市场类型: ${marketType}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量验证多个股票代码
|
||||||
|
* @param codes 股票代码数组
|
||||||
|
* @param marketType 市场类型
|
||||||
|
* @returns 包含所有无效代码及其错误信息的数组
|
||||||
|
*/
|
||||||
|
export const validateMultipleStockCodes = (
|
||||||
|
codes: string[],
|
||||||
|
marketType: MarketType
|
||||||
|
): { code: string; errorMessage: string }[] => {
|
||||||
|
const invalidCodes: { code: string; errorMessage: string }[] = [];
|
||||||
|
|
||||||
|
for (const code of codes) {
|
||||||
|
const result = validateStockCode(code, marketType);
|
||||||
|
if (!result.valid && result.errorMessage) {
|
||||||
|
invalidCodes.push({
|
||||||
|
code,
|
||||||
|
errorMessage: result.errorMessage
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return invalidCodes;
|
||||||
|
};
|
||||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
18
frontend/tsconfig.app.json
Normal file
18
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
25
frontend/tsconfig.node.json
Normal file
25
frontend/tsconfig.node.json
Normal 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
68
frontend/vite.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -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)}")
|
|
||||||
@@ -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
2
services/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# services包初始化文件
|
||||||
|
# 用于组织股票分析服务的各个模块
|
||||||
456
services/ai_analyzer.py
Normal file
456
services/ai_analyzer.py
Normal 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] + "..."
|
||||||
228
services/fund_service_async.py
Normal file
228
services/fund_service_async.py
Normal 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)
|
||||||
269
services/stock_analyzer_service.py
Normal file
269
services/stock_analyzer_service.py
Normal 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})
|
||||||
260
services/stock_data_provider.py
Normal file
260
services/stock_data_provider.py
Normal 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
128
services/stock_scorer.py
Normal 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
|
||||||
187
services/technical_indicator.py
Normal file
187
services/technical_indicator.py
Normal 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
|
||||||
154
services/us_stock_service_async.py
Normal file
154
services/us_stock_service_async.py
Normal 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)
|
||||||
@@ -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} 只出错")
|
|
||||||
1287
templates/index.html
1287
templates/index.html
File diff suppressed because it is too large
Load Diff
@@ -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)}")
|
|
||||||
418
web_server.py
418
web_server.py
@@ -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)
|
||||||
Reference in New Issue
Block a user