Init: 导入源码

This commit is contained in:
Kevin Wong
2026-01-09 09:48:57 +08:00
parent 2fc6c128f3
commit 612c242218
65 changed files with 26150 additions and 315 deletions

313
.gitignore vendored
View File

@@ -1,314 +1,3 @@
# ---> Node
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
.cache/

150
Nginx.conf Normal file
View File

@@ -0,0 +1,150 @@
# HTTP重定向到HTTPS
server {
listen 80;
server_name rongye.xyz www.rongye.xyz 52.91.169.148;
return 301 https://$server_name$request_uri;
}
# HTTPS主配置
server {
listen 443 ssl http2;
server_name rongye.xyz www.rongye.xyz;
# Let's Encrypt SSL证书配置
ssl_certificate /etc/letsencrypt/live/rongye.xyz/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/rongye.xyz/privkey.pem;
# SSL安全配置
ssl_session_timeout 1d;
ssl_session_cache shared:MozTLS:10m;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# 增加上传文件大小限制(支持语音文件上传)
client_max_body_size 20M;
# 设置超时时间
proxy_connect_timeout 75s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# 前端静态文件
location / {
root /home/ubuntu/my-ai-website-clean/frontend/build;
try_files $uri /index.html;
# 添加缓存控制
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
# ===== API路由 =====
location /users/ {
proxy_pass http://127.0.0.1:8001/users/;
include /etc/nginx/proxy_params;
}
location /apps/ {
proxy_pass http://127.0.0.1:8001/apps/;
include /etc/nginx/proxy_params;
}
location /balance/ {
proxy_pass http://127.0.0.1:8001/balance/;
include /etc/nginx/proxy_params;
}
location /orders/ {
proxy_pass http://127.0.0.1:8001/orders/;
include /etc/nginx/proxy_params;
}
location /history/ {
proxy_pass http://127.0.0.1:8001/history/;
include /etc/nginx/proxy_params;
}
location /twitter/ {
proxy_pass http://127.0.0.1:8001/twitter/;
include /etc/nginx/proxy_params;
}
location /twitter-post/ {
proxy_pass http://127.0.0.1:8001/twitter-post/;
include /etc/nginx/proxy_params;
}
location /news-stock/ {
proxy_pass http://127.0.0.1:8001/news-stock/;
include /etc/nginx/proxy_params;
}
# AI智能客服路由包括所有子路由query, asr, audio
location /ai-chatbot/ {
proxy_pass http://127.0.0.1:8001/ai-chatbot/;
include /etc/nginx/proxy_params;
# 针对语音处理的特殊配置
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
proxy_send_timeout 300s;
# 音频文件缓存设置
location ~* /ai-chatbot/audio/ {
proxy_pass http://127.0.0.1:8001;
include /etc/nginx/proxy_params;
expires 1h;
add_header Cache-Control "public";
}
}
# API文档
location /docs {
proxy_pass http://127.0.0.1:8001/docs;
include /etc/nginx/proxy_params;
}
location /redoc {
proxy_pass http://127.0.0.1:8001/redoc;
include /etc/nginx/proxy_params;
}
# 通用API代理向后兼容
location /api/ {
proxy_pass http://127.0.0.1:8001/;
include /etc/nginx/proxy_params;
}
# 错误页面配置
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# 安全配置
server_tokens off;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# 防止访问隐藏文件
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# 日志配置
access_log /var/log/nginx/ai-website-access.log;
error_log /var/log/nginx/ai-website-error.log;
}

109
README.md
View File

@@ -1,2 +1,109 @@
# AI-Website
# AI应用展示与付费平台
## 项目概述
本项目是一个完整的AI应用展示与付费平台用户可以浏览、购买和使用各种AI应用服务。平台支持用户注册、登录、余额管理、应用购买和使用等功能同时提供完善的管理后台方便管理员管理用户、应用和订单。
## 技术栈
### 前端技术栈
- **React 19.1.0**用于构建用户界面的JavaScript库
- **React Router 7.5.3**:前端路由管理
- **Ant Design 5.24.9**UI组件库提供美观且功能丰富的组件
- **Axios 1.9.0**处理HTTP请求
- **JWT认证**:用于用户身份验证
### 后端技术栈
- **FastAPI**高性能Python Web框架
- **SQLAlchemy**ORM系统用于数据库交互
- **Pydantic**:数据验证和模型定义
- **Uvicorn**ASGI服务器
- **Passlib (bcrypt)**:密码哈希处理
- **JWT**:用户认证和授权
### 部署环境
- **AWS EC2**:应用托管
- **Nginx**:反向代理和静态文件服务
## 目录结构
```
Website-Clean/
├── backend/ # FastAPI后端
│ ├── app/ # 应用代码
│ │ ├── routers/ # API路由
│ │ ├── models.py # 数据库模型
│ │ └── schemas.py # 数据验证模型
│ └── requirements.txt # 依赖包列表
├── frontend/ # React前端
│ ├── public/ # 静态资源
│ ├── src/ # 源代码
│ │ ├── pages/ # 页面组件
│ │ ├── components/# 通用组件
│ │ └── auth.js # 认证相关
│ └── package.json # 依赖配置
└── README.md # 项目说明
```
## 功能特性
- **用户管理**:注册、登录、编辑用户信息、删除用户
- **应用管理**:添加、编辑、删除应用
- **订单管理**:查看和管理用户订单
- **余额管理**:用户余额充值和消费
- **权限控制**:区分管理员和普通用户权限
- **响应式设计**:适应不同设备屏幕
## 快速开始
### 后端设置
```bash
# 进入后端目录
cd backend
# 创建虚拟环境
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
# 安装依赖
pip install -r requirements.txt
# 运行开发服务器
uvicorn app.main:app --reload --host 0.0.0.0 --port 8001
```
### 前端设置
```bash
# 进入前端目录
cd frontend
# 安装依赖
npm install
# 运行开发服务器
npm start
# 构建生产版本
npm run build
```
## 部署指南
1. 构建前端在frontend目录下运行`npm run build`
2. 配置Nginx设置反向代理将API请求转发到后端服务
3. 启动后端使用Uvicorn或Gunicorn运行FastAPI应用
4. 设置CORS确保前后端可以正常通信
## API文档
启动后端服务后,访问 `http://localhost:8001/docs` 查看自动生成的API文档。
## 许可证
[MIT](https://opensource.org/licenses/MIT)
实现后端 JWT 登录体系,升级前端对接

29
backend/README.md Normal file
View File

@@ -0,0 +1,29 @@
# Backend - FastAPI
## 简介
本目录为后端服务,基于 FastAPI 框架负责用户、余额、AI 应用等 API。
## 运行方式
```bash
cd backend
python -m venv venv
source venv/bin/activate # Windows 下用 venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## 目录结构
```
backend/
├── app/
│ ├── main.py
│ ├── models.py
│ ├── schemas.py
│ ├── database.py
│ └── routers/
│ ├── users.py
│ └── balance.py
├── requirements.txt
└── README.md
```

BIN
backend/aiplatform.db Normal file

Binary file not shown.

View File

@@ -0,0 +1,18 @@
# 文件已删除admin 创建脚本不再需要。
from passlib.context import CryptContext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
db = database.SessionLocal()
# 删除已有 admin
admin = db.query(models.User).filter(models.User.username == "admin").first()
if admin:
db.delete(admin)
db.commit()
hashed_password = pwd_context.hash("admin123")
admin_user = models.User(username="admin", hashed_password=hashed_password, is_admin=True, is_active=True, balance=100)
db.add(admin_user)
db.commit()
db.close()
print("admin 管理员已插入!")

View File

@@ -0,0 +1,17 @@
import sqlite3
conn = sqlite3.connect("app.db")
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
type VARCHAR,
amount FLOAT,
desc VARCHAR,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
conn.close()
print("history 表创建成功!")

12
backend/app/database.py Normal file
View File

@@ -0,0 +1,12 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# 统一使用一个数据库文件
import os
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
SQLALCHEMY_DATABASE_URL = f"sqlite:///{os.path.join(BASE_DIR, '../aiplatform.db')}"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()

216
backend/app/main.py Normal file
View File

@@ -0,0 +1,216 @@
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from app.routers import users, balance, apps, history, orders, twitter, twitter_post, news_stock, ai_chatbot # 统一导入所有路由模块
from app import models, database
from passlib.context import CryptContext
from sqlalchemy.orm import Session
import requests
import logging
from openai import OpenAI
app = FastAPI(title="AI Platform API")
# 自动建表,确保所有表都存在
models.Base.metadata.create_all(bind=database.engine)
# 加密工具
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# 初始化数据
@app.on_event("startup")
async def init_db_data():
"""在应用启动时初始化必要的数据"""
db = database.SessionLocal()
try:
# 1. 创建admin用户如果不存在
admin_user = db.query(models.User).filter(models.User.username == "admin").first()
if not admin_user:
print("创建admin用户...")
hashed_password = pwd_context.hash("admin123")
admin_user = models.User(
username="admin",
hashed_password=hashed_password,
is_admin=True,
is_active=True,
balance=100
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
print("admin用户创建成功")
# 2. 创建初始应用(如果应用表为空)
app_count = db.query(models.App).count()
if app_count == 0:
print("创建初始应用...")
default_apps = [
models.App(name="Twitter推文摘要", desc="输入Twitter用户名获取最近推文摘要", price=12, status="上架"),
models.App(name="Twitter自动发推", desc="输入Twitter用户名获取摘要并发送到Twitter", price=15, status="上架"),
models.App(name="热点新闻选股", desc="分析热点新闻对股票的影响,提供选股建议", price=20, status="上架") # 添加热点新闻选股应用
]
db.add_all(default_apps)
db.commit()
print("初始应用创建成功")
# 3. 创建示例订单数据(如果订单表为空)
order_count = db.query(models.Order).count()
if order_count == 0:
print("创建示例订单数据...")
# 获取用户和应用
users = db.query(models.User).all()
apps = db.query(models.App).all()
if users and apps:
# 创建一些示例订单
from datetime import datetime, timedelta
default_orders = [
models.Order(
user_id=users[0].id,
app_id=apps[0].id,
type="应用调用",
amount=apps[0].price,
description="使用Twitter推文摘要服务",
status="已完成",
created_at=datetime.utcnow() - timedelta(days=5)
),
models.Order(
user_id=users[0].id,
app_id=apps[1].id,
type="应用调用",
amount=apps[1].price,
description="使用Twitter自动发推服务",
status="已完成",
created_at=datetime.utcnow() - timedelta(days=2)
)
]
db.add_all(default_orders)
db.commit()
print("示例订单创建成功")
# 4. 创建示例历史记录数据(如果历史记录表为空)
history_count = db.query(models.History).count()
if history_count == 0:
print("创建示例历史记录数据...")
# 获取用户
users = db.query(models.User).all()
if users:
# 创建一些示例历史记录
from datetime import datetime, timedelta
default_history = [
models.History(
user_id=users[0].id,
type="recharge",
amount=100,
desc="账户充值",
created_at=datetime.utcnow() - timedelta(days=10)
),
models.History(
user_id=users[0].id,
type="consume",
amount=-12,
desc="使用Twitter推文摘要服务",
created_at=datetime.utcnow() - timedelta(days=5)
),
models.History(
user_id=users[0].id,
type="consume",
amount=-15,
desc="使用Twitter自动发推服务",
created_at=datetime.utcnow() - timedelta(days=2)
)
]
db.add_all(default_history)
db.commit()
print("示例历史记录创建成功")
except Exception as e:
print(f"初始化数据时出错: {e}")
finally:
db.close()
# 配置CORS
# 使用统一的跨域配置
allowed_origins = [
# 生产环境
"http://174.129.175.43:3000", # EC2实例1
"http://44.206.227.249:3000", # EC2实例2
"http://52.91.169.148:3000", # EC2实例3
"https://rongye.xyz", # 新域名
"http://rongye.xyz", # 新域名(http)
# 本地开发环境
"http://localhost:3000",
"http://127.0.0.1:3000",
"http://localhost",
"http://127.0.0.1",
]
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
expose_headers=["*"] # 允许前端访问所有响应头
)
# 注册路由 - 不再需要在这里添加/admin前缀因为已经在router中定义了
app.include_router(users.router, prefix="/users", tags=["users"])
app.include_router(balance.router, prefix="/balance", tags=["balance"])
app.include_router(apps.router, prefix="/apps", tags=["apps"])
app.include_router(history.router, prefix="/history", tags=["history"])
app.include_router(orders.router, prefix="/orders", tags=["orders"])
app.include_router(twitter.router, prefix="/twitter", tags=["twitter"])
app.include_router(twitter_post.router, prefix="/twitter-post", tags=["twitter_post"])
app.include_router(news_stock.router, prefix="/news-stock", tags=["news_stock"]) # 添加热点新闻选股路由
app.include_router(ai_chatbot.router, prefix="/ai-chatbot", tags=["ai_chatbot"]) # 添加AI客服路由
@app.get("/")
async def root():
return {"message": "Welcome to AI Platform API"}
@app.get("/test-twitter")
async def test_twitter():
try:
# 测试Twitter API连接
twitter_api_key = "e3dad005b0e54bdc88c6178a89adec13"
twitter_api_url = "https://api.twitterapi.io/twitter/tweet/advanced_search"
headers = {"X-API-Key": twitter_api_key}
params = {
"queryType": "Latest",
"query": "from:elonmusk",
"count": 5
}
twitter_response = requests.get(twitter_api_url, headers=headers, params=params)
# 初始化OpenAI客户端
openai_client = OpenAI(
api_key="sk-8a121704a9bc4ec6a5ab0ae16e0bc0ba",
base_url="https://api.deepseek.com"
)
return {
"status": "ok",
"twitter_api_status": twitter_response.status_code,
"twitter_api_response": twitter_response.json() if twitter_response.status_code == 200 else str(twitter_response.text)[:100],
"apis_available": {
"twitter_api": True,
"openai_api": True
}
}
except Exception as e:
return {
"status": "error",
"message": str(e),
"type": str(type(e))
}

53
backend/app/models.py Normal file
View File

@@ -0,0 +1,53 @@
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from app.database import Base
from datetime import datetime
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True, nullable=True)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
balance = Column(Float, default=0)
# 添加关系
orders = relationship("Order", back_populates="user")
class App(Base):
__tablename__ = "apps"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, index=True)
desc = Column(String)
price = Column(Float)
status = Column(String, default="上架") # 上架、下架
created_at = Column(DateTime, default=datetime.utcnow)
# 添加关系
orders = relationship("Order", back_populates="app")
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
app_id = Column(Integer, ForeignKey("apps.id")) # 添加 app_id 字段
type = Column(String, default="应用调用") # 订单类型
amount = Column(Float) # 订单金额
description = Column(String, nullable=True) # 订单描述
status = Column(String) # 订单状态:待支付、已完成、已取消等
created_at = Column(DateTime, default=datetime.utcnow)
# 添加关系
user = relationship("User", back_populates="orders")
app = relationship("App", back_populates="orders")
class History(Base):
__tablename__ = "history"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey('users.id'))
type = Column(String) # 'recharge' or 'consume'
amount = Column(Float)
desc = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,344 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app import models, database
from pydantic import BaseModel
from app.utils_jwt import create_access_token, get_current_user
from passlib.context import CryptContext
from typing import List, Optional
# 创建路由器,设置统一的前缀和标签
router = APIRouter(
prefix="/admin/api",
tags=["admin"]
)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# 获取数据库会话
def get_db():
db = database.SessionLocal()
try:
yield db
finally:
db.close()
# 管理员登录请求模型
class AdminLoginRequest(BaseModel):
username: str
password: str
@router.post("/login")
async def admin_login(req: AdminLoginRequest, db: Session = Depends(get_db)):
print(f"=== Admin login attempt: {req.username} ===")
# 特殊处理admin用户确保它存在且正确设置
if req.username == "admin":
admin_user = db.query(models.User).filter(models.User.username == "admin").first()
if not admin_user:
hashed_password = pwd_context.hash("admin123")
admin_user = models.User(
username="admin",
hashed_password=hashed_password,
is_admin=True,
is_active=True,
balance=100
)
db.add(admin_user)
db.commit()
db.refresh(admin_user)
print("admin用户创建成功")
user = db.query(models.User).filter(models.User.username == req.username).first()
if not user:
raise HTTPException(status_code=401, detail="账号或密码错误")
if not pwd_context.verify(req.password, user.hashed_password):
raise HTTPException(status_code=401, detail="账号或密码错误")
if not user.is_admin:
raise HTTPException(status_code=401, detail="该账户没有管理员权限")
token = create_access_token({"sub": str(user.id), "is_admin": True})
print(f"登录成功 - 用户ID: {user.id}, 用户名: {user.username}")
return {
"token": token,
"user": {
"id": user.id,
"username": user.username,
"is_admin": True
}
}
# 管理员权限依赖
async def admin_required(user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
return user
# 应用管理
class AppCreateRequest(BaseModel):
name: str
desc: str
price: float
status: str = "上架"
@router.get("/apps")
def get_apps(db: Session = Depends(get_db), _=Depends(admin_required)):
apps = db.query(models.App).all()
return {"code": 0, "msg": "success", "data": [
{
"id": app.id,
"name": app.name,
"desc": app.desc,
"price": app.price,
"status": app.status
}
for app in apps
]}
@router.post("/apps")
def add_app(req: AppCreateRequest, db: Session = Depends(get_db), _=Depends(admin_required)):
if db.query(models.App).filter(models.App.name == req.name).first():
return {"code": 1, "msg": "应用已存在"}
new_app = models.App(
name=req.name, desc=req.desc, price=req.price, status=req.status
)
db.add(new_app)
db.commit()
db.refresh(new_app)
# 操作日志
# db.add(models.Log(action="add_app", detail=f"添加应用 {req.name}"))
# db.commit()
return {"code": 0, "msg": "应用创建成功", "data": {"id": new_app.id}}
@router.put("/apps/{app_id}")
def edit_app(app_id: int, req: AppCreateRequest, db: Session = Depends(get_db), _=Depends(admin_required)):
app = db.query(models.App).filter(models.App.id == app_id).first()
if not app:
return {"code": 1, "msg": "应用不存在"}
app.name = req.name
app.desc = req.desc
app.price = req.price
app.status = req.status
db.commit()
# db.add(models.Log(action="edit_app", detail=f"修改应用 {app_id}"))
# db.commit()
return {"code": 0, "msg": "应用修改成功"}
@router.delete("/apps/{app_id}")
def delete_app(app_id: int, db: Session = Depends(get_db), _=Depends(admin_required)):
app = db.query(models.App).filter(models.App.id == app_id).first()
if not app:
return {"code": 1, "msg": "应用不存在"}
db.delete(app)
db.commit()
# db.add(models.Log(action="delete_app", detail=f"删除应用 {app_id}"))
# db.commit()
return {"code": 0, "msg": "应用删除成功"}
# 用户管理
@router.get("/users")
def get_users(db: Session = Depends(get_db), _=Depends(admin_required)):
users = db.query(models.User).all()
return {"code": 0, "msg": "success", "data": [
{
"id": u.id,
"username": u.username,
"email": getattr(u, "email", None),
"is_admin": getattr(u, "is_admin", False),
"status": "正常" if getattr(u, "is_active", True) else "禁用"
}
for u in users
]}
@router.post("/users")
def add_user(req: UserCreateRequest, db: Session = Depends(get_db), _=Depends(admin_required)):
if db.query(models.User).filter(models.User.username == req.username).first():
return {"code": 1, "msg": "用户名已存在"}
hashed_password = pwd_context.hash(req.password)
new_user = models.User(
username=req.username,
hashed_password=hashed_password,
email=req.email,
is_active=True
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# db.add(models.Log(action="add_user", detail=f"添加用户 {req.username}"))
# db.commit()
return {"code": 0, "msg": "用户创建成功", "data": {"id": new_user.id}}
@router.put("/users/{user_id}")
def edit_user(user_id: int, req: UserCreateRequest, db: Session = Depends(get_db), _=Depends(admin_required)):
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user:
return {"code": 1, "msg": "用户不存在"}
db_user.username = req.username
if req.password:
db_user.hashed_password = pwd_context.hash(req.password)
if req.email:
db_user.email = req.email
db.commit()
# db.add(models.Log(action="edit_user", detail=f"修改用户 {user_id}"))
# db.commit()
return {"code": 0, "msg": "用户修改成功"}
@router.delete("/users/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db), _=Depends(admin_required)):
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user:
return {"code": 1, "msg": "用户不存在"}
db.delete(db_user)
db.commit()
# db.add(models.Log(action="delete_user", detail=f"删除用户 {user_id}"))
# db.commit()
return {"code": 0, "msg": "用户删除成功"}
@router.put("/users/{user_id}/status")
def update_user_status(user_id: int, status: str, db: Session = Depends(get_db), _=Depends(admin_required)):
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user:
return {"code": 1, "msg": "用户不存在"}
db_user.is_active = (status == "正常")
db.commit()
# db.add(models.Log(action="update_user_status", detail=f"设置用户 {user_id} 状态为 {status}"))
# db.commit()
return {"code": 0, "msg": "状态更新成功"}
class UserCreateRequest(BaseModel):
username: str
password: str
email: Optional[str] = None
@router.post("/users") # 修改路径移除重复的admin
async def add_user(req: UserCreateRequest, db: Session = Depends(get_db), _=Depends(admin_required)):
if db.query(models.User).filter(models.User.username == req.username).first():
raise HTTPException(status_code=400, detail="用户名已存在")
hashed_password = pwd_context.hash(req.password)
new_user = models.User(
username=req.username,
hashed_password=hashed_password,
email=req.email,
is_active=True
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return {"user": {"id": new_user.id, "username": new_user.username}}
@router.put("/users/{user_id}") # 修改路径移除重复的admin
async def edit_user(
user_id: int,
req: UserCreateRequest,
db: Session = Depends(get_db),
_=Depends(admin_required)
):
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
db_user.username = req.username
if req.password:
db_user.hashed_password = pwd_context.hash(req.password)
if req.email:
db_user.email = req.email
db.commit()
return {"msg": "修改成功"}
@router.delete("/users/{user_id}") # 修改路径移除重复的admin
async def delete_user(user_id: int, db: Session = Depends(get_db), _=Depends(admin_required)):
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
db.delete(db_user)
db.commit()
return {"msg": "删除成功"}
# 订单管理
@router.get("/orders")
def get_orders(db: Session = Depends(get_db), _=Depends(admin_required)):
orders = db.query(models.Order).all()
return {"code": 0, "msg": "success", "data": [
{
"id": order.id,
"user_id": order.user.username if order.user else None,
"type": order.type,
"amount": order.amount,
"description": order.description,
"created_at": order.created_at.strftime("%Y-%m-%d %H:%M:%S") if order.created_at else None,
"status": order.status
}
for order in orders
]}
@router.get("/orders/{order_id}")
def order_detail(order_id: int, db: Session = Depends(get_db), _=Depends(admin_required)):
order = db.query(models.Order).filter(models.Order.id == order_id).first()
if not order:
return {"code": 1, "msg": "订单不存在"}
return {"code": 0, "msg": "success", "data": {
"id": order.id,
"user_id": order.user.username if order.user else None,
"type": order.type,
"amount": order.amount,
"description": order.description,
"created_at": order.created_at.strftime("%Y-%m-%d %H:%M:%S") if order.created_at else None,
"status": order.status
}}
# 充值记录
@router.get("/finance")
def get_finance(db: Session = Depends(get_db), _=Depends(admin_required)):
finance_records = db.query(models.Finance).all()
user_map = {}
user_ids = set(record.user_id for record in finance_records)
users = db.query(models.User).filter(models.User.id.in_(user_ids)).all()
for user in users:
user_map[user.id] = user.username
return {"code": 0, "msg": "success", "data": [
{
"id": record.id,
"user_id": record.user_id,
"username": user_map.get(record.user_id, "未知用户"),
"amount": record.amount,
"description": record.desc,
"created_at": record.created_at.strftime("%Y-%m-%d %H:%M:%S") if record.created_at else None
}
for record in finance_records
]}
# 添加查询历史记录接口(包括充值记录)
@router.get("/history")
async def get_history(db: Session = Depends(get_db), _=Depends(admin_required)):
"""获取所有历史记录,包括充值和消费"""
history_records = db.query(models.History).all()
# 查询用户信息,用于显示用户名
user_map = {}
user_ids = set(record.user_id for record in history_records)
users = db.query(models.User).filter(models.User.id.in_(user_ids)).all()
for user in users:
user_map[user.id] = user.username
return {
"history": [
{
"id": record.id,
"user_id": record.user_id,
"username": user_map.get(record.user_id, "未知用户"),
"type": "充值" if record.type == "recharge" else "消费",
"amount": record.amount,
"description": record.desc,
"created_at": record.created_at.strftime("%Y-%m-%d %H:%M:%S") if record.created_at else None
}
for record in history_records
]
}

View File

@@ -0,0 +1,641 @@
from fastapi import APIRouter, UploadFile, File, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
import os
import time
import base64
import hashlib
import requests
import hmac
import urllib.parse
import http.client
from urllib.parse import urlencode
import json
from openai import OpenAI
from pydub import AudioSegment
import tempfile
import logging
import asyncio
from concurrent.futures import ThreadPoolExecutor
import threading
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
router = APIRouter()
# 阿里云语音服务配置
ALIYUN_ACCESS_KEY_ID = "LTAI5t5ZrbKQuuwkmQ1LFCBo"
ALIYUN_ACCESS_KEY_SECRET = "2vvspr0HcmmnBFzpXw4iNyLafSgUuN"
ALIYUN_APP_KEY = "wlIvC6tOAvQLoQDz"
ALIYUN_REGION = "cn-shanghai"
ALIYUN_HOST = "nls-gateway-cn-shanghai.aliyuncs.com"
# DeepSeek配置
DEEPSEEK_API_KEY = "sk-8a121704a9bc4ec6a5ab0ae16e0bc0ba"
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
# 音频文件存储目录
AUDIO_DIR = os.path.join(os.path.dirname(__file__), "..", "..", "audio")
os.makedirs(AUDIO_DIR, exist_ok=True)
# 全局变量存储token和过期时间
token_info = {
'token': None,
'expire_time': 0
}
# 添加线程池用于异步处理
executor = ThreadPoolExecutor(max_workers=4)
# 简单的内存缓存
response_cache = {}
cache_expire_time = {}
def get_signature(secret, text):
"""生成签名"""
h = hmac.new(secret.encode('utf-8'), text.encode('utf-8'), hashlib.sha1)
return base64.b64encode(h.digest()).decode('utf-8')
def get_aliyun_token(force_refresh=False):
"""获取阿里云访问令牌,带缓存和自动刷新"""
global token_info
# 检查token是否有效提前5分钟刷新
current_time = int(time.time())
if not force_refresh and token_info['token'] and token_info['expire_time'] > current_time + 300:
logger.info("使用缓存的阿里云Token")
return token_info['token']
logger.info("正在获取阿里云访问令牌..." + (" (强制刷新)" if force_refresh else ""))
# 构建请求参数
params = {
"Action": "CreateToken",
"Version": "2019-02-28",
"Format": "JSON",
"AccessKeyId": ALIYUN_ACCESS_KEY_ID,
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"SignatureMethod": "HMAC-SHA1",
"SignatureVersion": "1.0",
"SignatureNonce": str(int(time.time() * 1000))
}
# 对参数进行排序并生成查询字符串
sorted_params = sorted(params.items(), key=lambda x: x[0])
query_string = urlencode(dict(sorted_params))
# 构建待签名字符串
string_to_sign = f"POST&%2F&{urllib.parse.quote_plus(query_string)}"
# 计算签名
signature = get_signature(ALIYUN_ACCESS_KEY_SECRET + "&", string_to_sign)
params["Signature"] = signature
try:
conn = http.client.HTTPSConnection("nls-meta.cn-shanghai.aliyuncs.com")
headers = {"Content-Type": "application/x-www-form-urlencoded"}
# 发送请求
conn.request("POST", "/", headers=headers, body=urlencode(params))
response = conn.getresponse()
result = json.loads(response.read().decode('utf-8'))
if 'Token' in result and 'Id' in result['Token']:
# 更新token信息设置过期时间为55分钟后
token_info['token'] = result['Token']['Id']
token_info['expire_time'] = int(time.time()) + 3300 # 55分钟
logger.info("获取阿里云Token成功")
return token_info['token']
else:
logger.error(f"获取阿里云Token失败: {result}")
return None
except Exception as e:
logger.error(f"获取阿里云Token时发生异常: {str(e)}")
return None
finally:
if 'conn' in locals():
conn.close()
def aliyun_tts_chinese_text_to_audio(chinese_text, output_audio_path):
"""使用阿里云TTS接口进行中文语音合成"""
token = get_aliyun_token()
if not token:
raise Exception("无法获取阿里云访问令牌")
logger.info(f"正在合成语音: {chinese_text[:50]}...")
try:
conn = http.client.HTTPSConnection(ALIYUN_HOST, timeout=10)
headers = {
"Content-Type": "application/json",
"X-NLS-Token": token
}
# 使用更自然的音色和参数配置
tts_data = {
"appkey": ALIYUN_APP_KEY,
"text": chinese_text[:100], # 限制长度避免过长
"format": "wav",
"voice": "aixia", # 使用艾夏音色符合智能语音交互2.0配置
"volume": 50,
"speech_rate": 0, # 正常语速
"pitch_rate": 0, # 正常语调
"sample_rate": 16000
}
conn.request("POST", "/stream/v1/tts",
body=json.dumps(tts_data),
headers=headers)
response = conn.getresponse()
if response.status == 200:
# 检查响应类型
content_type = response.getheader('Content-Type', '')
response_data = response.read()
if 'audio' in content_type:
# 是音频数据
with open(output_audio_path, 'wb') as f:
f.write(response_data)
logger.info(f"语音合成成功,已保存到: {output_audio_path}, 大小: {len(response_data)} bytes")
return True
else:
# 可能是错误信息
try:
error_info = json.loads(response_data.decode('utf-8'))
logger.error(f"阿里云TTS返回错误信息: {json.dumps(error_info, indent=2, ensure_ascii=False)}")
except:
logger.error(f"阿里云TTS返回非JSON响应: {response_data[:500]}")
return False
else:
error_message = response.read().decode('utf-8')
logger.error(f"TTS请求失败: {response.status} {response.reason}, 错误: {error_message}")
return False
except Exception as e:
logger.error(f"TTS合成时发生异常: {str(e)}")
return False
finally:
if 'conn' in locals():
conn.close()
def convert_audio_format(input_file, target_sample_rate=16000, target_channels=1):
"""转换音频格式到WAV格式确保符合阿里云ASR要求"""
try:
logger.info(f"开始转换音频格式: {input_file}")
# 检查输入文件是否存在
if not os.path.exists(input_file):
logger.error(f"输入音频文件不存在: {input_file}")
return None
# 加载音频文件,自动检测格式
try:
audio = AudioSegment.from_file(input_file)
except Exception as e:
logger.error(f"无法读取音频文件: {e}")
return None
logger.info(f"原始音频信息 - 时长: {len(audio)}ms, 采样率: {audio.frame_rate}, 声道: {audio.channels}")
# 检查音频时长阿里云ASR限制60秒
if len(audio) > 60000: # 60秒 = 60000毫秒
logger.warning(f"音频时长({len(audio)/1000:.2f}s)超过60秒限制将截取前60秒")
audio = audio[:60000]
# 转换为目标格式16kHz单声道WAV
audio = audio.set_frame_rate(target_sample_rate).set_channels(target_channels)
# 确保是16位PCM编码
audio = audio.set_sample_width(2) # 2字节 = 16位
# 生成输出文件名
base_name = os.path.splitext(input_file)[0]
output_file = f"{base_name}_converted.wav"
# 导出为WAV格式
audio.export(output_file, format="wav", parameters=["-acodec", "pcm_s16le"])
logger.info(f"音频格式转换成功: {input_file} -> {output_file}")
logger.info(f"转换后音频信息 - 时长: {len(audio)}ms, 采样率: {target_sample_rate}, 声道: {target_channels}")
return output_file
except Exception as e:
logger.error(f"音频格式转换失败: {str(e)}")
return None
def aliyun_asr_chinese_audio_to_text(audio_path):
"""使用阿里云智能语音交互2.0 RESTful API进行中文语音识别"""
max_retries = 3
for attempt in range(max_retries):
# 获取token如果是重试则强制刷新
token = get_aliyun_token(force_refresh=(attempt > 0))
if not token:
logger.error("获取阿里云Token失败无法继续识别")
return ""
logger.info(f"正在识别音频: {audio_path} (尝试 {attempt + 1}/{max_retries})")
# 检查音频文件
if not os.path.exists(audio_path):
logger.error(f"音频文件不存在: {audio_path}")
return ""
# 转换音频格式确保符合阿里云ASR要求
converted_audio = convert_audio_format(audio_path)
if not converted_audio:
logger.error("音频格式转换失败")
return ""
conn = None
try:
# 读取转换后的音频文件
with open(converted_audio, 'rb') as f:
audio_data = f.read()
logger.info(f"音频文件大小: {len(audio_data)} bytes")
# 检查音频文件大小
if len(audio_data) == 0:
logger.error("音频文件为空")
continue
# 使用阿里云智能语音交互2.0的RESTful API
conn = http.client.HTTPSConnection(ALIYUN_HOST, timeout=30)
# 设置正确的请求头
headers = {
"X-NLS-Token": token,
"Content-Type": "application/octet-stream",
"Content-Length": str(len(audio_data)),
"Host": ALIYUN_HOST
}
# 构建请求参数按照阿里云智能语音交互2.0文档
params = {
"appkey": ALIYUN_APP_KEY,
"format": "wav",
"sample_rate": 16000,
"enable_punctuation_prediction": "true",
"enable_inverse_text_normalization": "true",
"enable_voice_detection": "false"
}
# 构建完整的请求URL
query_string = urllib.parse.urlencode(params)
full_url = f"/stream/v1/asr?{query_string}"
logger.info(f"发送ASR请求URL: {full_url}")
logger.info(f"请求头: {headers}")
# 发送POST请求直接传输二进制音频数据
conn.request("POST", full_url, body=audio_data, headers=headers)
response = conn.getresponse()
response_data = response.read()
logger.info(f"ASR响应状态: {response.status}")
if response.status == 200:
try:
result = json.loads(response_data.decode('utf-8'))
logger.info(f"ASR响应结果: {result}")
# 检查响应状态
status = result.get('status', 0)
message = result.get('message', '')
if status == 20000000 and message == 'SUCCESS':
transcription = result.get('result', '').strip()
if transcription:
logger.info(f"语音识别成功: {transcription}")
return transcription
else:
logger.warning("识别成功但结果为空可能是1)音频内容为静音 2)语音不清晰 3)语言不匹配")
if attempt < max_retries - 1:
logger.info("正在重试...")
continue
return ""
else:
logger.error(f"ASR识别失败: status={status}, message={message}")
if attempt < max_retries - 1:
logger.info("正在重试...")
continue
return ""
except json.JSONDecodeError as json_error:
logger.error(f"解析ASR响应JSON失败: {json_error}")
logger.info(f"原始响应: {response_data[:1000]}")
if attempt < max_retries - 1:
logger.info("正在重试...")
continue
return ""
elif response.status == 401: # 未授权token可能已过期
logger.warning("Token无效或已过期")
if attempt < max_retries - 1:
logger.info("正在尝试刷新Token并重试...")
continue
else:
logger.error("Token刷新重试次数已用完")
return ""
elif response.status == 40000001:
logger.error("身份认证失败检查Token是否正确或过期")
return ""
elif response.status == 40000003:
logger.error("参数无效,检查音频格式和采样率")
return ""
elif response.status == 41010101:
logger.error("不支持的采样率当前仅支持8000Hz和16000Hz")
return ""
else:
error_message = response_data.decode('utf-8', errors='ignore')
logger.error(f"ASR识别失败: 状态码{response.status}, 错误信息: {error_message}")
if attempt < max_retries - 1:
logger.info("正在重试...")
continue
return ""
except Exception as e:
logger.error(f"ASR识别时发生异常: {str(e)}")
if attempt < max_retries - 1:
logger.info("正在重试...")
continue
return ""
finally:
# 清理临时文件
if converted_audio and os.path.exists(converted_audio) and converted_audio != audio_path:
try:
os.remove(converted_audio)
logger.info(f"已清理临时文件: {converted_audio}")
except:
pass
# 关闭连接
if conn:
conn.close()
# 所有重试都失败了
logger.error("所有ASR重试都失败")
return ""
def get_cache_key(question: str) -> str:
"""生成缓存键"""
return hashlib.md5(question.encode()).hexdigest()
def get_cached_response(question: str) -> str:
"""获取缓存的回答"""
cache_key = get_cache_key(question)
current_time = time.time()
# 检查缓存是否存在且未过期5分钟过期
if (cache_key in response_cache and
cache_key in cache_expire_time and
cache_expire_time[cache_key] > current_time):
logger.info(f"使用缓存回答: {cache_key}")
return response_cache[cache_key]
return None
def set_cached_response(question: str, answer: str):
"""设置缓存回答"""
cache_key = get_cache_key(question)
response_cache[cache_key] = answer
cache_expire_time[cache_key] = time.time() + 300 # 5分钟过期
logger.info(f"缓存回答: {cache_key}")
def get_deepseek_response(question):
"""使用DeepSeek API获取简洁回答"""
# 首先检查缓存
cached_answer = get_cached_response(question)
if cached_answer:
return cached_answer
try:
client = OpenAI(
api_key=DEEPSEEK_API_KEY,
base_url=DEEPSEEK_BASE_URL,
timeout=8.0 # 设置8秒超时
)
# 针对客服场景优化提示词
system_prompt = """你是一个专业的智能客服助手。请遵循以下原则:
1. 回答要简洁明了通常控制在40字以内
2. 语气要友好、专业、有帮助
3. 如果不确定答案,请诚实说明并建议联系人工客服
4. 重点解决客户的实际问题
5. 避免冗长的解释,直接给出有用信息"""
response = client.chat.completions.create(
model="deepseek-chat",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": question}
],
max_tokens=150, # 进一步限制回答长度
temperature=0.5 # 降低随机性,提高一致性
)
answer = response.choices[0].message.content.strip()
logger.info(f"DeepSeek回答: {answer}")
# 缓存回答
set_cached_response(question, answer)
return answer
except Exception as e:
logger.error(f"DeepSeek API调用失败: {str(e)}")
return "抱歉,智能客服暂时繁忙,请稍后重试或联系人工客服。"
async def get_deepseek_response_async(question):
"""异步调用DeepSeek API"""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(executor, get_deepseek_response, question)
class ChatRequest(BaseModel):
question: str
@router.post("/query")
async def chat_query(req: ChatRequest):
"""AI客服对话接口"""
try:
question = req.question.strip()
if not question:
raise HTTPException(status_code=400, detail="问题不能为空")
# 使用DeepSeek获取回答
answer = await get_deepseek_response_async(question)
# 生成语音文件
timestamp = int(time.time() * 1000)
audio_filename = f"ai_response_{timestamp}.wav"
audio_path = os.path.join(AUDIO_DIR, audio_filename)
# 语音合成
tts_success = aliyun_tts_chinese_text_to_audio(answer, audio_path)
response_data = {
"answer": answer,
"audio_url": f"/ai-chatbot/audio/{audio_filename}" if tts_success else None
}
return response_data
except Exception as e:
logger.error(f"查询处理失败: {str(e)}")
raise HTTPException(status_code=500, detail="处理请求时发生错误")
@router.post("/query-text")
async def chat_query_text_only(req: ChatRequest):
"""AI客服对话接口 - 仅返回文本,快速响应"""
try:
question = req.question.strip()
if not question:
raise HTTPException(status_code=400, detail="问题不能为空")
# 使用DeepSeek获取回答
answer = await get_deepseek_response_async(question)
response_data = {
"answer": answer,
"timestamp": int(time.time() * 1000) # 用于生成语音时的唯一标识
}
return response_data
except Exception as e:
logger.error(f"文本查询处理失败: {str(e)}")
raise HTTPException(status_code=500, detail="处理请求时发生错误")
class AudioRequest(BaseModel):
text: str
timestamp: int
@router.post("/generate-audio")
async def generate_audio(req: AudioRequest):
"""异步生成语音文件"""
try:
text = req.text.strip()
if not text:
raise HTTPException(status_code=400, detail="文本不能为空")
# 使用时间戳生成语音文件名
audio_filename = f"ai_response_{req.timestamp}.wav"
audio_path = os.path.join(AUDIO_DIR, audio_filename)
# 语音合成
tts_success = aliyun_tts_chinese_text_to_audio(text, audio_path)
if tts_success:
return {
"success": True,
"audio_url": f"/ai-chatbot/audio/{audio_filename}"
}
else:
return {
"success": False,
"error": "语音生成失败"
}
except Exception as e:
logger.error(f"语音生成失败: {str(e)}")
return {
"success": False,
"error": str(e)
}
@router.post("/asr")
async def speech_recognition(file: UploadFile = File(...)):
"""语音识别接口"""
try:
# 检查文件格式
if not file.filename.lower().endswith(('.wav', '.mp3', '.m4a', '.webm', '.ogg')):
logger.warning(f"不支持的文件格式: {file.filename}")
# 保存上传的音频文件
timestamp = int(time.time() * 1000)
# 保持原始文件扩展名让pydub自动检测格式
original_ext = os.path.splitext(file.filename)[1] if file.filename else '.wav'
temp_filename = f"temp_audio_{timestamp}{original_ext}"
temp_path = os.path.join(AUDIO_DIR, temp_filename)
# 保存文件
with open(temp_path, "wb") as buffer:
content = await file.read()
buffer.write(content)
logger.info(f"接收到音频文件: {file.filename}, 大小: {len(content)} bytes, 临时保存为: {temp_path}")
# 语音识别
transcription = aliyun_asr_chinese_audio_to_text(temp_path)
# 清理临时文件
if os.path.exists(temp_path):
os.remove(temp_path)
logger.info(f"已清理临时文件: {temp_path}")
return {"text": transcription}
except Exception as e:
logger.error(f"语音识别失败: {str(e)}")
# 清理临时文件
if 'temp_path' in locals() and os.path.exists(temp_path):
try:
os.remove(temp_path)
except:
pass
raise HTTPException(status_code=500, detail="语音识别失败")
@router.get("/audio/{filename}")
async def get_audio(filename: str):
"""获取音频文件"""
audio_path = os.path.join(AUDIO_DIR, filename)
if os.path.exists(audio_path):
return FileResponse(
audio_path,
media_type="audio/wav",
headers={"Content-Disposition": f"inline; filename={filename}"}
)
else:
raise HTTPException(status_code=404, detail="音频文件不存在")
# 添加音频文件清理功能
def cleanup_old_audio_files():
"""清理超过1小时的音频文件"""
try:
current_time = time.time()
for filename in os.listdir(AUDIO_DIR):
if filename.endswith('.wav'):
file_path = os.path.join(AUDIO_DIR, filename)
file_age = current_time - os.path.getctime(file_path)
# 删除超过1小时的文件
if file_age > 3600:
try:
os.remove(file_path)
logger.info(f"已清理过期音频文件: {filename}")
except Exception as e:
logger.error(f"清理音频文件失败 {filename}: {e}")
except Exception as e:
logger.error(f"音频文件清理过程出错: {e}")
def start_cleanup_timer():
"""启动定时清理任务"""
cleanup_old_audio_files()
# 每30分钟执行一次清理
threading.Timer(1800.0, start_cleanup_timer).start()
# 启动清理任务
start_cleanup_timer()

147
backend/app/routers/apps.py Normal file
View File

@@ -0,0 +1,147 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, database
from pydantic import BaseModel
router = APIRouter()
def get_db():
db = database.SessionLocal()
try:
yield db
finally:
db.close()
from app.utils_jwt import get_current_user
# 根路径返回所有应用
@router.get("/")
def root_apps(db: Session = Depends(get_db)):
apps = db.query(models.App).all()
return {"apps": [
{"id": app.id, "name": app.name, "desc": app.desc, "price": app.price, "status": app.status} for app in apps
]}
class AppCreate(BaseModel):
name: str
desc: str
price: float
status: str = "上架"
class AppUpdate(BaseModel):
name: str = None
desc: str = None
price: float = None
status: str = None
# 查询所有应用(用户/前端)
@router.get("/list")
def list_apps(db: Session = Depends(get_db)):
apps = db.query(models.App).all()
return {"apps": [
{"id": app.id, "name": app.name, "desc": app.desc, "price": app.price, "status": app.status} for app in apps
]}
# 管理员获取全部应用(含下架)
@router.get("/all")
def list_all_apps(db: Session = Depends(get_db), user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
apps = db.query(models.App).all()
return [
{"id": app.id, "name": app.name, "desc": app.desc, "price": app.price, "status": app.status} for app in apps
]
# 新增应用(管理员)
@router.post("/add")
def add_app(app: AppCreate, db: Session = Depends(get_db), user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
new_app = models.App(**app.dict())
db.add(new_app)
db.commit()
db.refresh(new_app)
return {"msg": "添加成功", "app": {"id": new_app.id, "name": new_app.name}}
# 修改应用(管理员)
@router.put("/edit/{app_id}")
def edit_app(app_id: int, app: AppUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
db_app = db.query(models.App).filter(models.App.id == app_id).first()
if not db_app:
raise HTTPException(status_code=404, detail="应用不存在")
for field, value in app.dict(exclude_unset=True).items():
setattr(db_app, field, value)
db.commit()
return {"msg": "修改成功"}
# 删除应用(管理员)
@router.delete("/delete/{app_id}")
def delete_app(app_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
db_app = db.query(models.App).filter(models.App.id == app_id).first()
if not db_app:
raise HTTPException(status_code=404, detail="应用不存在")
db.delete(db_app)
db.commit()
return {"msg": "删除成功"}
# 用户调用应用(消费)
class UseAppRequest(BaseModel):
app_id: int
@router.post("/use")
def use_app(req: UseAppRequest, db: Session = Depends(get_db), user=Depends(get_current_user)):
# 获取应用信息
db_app = db.query(models.App).filter(models.App.id == req.app_id).first()
if not db_app:
raise HTTPException(status_code=404, detail="应用不存在")
# 获取最新的用户信息
db_user = db.query(models.User).filter(models.User.id == user.id).first()
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 检查余额
if db_user.balance < db_app.price:
raise HTTPException(status_code=400, detail="余额不足,请先充值")
try:
# 扣除余额
db_user.balance -= db_app.price
# 添加消费记录
from app.models import History
record = History(
user_id=db_user.id,
type='consume',
amount=-db_app.price,
desc=f"调用{db_app.name}"
)
db.add(record)
# 添加订单记录
from app.models import Order
order = Order(
user_id=db_user.id,
app_id=db_app.id,
type=db_app.name, # 使用应用名称作为订单类型
amount=db_app.price,
description=db_app.desc, # 添加应用描述
status="已完成" # 使用"已完成"代替"已支付"
)
db.add(order)
# 提交事务
db.commit()
return {
"msg": f"成功调用 {db_app.name}!已扣除{db_app.price}元。",
"balance": db_user.balance
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"操作失败:{str(e)}")

View File

@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, database
from datetime import datetime
router = APIRouter()
def get_db():
db = database.SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/me")
def get_my_balance(user_id: int, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {"balance": user.balance}
from pydantic import BaseModel
class RechargeRequest(BaseModel):
amount: float
from app.utils_jwt import get_current_user
@router.post("/recharge")
def recharge_balance(req: RechargeRequest, db: Session = Depends(get_db), user=Depends(get_current_user)):
if req.amount <= 0:
raise HTTPException(status_code=400, detail="Amount must be positive")
# 强制用db查一次确保user为当前session的持久对象
db_user = db.query(models.User).filter(models.User.id == user.id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
db_user.balance += req.amount
from app.models import History
record = History(user_id=db_user.id, type='recharge', amount=req.amount, desc='余额充值')
db.add(record)
db.commit()
return {"balance": db_user.balance}

View File

@@ -0,0 +1,114 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, database
router = APIRouter()
def get_db():
db = database.SessionLocal()
try:
yield db
finally:
db.close()
from app.utils_jwt import get_current_user
from typing import List
from app import schemas
# 根路径返回所有充值记录(管理员)
@router.get("/")
def root_history(db: Session = Depends(get_db), user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
try:
# 只获取充值记录,不显示消费记录
records = db.query(models.History).filter(models.History.type == "recharge").all()
result = []
for r in records:
# 获取用户名(如果存在)
username = ""
user_record = db.query(models.User).filter(models.User.id == r.user_id).first()
if user_record:
username = user_record.username
# 安全处理时间格式
timestamp_str = ""
try:
if hasattr(r, 'created_at') and r.created_at:
timestamp_str = r.created_at.strftime('%Y-%m-%d %H:%M:%S')
elif hasattr(r, 'timestamp') and r.timestamp:
timestamp_str = r.timestamp.strftime('%Y-%m-%d %H:%M:%S')
except:
timestamp_str = str(r.created_at or r.timestamp or "")
result.append({
"id": r.id,
"user_id": r.user_id,
"username": username,
"type": r.type,
"amount": r.amount,
"desc": r.desc,
"time": timestamp_str
})
return {"history": result}
except Exception as e:
print(f"Error in root_history: {str(e)}")
return {"history": [], "error": str(e)}
@router.get("/list")
def list_history(db: Session = Depends(get_db), user=Depends(get_current_user)):
records = db.query(models.History).filter(models.History.user_id == user.id).order_by(models.History.created_at.desc()).all()
return {"history": [
{
"type": r.type,
"amount": r.amount,
"desc": r.desc,
"time": r.created_at.strftime('%Y-%m-%d %H:%M:%S')
} for r in records
]}
# 管理员获取全部充值记录
@router.get("/all")
def list_all_history(db: Session = Depends(get_db), user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
try:
# 只获取充值记录,不显示消费记录
records = db.query(models.History).filter(models.History.type == "recharge").all()
result = []
for r in records:
# 获取用户名(如果存在)
username = ""
user_record = db.query(models.User).filter(models.User.id == r.user_id).first()
if user_record:
username = user_record.username
# 安全处理时间格式
timestamp_str = ""
try:
if hasattr(r, 'created_at') and r.created_at:
timestamp_str = r.created_at.strftime('%Y-%m-%d %H:%M:%S')
elif hasattr(r, 'timestamp') and r.timestamp:
timestamp_str = r.timestamp.strftime('%Y-%m-%d %H:%M:%S')
except:
timestamp_str = str(r.created_at or r.timestamp or "")
result.append({
"id": r.id,
"user_id": r.user_id,
"username": username,
"type": r.type,
"amount": r.amount,
"desc": r.desc,
"time": timestamp_str
})
return {"history": result}
except Exception as e:
# 记录错误但返回空列表避免500错误
print(f"Error in list_all_history: {str(e)}")
return {"history": [], "error": str(e)}

View File

@@ -0,0 +1,420 @@
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from typing import Optional, List, Dict, Any
import os
import logging
import pandas as pd
import json
import time
import re
import traceback
from datetime import datetime
import requests
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler("news_stock_api.log"), logging.StreamHandler()]
)
logger = logging.getLogger("NewsStockAPI")
router = APIRouter(
tags=["news_stock"],
responses={404: {"description": "Not found"}},
)
# 请求模型
class NewsAnalysisRequest(BaseModel):
news_content: str
# 响应模型
class AnalysisResult(BaseModel):
industries: List[str]
companies: List[dict]
analysis_details: str
timestamp: str
# DeepSeek API配置
API_KEY = "sk-8a121704a9bc4ec6a5ab0ae16e0bc0ba"
BASE_URL = "https://api.deepseek.com"
class NewsStockAnalyzer:
"""热点新闻股票影响分析器 - HTTP请求版本"""
def __init__(self):
"""初始化分析器"""
self.api_key = API_KEY
self.base_url = BASE_URL
self.load_company_info()
def load_company_info(self):
"""加载上市公司信息"""
try:
logger.info("开始加载上市公司信息...")
# 只使用相对路径,移除硬编码的绝对路径
possible_paths = [
# 相对于当前文件的路径
os.path.join(os.path.dirname(__file__), "..", "..", "..", "NewsImpactOnStocks", "上市公司信息表.xlsx"),
# 相对于backend目录的路径
os.path.join(os.path.dirname(__file__), "..", "..", "上市公司信息表.xlsx"),
# 相对于项目根目录的路径
os.path.join(os.path.dirname(__file__), "..", "..", "..", "上市公司信息表.xlsx")
]
company_info_path = None
for path in possible_paths:
if os.path.exists(path):
company_info_path = path
break
if company_info_path:
self.company_df = pd.read_excel(company_info_path)
logger.info(f"成功加载上市公司信息,共 {len(self.company_df)} 条记录")
logger.info(f"加载路径: {company_info_path}")
else:
logger.warning("未找到上市公司信息表,使用空数据")
logger.warning(f"尝试的路径: {possible_paths}")
self.company_df = pd.DataFrame()
except Exception as e:
logger.error(f"加载上市公司信息失败: {e}")
self.company_df = pd.DataFrame()
def call_deepseek_api(self, messages, max_retries=2):
"""使用HTTP请求调用DeepSeek API - 简化版本"""
for attempt in range(max_retries):
try:
logger.info(f"调用DeepSeek API尝试次数: {attempt + 1}/{max_retries}")
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
data = {
"model": "deepseek-chat",
"messages": messages,
"temperature": 0.1,
"stream": False
}
response = requests.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=data,
timeout=25 # 简化超时设置
)
if response.status_code == 200:
result = response.json()
content = result["choices"][0]["message"]["content"]
logger.info(f"API调用成功返回内容长度: {len(content)}")
return content
else:
logger.error(f"API请求失败: {response.status_code}")
if attempt < max_retries - 1:
time.sleep(1)
except Exception as e:
logger.error(f"API调用失败: {e}")
if attempt < max_retries - 1:
time.sleep(1)
return None
def analyze_news_impact_on_industries(self, news):
"""分析新闻对行业的影响"""
logger.info("开始分析新闻对行业的影响...")
system_prompt = """
# 角色
你是一位顶尖的证券分析师,拥有深厚的行业知识和敏锐的市场洞察力。能够对热点新闻进行深入分析,判断其对各行业的影响,并给出详细理由。
## 技能
### 技能 1分析新闻对行业的影响
1. 当用户提供热点新闻标题或内容时,确定其中的关键要素;
2. 结合最新的市场趋势、经济环境以及各行业发展情况,深入分析该新闻可能对哪些行业产生影响;
3. 输出全部可能有被影响的行业,并整合到一句话中。
## 输出格式
请按以下格式输出:
影响行业行业1、行业2、行业3...
影响分析:
1. 行业1[分析理由]
2. 行业2[分析理由]
...
## 限制:
- 只分析与新闻相关的行业影响,拒绝回答与新闻无关的问题。
- 分析理由要充分、有条理。
"""
user_prompt = f"""
下面是用户提供的热点新闻信息,请分析该新闻可能影响的全部行业,并给出详细理由。
===热点新闻开始===
{news}
===热点新闻结束===
"""
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
# 优先使用更快的chat模型
analysis = self.call_deepseek_api(messages)
if analysis:
logger.info("行业影响分析完成")
# 提取行业
industries = []
industry_pattern = r"影响行业[:](.*?)(?:\n|$)"
industry_match = re.search(industry_pattern, analysis)
if industry_match:
industry_text = industry_match.group(1).strip()
industries = [ind.strip() for ind in re.split(r'[,,、]', industry_text) if ind.strip()]
if not industries:
industries = self._extract_industries_from_text(analysis)
return {
"industries": industries,
"reasons": analysis
}
else:
return {
"industries": [],
"reasons": "分析过程中出现错误请检查API配置或网络连接。"
}
def _extract_industries_from_text(self, text):
"""从文本中提取可能的行业名称"""
common_industries = [
"互联网", "金融", "银行", "保险", "证券", "房地产", "医药", "医疗", "健康",
"教育", "零售", "消费", "制造", "能源", "电力", "新能源", "汽车", "电子",
"半导体", "通信", "传媒", "娱乐", "旅游", "餐饮", "物流", "交通", "航空",
"铁路", "船舶", "钢铁", "煤炭", "石油", "化工", "农业", "食品", "饮料",
"纺织", "服装", "建筑", "建材", "家电", "软件", "硬件", "人工智能", "云计算",
"大数据", "区块链", "物联网", "5G", "军工", "航天", "环保", "新材料"
]
found_industries = []
for industry in common_industries:
if industry in text:
found_industries.append(industry)
return found_industries
def search_related_companies(self, industries):
"""查找与行业相关的上市公司 - 简化版本"""
logger.info(f"开始查找与行业相关的上市公司: {', '.join(industries)}")
if self.company_df.empty:
return []
# 简化关键词映射
search_keywords = set()
for industry in industries:
# 直接使用行业名称作为关键词
search_keywords.add(industry)
# 添加一些基本的相关词
if "汽车" in industry:
search_keywords.update(["汽车", "汽车零部件", "新能源汽车"])
elif "电池" in industry:
search_keywords.update(["电池", "锂电池", "储能"])
elif "电子" in industry:
search_keywords.update(["电子", "消费电子"])
elif "半导体" in industry:
search_keywords.update(["半导体", "芯片"])
elif "通信" in industry:
search_keywords.update(["通信", "5G", "通信设备"])
logger.info(f"搜索关键词: {', '.join(search_keywords)}")
# 搜索相关公司
company_scores = {}
for keyword in search_keywords:
try:
# 在行业和主营业务中搜索
industry_match = self.company_df['IndustryName'].str.contains(keyword, na=False)
business_match = self.company_df['MAINBUSSINESS'].str.contains(keyword, na=False)
matched_companies = self.company_df[industry_match | business_match]
for _, company in matched_companies.iterrows():
symbol = company.get('Symbol')
score = company_scores.get(symbol, 0)
# 简化评分行业匹配得2分业务匹配得1分
if industry_match.iloc[company.name]:
score += 2
if business_match.iloc[company.name]:
score += 1
company_scores[symbol] = score
except Exception as e:
logger.error(f"搜索关键词 '{keyword}' 时出错: {e}")
if not company_scores:
logger.warning("未找到与行业相关的公司")
return []
# 按得分排序取前10家
sorted_companies = sorted(company_scores.items(), key=lambda x: x[1], reverse=True)[:10]
# 转换为返回格式
related_companies = []
for symbol, score in sorted_companies:
company_matches = self.company_df[self.company_df['Symbol'] == symbol]
if len(company_matches) > 0:
company_row = company_matches.iloc[0]
company_info = {
"code": str(company_row.get('Symbol', '')),
"name": str(company_row.get('ShortName', '')),
"industry": str(company_row.get('IndustryName', '')),
"business": str(company_row.get('MAINBUSSINESS', ''))[:200],
"score": score
}
related_companies.append(company_info)
logger.info(f"找到 {len(related_companies)} 家相关公司")
return related_companies
def analyze_company_impact(self, news, companies):
"""分析新闻对具体公司的影响 - 简化版本"""
if not companies:
return "未找到相关公司"
try:
logger.info(f"开始分析新闻对 {len(companies)} 家公司的影响...")
# 简化prompt
system_prompt = """你是证券分析师,请分析新闻对相关公司的影响。
输出格式:
🎯 公司名称(代码)
📈 影响分析:[简述影响]
要求每家公司分析不超过50字。"""
# 只分析前5家公司
company_list = []
for company in companies[:5]:
company_list.append(f"{company['name']}{company['code']}- {company['industry']}")
user_prompt = f"新闻:{news}\n\n相关公司:\n" + "\n".join(company_list)
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
]
analysis = self.call_deepseek_api(messages)
if analysis:
logger.info(f"公司影响分析完成")
return analysis
else:
return "分析服务暂时不可用"
except Exception as e:
logger.error(f"分析公司影响时出错: {e}")
return "分析过程中出现错误"
def analyze_news(self, news):
"""完整的新闻分析流程 - 简化版本"""
try:
# 1. 分析行业影响
industry_result = self.analyze_news_impact_on_industries(news)
industries = industry_result["industries"]
industry_analysis = industry_result["reasons"]
# 2. 查找相关公司
companies = self.search_related_companies(industries)
# 3. 分析公司影响
company_analysis = self.analyze_company_impact(news, companies)
# 4. 返回结果
return {
"industries": industries,
"companies": companies,
"analysis_details": f"{industry_analysis}\n\n{company_analysis}",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
except Exception as e:
logger.error(f"新闻分析失败: {e}")
return {
"industries": [],
"companies": [],
"analysis_details": "分析过程中出现错误",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
# 创建分析器实例
news_analyzer = NewsStockAnalyzer()
@router.post("/analyze", response_model=AnalysisResult)
async def analyze_news(request: NewsAnalysisRequest):
"""分析热点新闻对股票的影响"""
try:
logger.info(f"收到新闻分析请求,内容长度: {len(request.news_content)}")
if not request.news_content.strip():
raise HTTPException(status_code=400, detail="新闻内容不能为空")
# 执行分析
result = news_analyzer.analyze_news(request.news_content)
# 验证结果数据
if not isinstance(result, dict):
logger.error("分析结果不是字典类型")
raise HTTPException(status_code=500, detail="分析结果格式错误")
# 确保必要字段存在
if "industries" not in result:
result["industries"] = []
if "companies" not in result:
result["companies"] = []
if "analysis_details" not in result:
result["analysis_details"] = "分析完成"
if "timestamp" not in result:
result["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
logger.info(f"分析完成,找到 {len(result['industries'])} 个相关行业,{len(result['companies'])} 家相关公司")
# 创建响应对象,确保类型安全
try:
response = AnalysisResult(**result)
logger.info("成功创建AnalysisResult响应对象")
return response
except Exception as e:
logger.error(f"创建响应对象失败: {e}")
logger.error(f"结果数据: {result}")
# 返回一个最基本的安全响应
return AnalysisResult(
industries=[],
companies=[],
analysis_details="分析完成,但响应格式化失败",
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
except HTTPException:
raise
except Exception as e:
logger.error(f"处理新闻分析请求失败: {e}")
logger.error(traceback.format_exc())
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)[:100]}")
@router.get("/test")
async def test_news_stock_api():
"""测试新闻选股API路由是否正常工作"""
return {"status": "ok", "message": "热点新闻选股路由正常工作"}

View File

@@ -0,0 +1,140 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import models, database
from pydantic import BaseModel
from typing import Optional
from app.utils_jwt import get_current_user
router = APIRouter()
def get_db():
db = database.SessionLocal()
try:
yield db
finally:
db.close()
# 根路径返回所有订单(管理员)
@router.get("/")
def root_orders(db: Session = Depends(get_db), user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
try:
# 从历史记录表中获取消费记录
consume_records = db.query(models.History).filter(models.History.type == "consume").all()
result = []
for r in consume_records:
# 获取用户名(如果存在)
username = ""
user_record = db.query(models.User).filter(models.User.id == r.user_id).first()
if user_record:
username = user_record.username
# 安全处理时间格式
timestamp_str = ""
try:
if hasattr(r, 'created_at') and r.created_at:
timestamp_str = r.created_at.strftime('%Y-%m-%d %H:%M:%S')
elif hasattr(r, 'timestamp') and r.timestamp:
timestamp_str = r.timestamp.strftime('%Y-%m-%d %H:%M:%S')
except:
timestamp_str = str(r.created_at or r.timestamp or "")
result.append({
"id": r.id,
"user_id": r.user_id,
"username": username,
"type": r.type,
"amount": abs(r.amount), # 转为正数显示
"desc": r.desc,
"time": timestamp_str,
"status": "已完成" # 默认状态
})
return {"orders": result}
except Exception as e:
print(f"Error in root_orders: {str(e)}")
return {"orders": [], "error": str(e)}
# 查询当前用户订单
@router.get("/my")
def my_orders(db: Session = Depends(get_db), user=Depends(get_current_user)):
orders = db.query(models.Order).filter(models.Order.user_id == user.id).all()
return {"orders": [
{"id": o.id, "app_id": o.app_id, "amount": o.amount, "status": o.status, "timestamp": o.timestamp} for o in orders
]}
# 管理员查询所有订单
@router.get("/all")
def all_orders(db: Session = Depends(get_db), user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
try:
# 从历史记录表中获取消费记录
consume_records = db.query(models.History).filter(models.History.type == "consume").all()
result = []
for r in consume_records:
# 获取用户名(如果存在)
username = ""
user_record = db.query(models.User).filter(models.User.id == r.user_id).first()
if user_record:
username = user_record.username
# 安全处理时间格式
timestamp_str = ""
try:
if hasattr(r, 'created_at') and r.created_at:
timestamp_str = r.created_at.strftime('%Y-%m-%d %H:%M:%S')
elif hasattr(r, 'timestamp') and r.timestamp:
timestamp_str = r.timestamp.strftime('%Y-%m-%d %H:%M:%S')
except:
timestamp_str = str(r.created_at or r.timestamp or "")
result.append({
"id": r.id,
"user_id": r.user_id,
"username": username,
"type": r.type,
"amount": abs(r.amount), # 转为正数显示
"desc": r.desc,
"time": timestamp_str,
"status": "已完成" # 默认状态
})
return {"orders": result}
except Exception as e:
print(f"Error in all_orders: {str(e)}")
return {"orders": [], "error": str(e)}
class OrderUpdate(BaseModel):
status: Optional[str] = None
amount: Optional[float] = None
# 管理员修改订单
@router.put("/orders/{order_id}")
def update_order(order_id: int, order: OrderUpdate, db: Session = Depends(get_db), user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
db_order = db.query(models.Order).filter(models.Order.id == order_id).first()
if not db_order:
raise HTTPException(status_code=404, detail="订单不存在")
for field, value in order.dict(exclude_unset=True).items():
setattr(db_order, field, value)
db.commit()
return {"msg": "修改成功"}
# 管理员删除订单
@router.delete("/orders/{order_id}")
def delete_order(order_id: int, db: Session = Depends(get_db), user=Depends(get_current_user)):
if not getattr(user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限")
db_order = db.query(models.Order).filter(models.Order.id == order_id).first()
if not db_order:
raise HTTPException(status_code=404, detail="订单不存在")
db.delete(db_order)
db.commit()
return {"msg": "删除成功"}

View File

@@ -0,0 +1,248 @@
from fastapi import APIRouter, HTTPException, status
from typing import Optional
import os
import logging
import requests
import json
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler("twitter_api.log"), logging.StreamHandler()]
)
logger = logging.getLogger("TwitterAPI")
router = APIRouter(
tags=["twitter"],
responses={404: {"description": "Not found"}},
)
# DeepSeek API配置
API_KEY = "sk-8a121704a9bc4ec6a5ab0ae16e0bc0ba"
BASE_URL = "https://api.deepseek.com"
# 帮助调试问题的简单测试路由
@router.get("/test")
async def test_twitter_api():
"""测试Twitter API路由是否正常工作"""
return {"status": "ok", "message": "Twitter路由正常工作"}
class TwitterService:
def __init__(self):
"""初始化Twitter和用户推文总结服务"""
try:
# 使用HTTP请求方式调用DeepSeek API
self.api_key = API_KEY
self.base_url = BASE_URL
self.twitter_api_key = "e3dad005b0e54bdc88c6178a89adec13"
self.twitter_api_url = "https://api.twitterapi.io/twitter/tweet/advanced_search"
logger.info("TwitterService 初始化成功")
except Exception as e:
logger.error(f"初始化TwitterService失败: {str(e)}")
raise
def call_deepseek_api(self, messages, model="deepseek-chat"):
"""使用HTTP请求调用DeepSeek API"""
try:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
data = {
"model": model,
"messages": messages,
"temperature": 0.1,
"stream": False
}
response = requests.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=data,
timeout=60
)
if response.status_code == 200:
result = response.json()
return result["choices"][0]["message"]["content"]
else:
logger.error(f"DeepSeek API请求失败: {response.status_code}, {response.text}")
return None
except Exception as e:
logger.error(f"调用DeepSeek API失败: {e}")
return None
def get_user_tweets(self, username):
"""获取指定用户的最近推文 - 保持与原始代码相同的逻辑"""
username = username.lstrip('@')
try:
logger.info(f"请求Twitter API获取用户 {username} 的推文")
# 使用与原始代码相同的请求参数
url = self.twitter_api_url
headers = {"X-API-Key": self.twitter_api_key}
params = {
"queryType": "Latest",
"query": f"from:{username}",
"count": 10 # 获取更多推文以生成更好的摘要
}
# 记录请求详情
logger.info(f"API请求详情: URL={url}, 参数={params}")
# 发送请求,保持简单实现
response = requests.get(url, headers=headers, params=params)
# 记录响应状态
logger.info(f"API响应状态码: {response.status_code}")
if response.status_code == 200:
try:
data = response.json()
# 记录API返回的JSON数据以便调试
logger.info(f"API响应数据: {json.dumps(data)[:500]}")
tweets = data.get("tweets", [])
if tweets:
tweet_texts = [tweet["text"] for tweet in tweets]
logger.info(f"成功获取用户 '{username}'{len(tweet_texts)} 条推文")
return {
"success": True,
"tweets": tweet_texts,
"content": "\n".join(tweet_texts)
}
else:
logger.warning(f"没有找到用户 '{username}' 的推文")
return {
"success": False,
"detail": f"没有找到用户 @{username} 的推文"
}
except Exception as e:
logger.error(f"解析API响应失败: {str(e)}")
logger.error(f"响应内容: {response.text[:500]}")
return {
"success": False,
"detail": f"解析Twitter API响应失败: {str(e)}"
}
else:
# 记录错误响应内容以便调试
logger.error(f"Twitter API 请求失败,状态码: {response.status_code}")
logger.error(f"响应内容: {response.text[:500]}")
return {
"success": False,
"detail": f"Twitter API请求失败状态码: {response.status_code}"
}
except Exception as e:
logger.error(f"获取用户推文失败: {str(e)}")
logger.exception("详细错误信息:")
return {
"success": False,
"detail": f"获取用户推文失败: {str(e)}"
}
def generate_summary(self, tweet_content, username):
"""根据推文内容生成总结 - 使用HTTP请求方式"""
try:
# 记录推文内容长度
content_length = len(tweet_content) if tweet_content else 0
logger.info(f"生成摘要: 用户={username}, 推文长度={content_length}")
# 如果推文内容为空,使用模拟数据
if not tweet_content:
logger.warning(f"推文内容为空,无法生成摘要")
return f"无法获取用户 @{username} 的推文数据,请稍后再试。"
# 计算最大摘要长度
max_summary_length = 280 - len(f"{username} 的最近推文摘要:") - 1
# 使用HTTP请求调用DeepSeek API生成摘要
try:
logger.info("调用DeepSeek API生成摘要")
messages = [
{"role": "system", "content": "You are a helpful assistant that summarizes tweets accurately"},
{"role": "user", "content": f"请总结用户 @{username} 以下推文的主要内容必须使用简体中文总结必须以序号1.、2.、...)分隔每条要点,每条要点简洁明了且以句号或感叹号结尾,总长度不得超过 {max_summary_length} 字符:\n{tweet_content}"}
]
summary_content = self.call_deepseek_api(messages)
if summary_content:
logger.info(f"成功生成摘要,长度: {len(summary_content)}")
else:
logger.error("DeepSeek API调用失败")
return f"生成摘要失败,请稍后再试。"
except Exception as e:
logger.error(f"DeepSeek API调用失败: {str(e)}")
logger.exception("详细错误信息:")
return f"生成摘要失败,请稍后再试。错误: {str(e)[:100]}"
# 确保返回的内容不超过限制
summary = summary_content.strip()
if len(summary) > max_summary_length:
summary = summary[:max_summary_length]
# 检查并删除不完整的最后一条要点
lines = summary.split('\n')
if lines:
last_line = lines[-1]
# 检查最后一条是否以句号、感叹号或问号结尾
if not last_line.endswith(('', '', '', '.')):
# 如果不完整,删除最后一条
lines.pop()
summary = '\n'.join(lines)
logger.info(f"成功为用户 '{username}' 生成摘要")
return summary
except Exception as e:
logger.error(f"生成摘要失败: {str(e)}")
logger.exception("详细错误信息:")
return f"生成摘要失败,请稍后再试。"
# 创建TwitterService实例
twitter_service = TwitterService()
@router.get("/summary")
async def get_twitter_summary(username: str):
"""获取Twitter用户最近推文的摘要"""
try:
logger.info(f"收到Twitter摘要请求用户名: {username}")
# 获取用户推文
tweets_result = twitter_service.get_user_tweets(username)
logger.info(f"获取推文结果: {json.dumps(tweets_result)[:200] if isinstance(tweets_result, dict) else '未知结果'}")
# 如果获取推文失败,直接返回错误信息
if not tweets_result.get("success", False):
logger.warning(f"获取推文失败: {tweets_result.get('detail', '未知错误')}")
return {
"detail": tweets_result.get("detail", "获取推文失败")
}
# 生成摘要
tweet_content = tweets_result.get("content", "")
summary = twitter_service.generate_summary(tweet_content, username)
logger.info(f"生成摘要: 长度={len(summary) if summary else 0}")
# 返回摘要结果 - 确保格式与原始main.py中的返回完全一致
return {
"username": username,
"summary": summary,
"status": "success"
}
except Exception as e:
logger.error(f"处理Twitter摘要请求失败: {str(e)}")
logger.exception("详细错误信息:")
# 与原始main.py中的错误处理一致
return {
"detail": f"处理请求失败: {str(e)}"
}

View File

@@ -0,0 +1,325 @@
from fastapi import APIRouter, HTTPException, status
from typing import Optional
import os
import logging
import requests
import tweepy
import json
from pydantic import BaseModel
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[logging.FileHandler("twitter_post_api.log"), logging.StreamHandler()]
)
logger = logging.getLogger("TwitterPostAPI")
router = APIRouter(
tags=["twitter_post"],
responses={404: {"description": "Not found"}},
)
# DeepSeek API配置
API_KEY = "sk-8a121704a9bc4ec6a5ab0ae16e0bc0ba"
BASE_URL = "https://api.deepseek.com"
# 请求模型
class TwitterPostRequest(BaseModel):
username: str
post_to_twitter: bool = False
# 帮助调试问题的简单测试路由
@router.get("/test")
async def test_twitter_post_api():
"""测试Twitter发推API路由是否正常工作"""
return {"status": "ok", "message": "Twitter发推路由正常工作"}
class TwitterPostService:
def __init__(self):
"""初始化Twitter发推服务"""
try:
# 使用HTTP请求方式调用DeepSeek API
self.api_key = API_KEY
self.base_url = BASE_URL
# Twitter API凭证
self.api_key_twitter = "3nt1jN4VvqUaaXGHv9AN5VsTV"
self.api_secret = "M2io73S7TzitFiBw825QIq8atyZRljbIDQuTpH39uFZanQ4XFh"
self.access_token = "1944636908-prxfjL6OIb56BQjuFTdChrUPh81OjmBbV7pfnWw"
self.access_secret = "D5AdCVRvIhGEmTmXA8hL5ciAUxIqNMZ3K3B3YejpqqNKj"
self.bearer_token = "AAAAAAAAAAAAAAAAAAAAAGOd0gEAAAAALtv%2BzLsfGLLa5ydUt60ci6J5ce0%3DMBXViJ1NLY4XYdeuMq1xZQ98kHbeGK5lAJoV2j7Ssmcafk8Skn"
# 初始化Twitter客户端用于发送
self.twitter_client = tweepy.Client(
bearer_token=self.bearer_token,
consumer_key=self.api_key_twitter,
consumer_secret=self.api_secret,
access_token=self.access_token,
access_token_secret=self.access_secret
)
# 用于获取推文的API
self.twitter_api_key = "e3dad005b0e54bdc88c6178a89adec13"
self.twitter_api_url = "https://api.twitterapi.io/twitter/tweet/advanced_search"
logger.info("TwitterPostService 初始化成功")
except Exception as e:
logger.error(f"初始化TwitterPostService失败: {str(e)}")
raise
def call_deepseek_api(self, messages, model="deepseek-chat"):
"""使用HTTP请求调用DeepSeek API"""
try:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
data = {
"model": model,
"messages": messages,
"temperature": 0.1,
"stream": False
}
response = requests.post(
f"{self.base_url}/chat/completions",
headers=headers,
json=data,
timeout=60
)
if response.status_code == 200:
result = response.json()
return result["choices"][0]["message"]["content"]
else:
logger.error(f"DeepSeek API请求失败: {response.status_code}, {response.text}")
return None
except Exception as e:
logger.error(f"调用DeepSeek API失败: {e}")
return None
def get_user_tweets(self, username):
"""获取指定用户的最近推文"""
username = username.lstrip('@')
try:
logger.info(f"请求Twitter API获取用户 {username} 的推文")
# 使用与原始代码相同的请求参数
url = self.twitter_api_url
headers = {"X-API-Key": self.twitter_api_key}
params = {
"queryType": "Latest",
"query": f"from:{username}",
"count": 5 # 获取5条推文
}
# 记录请求详情
logger.info(f"API请求详情: URL={url}, 参数={params}")
# 发送请求
response = requests.get(url, headers=headers, params=params)
# 记录响应状态
logger.info(f"API响应状态码: {response.status_code}")
if response.status_code == 200:
try:
data = response.json()
# 记录API返回的JSON数据以便调试
logger.info(f"API响应数据: {json.dumps(data)[:500]}")
tweets = data.get("tweets", [])
if tweets:
tweet_texts = [tweet["text"] for tweet in tweets]
logger.info(f"成功获取用户 '{username}'{len(tweet_texts)} 条推文")
return {
"success": True,
"tweets": tweet_texts,
"content": "\n".join(tweet_texts)
}
else:
logger.warning(f"没有找到用户 '{username}' 的推文")
return {
"success": False,
"detail": f"没有找到用户 @{username} 的推文"
}
except Exception as e:
logger.error(f"解析API响应失败: {str(e)}")
logger.error(f"响应内容: {response.text[:500]}")
return {
"success": False,
"detail": f"解析Twitter API响应失败: {str(e)}"
}
else:
# 记录错误响应内容以便调试
logger.error(f"Twitter API 请求失败,状态码: {response.status_code}")
logger.error(f"响应内容: {response.text[:500]}")
return {
"success": False,
"detail": f"Twitter API请求失败状态码: {response.status_code}"
}
except Exception as e:
logger.error(f"获取用户推文失败: {str(e)}")
logger.exception("详细错误信息:")
return {
"success": False,
"detail": f"获取用户推文失败: {str(e)}"
}
def generate_summary(self, tweet_content, username):
"""根据推文内容生成总结 - 使用HTTP请求方式"""
try:
# 记录推文内容长度
content_length = len(tweet_content) if tweet_content else 0
logger.info(f"生成摘要: 用户={username}, 推文长度={content_length}")
# 如果推文内容为空,返回错误
if not tweet_content:
logger.warning(f"推文内容为空,无法生成摘要")
return f"无法获取用户 @{username} 的推文数据,请稍后再试。"
# 计算最大摘要长度
prefix = f"{username} 的最近推文摘要:"
max_tweet_length = 280
max_summary_length = max_tweet_length - len(prefix) - 1
# 使用HTTP请求调用DeepSeek API生成摘要
try:
logger.info("调用DeepSeek API生成摘要")
messages = [
{"role": "system", "content": "You are a helpful assistant that summarizes tweets accurately"},
{"role": "user", "content": f"请总结用户 @{username} 以下推文的主要内容必须使用简体中文总结必须以序号1.、2.、...)分隔每条要点,每条要点简洁明了且以句号或感叹号结尾,总长度不得超过 {max_summary_length} 字符:\n{tweet_content}"}
]
summary_content = self.call_deepseek_api(messages)
if summary_content:
logger.info(f"成功生成摘要,长度: {len(summary_content)}")
else:
logger.error("DeepSeek API调用失败")
return f"生成摘要失败,请稍后再试。"
except Exception as e:
logger.error(f"DeepSeek API调用失败: {str(e)}")
logger.exception("详细错误信息:")
return f"生成摘要失败,请稍后再试。错误: {str(e)[:100]}"
# 确保返回的内容不超过限制
summary = summary_content.strip()
if len(summary) > max_summary_length:
summary = summary[:max_summary_length]
# 检查并删除不完整的最后一条要点
lines = summary.split('\n')
if lines:
last_line = lines[-1]
# 检查最后一条是否以句号、感叹号或问号结尾
if not last_line.endswith(('', '', '', '.')):
# 如果不完整,删除最后一条
lines.pop()
summary = '\n'.join(lines)
logger.info(f"成功为用户 '{username}' 生成摘要")
return summary
except Exception as e:
logger.error(f"生成摘要失败: {str(e)}")
logger.exception("详细错误信息:")
return f"生成摘要失败,请稍后再试。"
def post_to_twitter(self, summary, username):
"""将摘要发布到Twitter"""
try:
prefix = f"{username} 的最近推文摘要:"
tweet_text = f"{prefix}\n{summary}"
logger.info(f"准备发送推文: {tweet_text[:100]}...")
logger.info(f"推文长度: {len(tweet_text)} 字符")
# 发送推文
response = self.twitter_client.create_tweet(text=tweet_text)
# 获取发送的推文ID
tweet_id = response.data['id'] if hasattr(response, 'data') and 'id' in response.data else "未知"
logger.info(f"推文发送成功ID: {tweet_id}")
return {
"success": True,
"tweet_id": tweet_id,
"tweet_url": f"https://twitter.com/user/status/{tweet_id}" if tweet_id != "未知" else None
}
except Exception as e:
logger.error(f"发送推文失败: {str(e)}")
logger.exception("详细错误信息:")
return {
"success": False,
"detail": f"发送推文失败: {str(e)}"
}
# 创建TwitterPostService实例
twitter_post_service = TwitterPostService()
@router.post("/create")
async def create_twitter_post(request: TwitterPostRequest):
"""获取Twitter用户最近推文的摘要并可选择发布到Twitter"""
try:
username = request.username
post_to_twitter = request.post_to_twitter
logger.info(f"收到Twitter发推请求用户名: {username}, 是否发送: {post_to_twitter}")
# 获取用户推文
tweets_result = twitter_post_service.get_user_tweets(username)
logger.info(f"获取推文结果: {json.dumps(tweets_result)[:200] if isinstance(tweets_result, dict) else '未知结果'}")
# 如果获取推文失败,直接返回错误信息
if not tweets_result.get("success", False):
logger.warning(f"获取推文失败: {tweets_result.get('detail', '未知错误')}")
return {
"detail": tweets_result.get("detail", "获取推文失败")
}
# 生成摘要
tweet_content = tweets_result.get("content", "")
summary = twitter_post_service.generate_summary(tweet_content, username)
logger.info(f"生成摘要: 长度={len(summary) if summary else 0}")
# 返回结果对象
result = {
"username": username,
"summary": summary,
"status": "success"
}
# 如果需要发送到Twitter
if post_to_twitter:
logger.info(f"准备发送摘要到Twitter...")
post_result = twitter_post_service.post_to_twitter(summary, username)
if post_result.get("success", False):
result["tweet_posted"] = True
result["tweet_id"] = post_result.get("tweet_id")
result["tweet_url"] = post_result.get("tweet_url")
else:
result["tweet_posted"] = False
result["tweet_error"] = post_result.get("detail", "发送推文失败")
else:
result["tweet_posted"] = False
return result
except Exception as e:
logger.error(f"处理Twitter发推请求失败: {str(e)}")
logger.exception("详细错误信息:")
# 与原始main.py中的错误处理一致
return {
"detail": f"处理请求失败: {str(e)}"
}

View File

@@ -0,0 +1,177 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app import schemas, models, database
from passlib.context import CryptContext
router = APIRouter()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def get_db():
db = database.SessionLocal()
try:
yield db
finally:
db.close()
from typing import List
@router.get("/", response_model=List[schemas.UserOut])
def list_users(db: Session = Depends(get_db)):
return db.query(models.User).all()
def get_password_hash(password):
return pwd_context.hash(password)
@router.post("/register", response_model=schemas.UserOut)
def register(user: schemas.UserCreate, db: Session = Depends(get_db)):
try:
# 检查用户名是否已存在
db_user = db.query(models.User).filter(models.User.username == user.username).first()
if db_user:
print(f"用户名已存在: {user.username}")
raise HTTPException(status_code=400, detail="用户名已被注册,请更换用户名")
hashed_password = get_password_hash(user.password)
new_user = models.User(
username=user.username,
hashed_password=hashed_password,
balance=user.balance,
is_admin=user.is_admin
)
db.add(new_user)
db.commit()
db.refresh(new_user)
print(f"用户注册成功: {user.username}, 初始余额: {user.balance}, 管理员权限: {user.is_admin}")
return new_user
except HTTPException:
# 已处理的HTTP异常直接抛出
raise
except Exception as e:
print("注册用户出错:", e)
raise HTTPException(status_code=500, detail=f"注册失败: {e}")
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
from fastapi.security import OAuth2PasswordRequestForm
from app.utils_jwt import create_access_token, get_current_user
@router.post("/login")
def login(user: schemas.UserCreate, db: Session = Depends(get_db)):
# 检查用户是否存在
db_user = db.query(models.User).filter(models.User.username == user.username).first()
if not db_user:
print(f"登录失败 - 用户不存在: {user.username}")
raise HTTPException(status_code=400, detail="用户名或密码错误")
# 验证密码
if not verify_password(user.password, db_user.hashed_password):
print(f"登录失败 - 密码错误: {user.username}")
raise HTTPException(status_code=400, detail="用户名或密码错误")
# 登录成功生成token
token = create_access_token({"sub": str(db_user.id)})
print(f"用户登录成功: {user.username}, 余额: {db_user.balance}")
return {"access_token": token, "token_type": "bearer", "user": {"id": db_user.id, "username": db_user.username, "balance": db_user.balance, "is_admin": db_user.is_admin}}
# 支持OAuth2标准token获取
@router.post("/token")
def token(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
db_user = db.query(models.User).filter(models.User.username == form_data.username).first()
if not db_user or not verify_password(form_data.password, db_user.hashed_password):
raise HTTPException(status_code=400, detail="Incorrect username or password")
token = create_access_token({"sub": str(db_user.id)})
return {"access_token": token, "token_type": "bearer"}
# 管理员创建用户(包括设置余额和权限)
@router.post("/create", response_model=schemas.UserOut)
def create_user(user: schemas.UserCreate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
# 检查当前用户是否为管理员
if not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限,只有管理员可以创建用户")
try:
# 检查用户名是否已存在
db_user = db.query(models.User).filter(models.User.username == user.username).first()
if db_user:
raise HTTPException(status_code=400, detail="用户名已被注册,请更换用户名")
hashed_password = get_password_hash(user.password)
new_user = models.User(
username=user.username,
hashed_password=hashed_password,
balance=user.balance,
is_admin=user.is_admin
)
db.add(new_user)
db.commit()
db.refresh(new_user)
print(f"管理员创建用户成功: {user.username}, 初始余额: {user.balance}, 管理员权限: {user.is_admin}")
return new_user
except HTTPException:
# 已处理的HTTP异常直接抛出
raise
except Exception as e:
print("创建用户出错:", e)
raise HTTPException(status_code=500, detail=f"创建用户失败: {e}")
# 管理员更新用户信息
@router.put("/update/{user_id}", response_model=dict)
def update_user(user_id: int, user_update: schemas.UserUpdate, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
# 检查当前用户是否为管理员
if not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限,只有管理员可以更新用户信息")
try:
# 查找用户
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 更新用户信息
update_data = user_update.dict(exclude_unset=True)
for field, value in update_data.items():
# 如果字段值不为Null才更新
if value is not None:
setattr(db_user, field, value)
db.commit()
print(f"管理员更新用户成功: {db_user.username}, 余额: {db_user.balance}, 管理员权限: {db_user.is_admin}")
return {"msg": "更新成功", "id": db_user.id}
except HTTPException:
# 已处理的HTTP异常直接抛出
raise
except Exception as e:
print("更新用户出错:", e)
raise HTTPException(status_code=500, detail=f"更新用户失败: {e}")
# 管理员删除用户
@router.delete("/delete/{user_id}", response_model=dict)
def delete_user(user_id: int, db: Session = Depends(get_db), current_user=Depends(get_current_user)):
# 检查当前用户是否为管理员
if not getattr(current_user, "is_admin", False):
raise HTTPException(status_code=403, detail="无权限,只有管理员可以删除用户")
try:
# 查找用户
db_user = db.query(models.User).filter(models.User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
# 删除用户
username = db_user.username # 保存用户名以便记录
db.delete(db_user)
db.commit()
print(f"管理员删除用户成功: {username}")
return {"msg": "删除成功"}
except HTTPException:
# 已处理的HTTP异常直接抛出
raise
except Exception as e:
print("删除用户出错:", e)
raise HTTPException(status_code=500, detail=f"删除用户失败: {e}")

31
backend/app/schemas.py Normal file
View File

@@ -0,0 +1,31 @@
from pydantic import BaseModel
class UserCreate(BaseModel):
username: str
password: str
balance: float = 0.0
is_admin: bool = False
class UserUpdate(BaseModel):
username: str = None
balance: float = None
is_admin: bool = None
class UserOut(BaseModel):
id: int
username: str
balance: float
class Config:
orm_mode = True
class HistoryOut(BaseModel):
id: int
user_id: int
type: str
amount: float
desc: str
timestamp: str
class Config:
orm_mode = True

14
backend/app/show_admin.py Normal file
View File

@@ -0,0 +1,14 @@
# 文件已删除admin 查询脚本不再需要。
db = database.SessionLocal()
admin = db.query(models.User).filter(models.User.username == "admin").first()
if admin:
print("id:", admin.id)
print("username:", admin.username)
print("is_admin:", admin.is_admin)
print("is_active:", admin.is_active)
print("balance:", admin.balance)
print("hashed_password:", admin.hashed_password)
else:
print("admin 用户不存在")
db.close()

44
backend/app/utils_jwt.py Normal file
View File

@@ -0,0 +1,44 @@
from datetime import datetime, timedelta
from jose import JWTError, jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app import models, database
from sqlalchemy.orm import Session
SECRET_KEY = "mysecretkey123456" # 生产环境请用更安全的key
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/users/token")
def create_access_token(data: dict, expires_delta: timedelta = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_db():
db = database.SessionLocal()
try:
yield db
finally:
db.close()
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int = payload.get("sub")
if user_id is None:
raise credentials_exception
except JWTError:
raise credentials_exception
user = db.query(models.User).filter(models.User.id == int(user_id)).first()
if user is None:
raise credentials_exception
return user

167
backend/init_db.py Normal file
View File

@@ -0,0 +1,167 @@
import os
import sqlite3
from datetime import datetime, timedelta
from passlib.context import CryptContext
# 加密工具
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def init_database():
"""初始化数据库,确保所有必要的表和数据存在"""
# 连接数据库
db_path = 'aiplatform.db'
# 如果数据库文件已存在,先删除
if os.path.exists(db_path):
print(f"删除现有数据库文件: {db_path}")
os.remove(db_path)
print(f"创建新数据库: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 创建表(如果不存在)
create_tables(cursor)
# 初始化基础数据
init_data(cursor)
# 提交更改
conn.commit()
conn.close()
print("数据库初始化完成")
def create_tables(cursor):
"""创建所有必要的表"""
print("创建数据库表...")
# 用户表
cursor.execute('''
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
email TEXT UNIQUE,
hashed_password TEXT,
is_active INTEGER DEFAULT 1,
is_admin INTEGER DEFAULT 0,
balance REAL DEFAULT 0
)
''')
# 应用表
cursor.execute('''
CREATE TABLE IF NOT EXISTS apps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
desc TEXT,
price REAL,
status TEXT DEFAULT '上架',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# 订单表
cursor.execute('''
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
app_id INTEGER,
type TEXT DEFAULT '应用调用',
amount REAL,
description TEXT,
status TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (app_id) REFERENCES apps (id)
)
''')
# 历史记录表
cursor.execute('''
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
type TEXT,
amount REAL,
desc TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
)
''')
print("数据库表创建完成")
def init_data(cursor):
"""初始化基础数据"""
print("初始化基础数据...")
# 1. 创建admin用户
print("创建admin用户...")
hashed_password = pwd_context.hash("admin123")
cursor.execute('''
INSERT INTO users (username, hashed_password, is_admin, is_active, balance)
VALUES (?, ?, ?, ?, ?)
''', ("admin", hashed_password, 1, 1, 100))
# 2. 创建Twitter应用
print("创建Twitter应用...")
apps = [
("Twitter推文摘要", "输入Twitter用户名获取最近推文摘要", 12.0, "上架", datetime.utcnow()),
("Twitter自动发推", "输入Twitter用户名获取摘要并发送到Twitter", 15.0, "上架", datetime.utcnow())
]
for app in apps:
cursor.execute('''
INSERT INTO apps (name, desc, price, status, created_at)
VALUES (?, ?, ?, ?, ?)
''', app)
# 获取新创建的应用ID
cursor.execute("SELECT id FROM apps WHERE name = 'Twitter推文摘要'")
summary_app_id = cursor.fetchone()[0]
cursor.execute("SELECT id FROM apps WHERE name = 'Twitter自动发推'")
post_app_id = cursor.fetchone()[0]
# 3. 创建示例订单
print("创建示例订单...")
# 获取admin用户ID
cursor.execute("SELECT id FROM users WHERE username = 'admin'")
admin_id = cursor.fetchone()[0]
days_ago_5 = datetime.utcnow() - timedelta(days=5)
days_ago_2 = datetime.utcnow() - timedelta(days=2)
orders = [
(admin_id, summary_app_id, "应用调用", 12.0, "使用Twitter推文摘要服务", "已完成", days_ago_5),
(admin_id, post_app_id, "应用调用", 15.0, "使用Twitter自动发推服务", "已完成", days_ago_2)
]
for order in orders:
cursor.execute('''
INSERT INTO orders (user_id, app_id, type, amount, description, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', order)
# 4. 创建历史记录
print("创建历史记录...")
days_ago_10 = datetime.utcnow() - timedelta(days=10)
histories = [
(admin_id, "recharge", 100.0, "账户充值", days_ago_10),
(admin_id, "consume", -12.0, "使用Twitter推文摘要服务", days_ago_5),
(admin_id, "consume", -15.0, "使用Twitter自动发推服务", days_ago_2)
]
for history in histories:
cursor.execute('''
INSERT INTO history (user_id, type, amount, desc, created_at)
VALUES (?, ?, ?, ?, ?)
''', history)
# 如果想要手动运行初始化
if __name__ == "__main__":
print("开始初始化数据库...")
init_database()
print("数据库初始化完成!")

15
backend/requirements.txt Normal file
View File

@@ -0,0 +1,15 @@
fastapi
uvicorn
sqlalchemy
pydantic
passlib[bcrypt]
python-jose
python-multipart
PyJWT
openai
tweepy
requests
pandas
openpyxl
pydub
ffmpeg-python

Binary file not shown.

23
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

70
frontend/README.md Normal file
View File

@@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

18639
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
frontend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"antd": "^5.24.9",
"axios": "^1.9.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.5.3",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:8001"
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>AI应用平台</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "AI应用平台",
"name": "AI应用展示与付费平台",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

56
frontend/src/App.css Normal file
View File

@@ -0,0 +1,56 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 添加抖动动画,用于登录错误、余额不足等情景反馈 */
@keyframes shake {
0%, 100% { transform: translateX(0); }
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
20%, 40%, 60%, 80% { transform: translateX(5px); }
}
.shake {
animation: shake 0.6s cubic-bezier(.36,.07,.19,.97) both;
}
/* 按钮悬浮效果 */
.btn-hover-effect:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
transition: all 0.2s ease;
}

45
frontend/src/App.js Normal file
View File

@@ -0,0 +1,45 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Home from './pages/Home';
import Register from './pages/Register';
import Profile from './pages/Profile';
import Login from './pages/Login';
import AdminHome from './pages/Admin/AdminHome';
import NavBar from './components/NavBar';
import 'antd/dist/reset.css';
function App() {
// 权限控制:判断当前用户是否为管理员
const user = JSON.parse(localStorage.getItem('user') || 'null');
const isAdmin = user && user.is_admin;
return (
<Router>
<Routes>
<Route path="/admin/*" element={
isAdmin ? (
<AdminHome />
) : (
<Navigate to="/" replace />
)
} />
<Route path="*" element={
<>
<NavBar />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/profile" element={<Profile />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</>
} />
</Routes>
</Router>
);
}
export default App;

8
frontend/src/App.test.js Normal file
View File

@@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

70
frontend/src/auth.js Normal file
View File

@@ -0,0 +1,70 @@
// 根据环境自动选择API地址
const BASE_URL = '';
// 使用相对路径,所有 API 请求会自动走当前域名下的 Nginx 代理,避免 CORS 问题
// 工具函数获取token
export function getToken() {
return localStorage.getItem('token') || '';
}
// 通用fetch自动加Authorization
export async function authFetch(url, options = {}) {
const token = getToken();
const headers = {
'Content-Type': 'application/json',
...(options.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {})
};
// 如果有body且是对象转换为JSON字符串
if (options.body && typeof options.body === 'object') {
options.body = JSON.stringify(options.body);
}
try {
const response = await fetch(`${BASE_URL}${url}`, {
...options,
headers,
credentials: 'include' // 添加这个以支持跨域请求携带认证信息
});
// 预处理常见错误状态码
if (response.status === 401) {
// 未授权可能是token过期
localStorage.removeItem('token');
localStorage.removeItem('user');
throw new Error('登录已过期,请重新登录');
}
let responseData;
try {
responseData = await response.json();
} catch (e) {
throw new Error('服务器响应格式错误');
}
if (!response.ok) {
// 提取错误详情
const errorMessage =
responseData.detail ||
responseData.message ||
responseData.error ||
`请求失败 (${response.status})`;
console.error('API错误:', {
status: response.status,
url,
error: errorMessage
});
throw new Error(errorMessage);
}
return responseData;
} catch (error) {
console.error('请求错误:', error);
throw error;
}
}

View File

@@ -0,0 +1,429 @@
/* AI客服悬浮按钮 - 简约扁平化风格 */
.ai-chatbot-float-btn {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
background: #667eea;
color: white;
border-radius: 50%;
width: 56px;
height: 56px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
transition: all 0.3s ease;
font-size: 11px;
font-weight: 500;
border: none;
animation: floatBounce 3s ease-in-out infinite;
overflow: hidden;
}
.ai-chatbot-float-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.3);
background: #5a6fd8;
animation: none;
}
/* 跳动动画 - 简约版本 */
@keyframes floatBounce {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-3px);
}
}
.ai-chatbot-float-btn .anticon {
font-size: 18px;
margin-bottom: 1px;
}
.ai-chatbot-float-btn span {
font-size: 9px;
line-height: 1;
font-weight: 500;
}
/* 重新设计对话框 - 简约现代风格 */
.ai-chatbot-modal .ant-modal {
border-radius: 16px;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12);
}
.ai-chatbot-modal .ant-modal-content {
border-radius: 16px;
padding: 0;
background: #ffffff;
}
.ai-chatbot-modal .ant-modal-header {
background: #ffffff;
border-bottom: 1px solid #f0f2f5;
padding: 20px 24px;
border-radius: 16px 16px 0 0;
}
.ai-chatbot-modal .ant-modal-body {
padding: 0;
}
.ai-chatbot-header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.ai-chatbot-header > div:first-child {
display: flex;
align-items: center;
font-weight: 600;
font-size: 16px;
color: #1a1a1a;
}
.ai-chatbot-close-btn {
background: #f8f9fa;
border: none;
border-radius: 8px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
color: #6b7280;
transition: all 0.2s ease;
font-weight: 500;
}
.ai-chatbot-close-btn:hover {
background: #e5e7eb;
color: #374151;
}
.audio-playing-indicator {
color: #10b981;
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
/* 对话框容器 - 简约布局 */
.ai-chatbot-container {
height: 520px;
display: flex;
flex-direction: column;
background: #ffffff;
}
/* 消息列表区域 */
.ai-chatbot-messages {
flex: 1;
overflow-y: auto;
padding: 24px;
background: #fafbfc;
}
/* 消息项样式 - 简约气泡 */
.ai-chatbot-message {
display: flex;
margin-bottom: 20px;
animation: fadeInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-chatbot-message.user {
flex-direction: row-reverse;
}
.ai-chatbot-message .message-avatar {
margin: 0 12px;
flex-shrink: 0;
}
.ai-chatbot-message .message-content {
max-width: 75%;
position: relative;
}
/* 用户消息气泡 - 简约蓝色 */
.ai-chatbot-message.user .message-content {
background: #667eea;
color: white;
border-radius: 20px 20px 6px 20px;
padding: 12px 18px;
box-shadow: 0 2px 12px rgba(102, 126, 234, 0.15);
border: none;
}
/* AI消息气泡 - 简约灰色 */
.ai-chatbot-message.ai .message-content {
background: #ffffff;
color: #1f2937;
border-radius: 20px 20px 20px 6px;
padding: 12px 18px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
border: 1px solid #e5e7eb;
}
.message-text {
line-height: 1.5;
word-wrap: break-word;
margin-bottom: 6px;
font-size: 14px;
}
/* 播放音频按钮 */
.play-audio-btn {
padding: 4px 8px !important;
height: auto !important;
font-size: 12px;
margin-top: 6px;
border-radius: 12px !important;
font-weight: 500;
}
/* 音频加载指示器 */
.audio-loading-indicator {
display: flex;
align-items: center;
margin-top: 6px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.05);
border-radius: 12px;
font-size: 12px;
}
.ai-chatbot-message.user .audio-loading-indicator {
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.9);
}
.ai-chatbot-message.ai .audio-loading-indicator {
background: #f0f4ff;
color: #667eea;
}
.ai-chatbot-message.user .play-audio-btn {
color: rgba(255, 255, 255, 0.9) !important;
background: rgba(255, 255, 255, 0.1) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
}
.ai-chatbot-message.user .play-audio-btn:hover {
color: white !important;
background: rgba(255, 255, 255, 0.2) !important;
}
.ai-chatbot-message.ai .play-audio-btn {
color: #667eea !important;
background: #f0f4ff !important;
border: 1px solid #e0e8ff !important;
}
.ai-chatbot-message.ai .play-audio-btn:hover {
background: #e0e8ff !important;
}
/* 消息时间 */
.message-time {
font-size: 11px;
opacity: 0.7;
margin-top: 6px;
text-align: right;
font-weight: 400;
}
.ai-chatbot-message.ai .message-time {
text-align: left;
}
/* 输入区域 - 现代化设计 */
.ai-chatbot-input {
border-top: 1px solid #e5e7eb;
padding: 20px 24px;
background: #ffffff;
border-radius: 0 0 16px 16px;
}
.input-container {
display: flex;
align-items: center;
gap: 12px;
}
.ai-chatbot-input .ant-input {
border: 1px solid #e5e7eb;
background: #f8f9fa;
padding: 12px 18px;
font-size: 14px;
border-radius: 24px;
box-shadow: none;
transition: all 0.2s ease;
flex: 1;
}
.ai-chatbot-input .ant-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
background: #ffffff;
}
.ai-chatbot-input .ant-input::placeholder {
color: #9ca3af;
font-weight: 400;
}
.button-group {
display: flex;
gap: 8px;
}
/* 简约圆形按钮 */
.ai-chatbot-input .ant-btn {
border: none;
border-radius: 50%;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: #667eea;
color: white;
transition: all 0.2s ease;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
}
.ai-chatbot-input .ant-btn:hover {
background: #5a67d8;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.ai-chatbot-input .ant-btn:active {
transform: translateY(0);
box-shadow: 0 2px 6px rgba(102, 126, 234, 0.25);
}
.ai-chatbot-input .ant-btn:disabled {
background: #e5e7eb;
color: #9ca3af;
transform: none;
box-shadow: none;
cursor: not-allowed;
}
.ai-chatbot-input .ant-btn .anticon {
font-size: 16px;
}
/* 简约录音按钮 */
.ai-chatbot-input .ant-btn.recording-btn {
background: #ef4444;
animation: recordingPulse 1.5s ease-in-out infinite;
}
.ai-chatbot-input .ant-btn.recording-btn:hover {
background: #dc2626;
}
@keyframes recordingPulse {
0%, 100% {
transform: scale(1);
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.2);
}
50% {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
}
}
/* 滚动条美化 */
.ai-chatbot-messages::-webkit-scrollbar {
width: 6px;
}
.ai-chatbot-messages::-webkit-scrollbar-track {
background: transparent;
}
.ai-chatbot-messages::-webkit-scrollbar-thumb {
background: #d1d5db;
border-radius: 3px;
}
.ai-chatbot-messages::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
}
/* 响应式设计 */
@media (max-width: 768px) {
.ai-chatbot-float-btn {
bottom: 20px;
right: 20px;
width: 60px;
height: 60px;
}
.ai-chatbot-float-btn .anticon {
font-size: 18px;
}
.ai-chatbot-float-btn span {
font-size: 8px;
}
.ai-chatbot-modal {
margin: 0;
padding: 0;
}
.ai-chatbot-modal .ant-modal {
max-width: 100vw;
width: 100vw !important;
height: 100vh;
margin: 0;
border-radius: 0;
}
.ai-chatbot-container {
height: calc(100vh - 140px);
}
.ai-chatbot-message .message-content {
max-width: 85%;
}
.button-group {
gap: 6px;
}
.ai-chatbot-input .ant-btn {
width: 40px;
height: 40px;
}
}
/* 加载状态 */
.ai-chatbot-message .ant-spin {
color: #667eea;
}

View File

@@ -0,0 +1,747 @@
import React, { useState, useRef, useEffect } from 'react';
import { Modal, Button, Input, message, Avatar, Spin } from 'antd';
import { CustomerServiceOutlined, AudioOutlined, SendOutlined, SoundOutlined } from '@ant-design/icons';
import './AIChatbot.css';
// 录音功能类
class AudioRecorder {
constructor() {
this.mediaRecorder = null;
this.audioChunks = [];
this.recordingTimeout = null;
this.isRecording = false;
this.recordedMimeType = null;
this.mediaStream = null;
}
async start() {
try {
console.log('开始请求麦克风权限...');
this.mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('麦克风权限获取成功');
// 检查支持的音频格式
let options = { mimeType: 'audio/wav' };
if (!MediaRecorder.isTypeSupported('audio/wav')) {
if (MediaRecorder.isTypeSupported('audio/webm')) {
options = { mimeType: 'audio/webm' };
console.log('使用 audio/webm 格式');
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
options = { mimeType: 'audio/mp4' };
console.log('使用 audio/mp4 格式');
} else {
options = {};
console.log('使用默认音频格式');
}
} else {
console.log('使用 audio/wav 格式');
}
this.mediaRecorder = new MediaRecorder(this.mediaStream, options);
this.audioChunks = [];
this.isRecording = true;
this.recordedMimeType = options.mimeType || 'audio/webm';
this.mediaRecorder.ondataavailable = (event) => {
console.log('音频数据可用:', event.data.size);
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
};
this.mediaRecorder.onstart = () => {
console.log('MediaRecorder 开始录音');
this.isRecording = true;
};
this.mediaRecorder.onstop = () => {
console.log('MediaRecorder 停止录音');
this.isRecording = false;
this.releaseMediaStream();
};
this.mediaRecorder.start(1000); // 每秒收集一次数据
console.log('MediaRecorder.start() 调用完成');
return true;
} catch (error) {
console.error('录音启动失败:', error);
this.isRecording = false;
this.releaseMediaStream();
return false;
}
}
releaseMediaStream() {
if (this.mediaStream) {
console.log('释放媒体流资源...');
this.mediaStream.getTracks().forEach(track => {
track.stop();
console.log('媒体轨道已停止:', track.label);
});
this.mediaStream = null;
}
}
async stop() {
return new Promise((resolve) => {
console.log('准备停止录音...');
if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') {
console.log('MediaRecorder 未活跃,直接返回');
this.isRecording = false;
this.releaseMediaStream();
resolve(null);
return;
}
this.mediaRecorder.onstop = () => {
console.log('MediaRecorder 停止事件触发');
const audioBlob = new Blob(this.audioChunks, {
type: this.recordedMimeType || 'audio/webm'
});
console.log('创建音频 Blob大小:', audioBlob.size, '格式:', this.recordedMimeType);
this.isRecording = false;
// 立即释放媒体流资源
this.releaseMediaStream();
resolve(audioBlob);
};
try {
this.mediaRecorder.stop();
console.log('MediaRecorder.stop() 调用完成');
// 清除录音超时
if (this.recordingTimeout) {
clearTimeout(this.recordingTimeout);
this.recordingTimeout = null;
console.log('录音超时已清除');
}
} catch (error) {
console.error('停止录音时出错:', error);
this.isRecording = false;
this.releaseMediaStream();
resolve(null);
}
});
}
// 获取当前录音状态
get isCurrentlyRecording() {
return this.isRecording && this.mediaRecorder && this.mediaRecorder.state === 'recording';
}
// 设置录音超时
setRecordingTimeout(callback, duration) {
console.log(`设置录音超时: ${duration}ms`);
this.recordingTimeout = setTimeout(() => {
console.log('录音超时触发');
callback();
}, duration);
}
// 清除录音超时
clearRecordingTimeout() {
if (this.recordingTimeout) {
clearTimeout(this.recordingTimeout);
this.recordingTimeout = null;
console.log('录音超时已手动清除');
}
}
// 强制停止所有录音相关资源
forceStop() {
console.log('强制停止所有录音资源');
this.isRecording = false;
this.clearRecordingTimeout();
if (this.mediaRecorder) {
try {
if (this.mediaRecorder.state !== 'inactive') {
this.mediaRecorder.stop();
}
} catch (error) {
console.error('强制停止录音时出错:', error);
}
}
this.releaseMediaStream();
}
}
const AIChatbot = () => {
const [visible, setVisible] = useState(false);
const [messages, setMessages] = useState([
{
type: 'ai',
content: '您好!我是智能客服助手,有什么可以帮助您的吗?',
timestamp: new Date()
}
]);
const [inputValue, setInputValue] = useState('');
const [loading, setLoading] = useState(false);
const [recording, setRecording] = useState(false);
const [audioPlaying, setAudioPlaying] = useState(false);
const recorderRef = useRef(new AudioRecorder());
const audioRef = useRef(null);
const messagesEndRef = useRef(null);
// 检测是否为移动设备
const isMobileDevice = () => {
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
};
// 检测是否为iOS设备
const isIOSDevice = () => {
return /iPhone|iPad|iPod/i.test(navigator.userAgent);
};
// 检测是否为安卓设备
const isAndroidDevice = () => {
return /Android/i.test(navigator.userAgent);
};
// 获取设备特定的提示文本
const getAudioButtonText = (hasNewAudio) => {
return '播放语音';
};
// 尝试预先获取音频播放权限(移动端优化)
const tryEnableAudioContext = async () => {
if (isMobileDevice()) {
try {
// 创建一个静音的短音频来获取播放权限
const audio = new Audio();
audio.src = 'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YQAAAAA=';
audio.volume = 0;
const playPromise = audio.play();
if (playPromise !== undefined) {
await playPromise;
audio.pause();
console.log('音频播放权限已获取');
}
} catch (error) {
console.log('无法获取音频播放权限:', error);
}
}
};
// 自动滚动到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// 组件卸载时清理资源
useEffect(() => {
return () => {
console.log('组件卸载,清理录音资源');
if (recorderRef.current) {
recorderRef.current.forceStop();
}
if (audioRef.current) {
audioRef.current.pause();
}
};
}, []);
// 发送文本消息
const sendMessage = async () => {
if (!inputValue.trim()) return;
// 尝试获取音频播放权限(移动端优化)
tryEnableAudioContext();
const userMessage = {
type: 'user',
content: inputValue,
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
const questionText = inputValue;
setInputValue('');
setLoading(true);
try {
// 优化:设置较短的超时时间,提供更快的响应
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
const textResponse = await fetch('/ai-chatbot/query-text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ question: questionText }),
signal: controller.signal
});
clearTimeout(timeoutId);
if (!textResponse.ok) {
throw new Error(`服务器错误: ${textResponse.status}`);
}
const textData = await textResponse.json();
// 立即显示AI文本回答优化用户体验
const aiMessage = {
type: 'ai',
content: textData.answer,
timestamp: new Date(),
audioUrl: null,
messageId: textData.timestamp,
audioLoading: true // 添加音频加载状态
};
setMessages(prev => [...prev, aiMessage]);
setLoading(false);
// 第二步异步生成语音不阻塞UI
generateAudioAsync(textData.answer, textData.timestamp);
} catch (error) {
console.error('发送消息失败:', error);
setLoading(false);
let errorMsg = '抱歉,服务暂时不可用,请稍后重试';
if (error.name === 'AbortError') {
errorMsg = '请求超时,请检查网络连接后重试';
} else if (error.message.includes('fetch')) {
errorMsg = '网络连接失败,请检查网络后重试';
}
message.error(errorMsg);
const errorMessage = {
type: 'ai',
content: errorMsg,
timestamp: new Date()
};
setMessages(prev => [...prev, errorMessage]);
}
};
// 优化异步生成语音
const generateAudioAsync = async (text, timestamp) => {
try {
console.log('开始异步生成语音...');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000); // 15秒超时
const audioResponse = await fetch('/ai-chatbot/generate-audio', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ text, timestamp }),
signal: controller.signal
});
clearTimeout(timeoutId);
if (audioResponse.ok) {
const audioData = await audioResponse.json();
if (audioData.success) {
console.log('语音生成成功:', audioData.audio_url);
// 更新消息添加音频URL并移除加载状态
setMessages(prev => prev.map(msg =>
msg.messageId === timestamp
? { ...msg, audioUrl: audioData.audio_url, audioLoading: false }
: msg
));
// 自动播放语音(如果用户允许)
playAudio(audioData.audio_url, true);
} else {
console.error('语音生成失败:', audioData.error);
// 移除加载状态,不显示播放按钮
setMessages(prev => prev.map(msg =>
msg.messageId === timestamp
? { ...msg, audioUrl: null, audioLoading: false }
: msg
));
}
}
} catch (error) {
console.error('生成语音时出错:', error);
// 移除加载状态
setMessages(prev => prev.map(msg =>
msg.messageId === timestamp
? { ...msg, audioUrl: null, audioLoading: false }
: msg
));
// 语音生成失败不显示错误提示,因为文本已经正常显示
console.log('语音生成失败,但文本回答正常显示');
}
};
// 开始录音
const startRecording = async () => {
console.log('开始录音函数被调用,当前状态:', { recording, loading });
// 防止重复录音
if (recording || loading) {
console.log('已在录音或加载中,忽略');
return;
}
// 尝试获取音频播放权限(移动端优化)
tryEnableAudioContext();
console.log('准备开始录音...');
const success = await recorderRef.current.start();
if (success) {
console.log('录音启动成功');
setRecording(true);
// 设置4秒后自动停止录音
recorderRef.current.setRecordingTimeout(() => {
console.log('录音超时,强制停止录音...');
forceStopRecording();
}, 4000);
} else {
console.log('录音启动失败');
message.error('无法访问麦克风,请检查权限设置');
}
};
// 强制停止录音(用于超时)
const forceStopRecording = async () => {
console.log('强制停止录音当前MediaRecorder状态:', recorderRef.current.isCurrentlyRecording);
// 直接操作MediaRecorder不检查React状态
if (recorderRef.current.isCurrentlyRecording) {
setRecording(false);
setLoading(true);
recorderRef.current.clearRecordingTimeout();
try {
const audioBlob = await recorderRef.current.stop();
console.log('强制停止录音完成,音频数据:', audioBlob);
if (audioBlob && audioBlob.size > 0) {
await processAudioRecording(audioBlob);
} else {
console.log('录音数据为空或无效');
}
} catch (error) {
console.error('强制停止录音时出错:', error);
message.error('语音识别失败,请重试');
// 如果正常停止失败,使用强制停止确保资源释放
recorderRef.current.forceStop();
} finally {
setLoading(false);
setRecording(false);
}
} else {
console.log('MediaRecorder未在录音强制清理资源');
recorderRef.current.forceStop();
setRecording(false);
setLoading(false);
}
};
// 手动停止录音
const stopRecording = async () => {
console.log('手动停止录音函数被调用,当前状态:', { recording, loading });
if (!recording || loading) {
console.log('未在录音或正在加载,忽略停止请求');
return;
}
console.log('开始手动停止录音流程...');
setRecording(false);
setLoading(true);
recorderRef.current.clearRecordingTimeout();
try {
const audioBlob = await recorderRef.current.stop();
console.log('手动停止录音完成,音频数据:', audioBlob);
if (audioBlob && audioBlob.size > 0) {
await processAudioRecording(audioBlob);
} else {
console.log('录音数据为空或无效');
}
} catch (error) {
console.error('手动停止录音时出错:', error);
message.error('语音识别失败,请重试');
} finally {
setLoading(false);
setRecording(false);
}
};
// 处理录音音频
const processAudioRecording = async (audioBlob) => {
console.log('开始处理录音音频,大小:', audioBlob.size);
const formData = new FormData();
formData.append('file', audioBlob, 'voice.wav');
const response = await fetch('/ai-chatbot/asr', {
method: 'POST',
body: formData
});
console.log('ASR请求完成状态:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('ASR响应错误:', response.status, errorText);
throw new Error(`语音识别失败: ${response.status}`);
}
const data = await response.json();
console.log('语音识别结果:', data);
if (data.text && data.text.trim()) {
// 自动发送识别的文本
const userMessage = {
type: 'user',
content: data.text,
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
// 使用优化的AI回答流程
try {
console.log('发送到AI进行回答...');
// 第一步:快速获取文本回答
const textResponse = await fetch('/ai-chatbot/query-text', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ question: data.text })
});
if (textResponse.ok) {
const textData = await textResponse.json();
console.log('AI文本回答:', textData);
// 立即显示AI文本回答
const aiMessage = {
type: 'ai',
content: textData.answer,
timestamp: new Date(),
audioUrl: null,
messageId: textData.timestamp
};
setMessages(prev => [...prev, aiMessage]);
setLoading(false);
// 第二步:异步生成语音
generateAudioAsync(textData.answer, textData.timestamp);
} else {
console.error('AI查询失败:', textResponse.status);
message.error('AI回答失败请重试');
}
} catch (error) {
console.error('AI查询时出错:', error);
message.error('AI回答失败请重试');
}
} else {
console.log('语音识别结果为空');
}
};
// 播放音频
const playAudio = (audioUrl, isAutoPlay = false) => {
if (audioRef.current) {
audioRef.current.pause();
}
const audio = new Audio(audioUrl);
audioRef.current = audio;
audio.onplay = () => setAudioPlaying(true);
audio.onended = () => setAudioPlaying(false);
audio.onerror = () => {
setAudioPlaying(false);
if (!isAutoPlay) {
message.error('音频播放失败');
}
};
const playPromise = audio.play();
if (playPromise !== undefined) {
playPromise.then(() => {
// 播放成功
console.log('音频播放成功');
}).catch(error => {
console.log('音频播放被阻止:', error.name);
setAudioPlaying(false);
if (isAutoPlay) {
if (isIOSDevice()) {
console.log('iOS设备自动播放被阻止这是正常的安全策略');
} else if (isAndroidDevice()) {
console.log('Android设备自动播放被阻止Chrome/Firefox等浏览器的安全策略');
} else if (isMobileDevice()) {
console.log('移动端自动播放被阻止,用户需要手动点击播放');
} else {
console.log('桌面端自动播放被阻止,可能需要用户先与页面交互');
}
// 自动播放失败时不显示错误消息,让用户点击播放按钮
} else {
message.error('音频播放失败');
}
});
}
};
// 关闭对话框
const handleClose = () => {
setVisible(false);
if (audioRef.current) {
audioRef.current.pause();
setAudioPlaying(false);
}
if (recording) {
console.log('对话框关闭,强制停止录音并释放资源');
recorderRef.current.forceStop(); // 使用forceStop确保完全清理
setRecording(false);
setLoading(false);
}
};
return (
<>
{/* 悬浮按钮 */}
{!visible && (
<div className="ai-chatbot-float-btn" onClick={() => {
setVisible(true);
// 在打开对话框时尝试获取音频播放权限
tryEnableAudioContext();
}}>
<CustomerServiceOutlined />
<span>智能客服</span>
</div>
)}
{/* 对话框 */}
<Modal
title={
<div className="ai-chatbot-header">
<div>
<Avatar
size="default"
icon={<CustomerServiceOutlined />}
style={{ backgroundColor: '#667eea', marginRight: 12 }}
/>
<span>智能客服</span>
{audioPlaying && (
<div className="audio-playing-indicator" style={{ marginLeft: 16 }}>
<SoundOutlined /> 正在播放
</div>
)}
</div>
<div
className="ai-chatbot-close-btn"
onClick={handleClose}
>
</div>
</div>
}
open={visible}
onCancel={handleClose}
footer={null}
width={420}
className="ai-chatbot-modal"
closable={false}
centered
>
<div className="ai-chatbot-container">
{/* 消息列表 */}
<div className="ai-chatbot-messages">
{messages.map((msg, index) => (
<div
key={index}
className={`ai-chatbot-message ${msg.type === 'user' ? 'user' : 'ai'}`}
>
<div className="message-avatar">
{msg.type === 'user' ? (
<Avatar size="small" style={{ backgroundColor: '#52c41a' }}>U</Avatar>
) : (
<Avatar size="small" icon={<CustomerServiceOutlined />} style={{ backgroundColor: '#1890ff' }} />
)}
</div>
<div className="message-content">
<div className="message-text">{msg.content}</div>
{msg.audioLoading && (
<div className="audio-loading-indicator">
<Spin size="small" />
<span style={{ marginLeft: 8, fontSize: 12, color: '#999' }}>
正在生成语音...
</span>
</div>
)}
{msg.audioUrl && (
<Button
size="small"
type="link"
icon={<SoundOutlined />}
onClick={() => {
playAudio(msg.audioUrl);
}}
className={`play-audio-btn`}
>
{getAudioButtonText(false)}
</Button>
)}
<div className="message-time">
{msg.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* 输入区域 */}
<div className="ai-chatbot-input">
<div className="input-container">
<Input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onPressEnter={sendMessage}
placeholder="输入您的问题..."
disabled={loading || recording}
/>
<div className="button-group">
<Button
type="primary"
icon={<AudioOutlined />}
loading={loading && !recording}
onClick={recording ? stopRecording : startRecording}
className={recording ? 'recording-btn' : ''}
disabled={loading && !recording}
/>
<Button
type="primary"
icon={<SendOutlined />}
onClick={sendMessage}
disabled={!inputValue.trim() || loading || recording}
/>
</div>
</div>
</div>
</div>
</Modal>
</>
);
};
export default AIChatbot;

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Menu } from 'antd';
const items = [
{ label: <Link to="/admin/apps">应用管理</Link>, key: 'apps' },
{ label: <Link to="/admin/users">用户管理</Link>, key: 'users' },
{ label: <Link to="/admin/orders">订单管理</Link>, key: 'orders' },
{ label: <Link to="/admin/finance">充值记录</Link>, key: 'finance' },
{ label: <Link to="/admin/settings">系统设置</Link>, key: 'settings' },
];
export default function AdminNavBar() {
const location = useLocation();
const selectedKey = location.pathname.split('/')[2] || 'apps';
return (
<Menu mode="horizontal" selectedKeys={[selectedKey]} items={items} style={{ marginBottom: 24 }} />
);
}

View File

@@ -0,0 +1,184 @@
import React, { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Button } from 'antd';
function NavBar() {
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null'));
const [highlight, setHighlight] = useState(false);
const navigate = useNavigate();
// 全局余额同步,监听 localStorage user 变化
useEffect(() => {
const interval = setInterval(() => {
const u = JSON.parse(localStorage.getItem('user') || 'null');
if (!user || !u || user.balance !== u.balance || user.username !== u.username) {
setUser(u);
setHighlight(true);
setTimeout(() => setHighlight(false), 1200);
}
}, 500);
return () => clearInterval(interval);
}, [user]);
const handleLogout = () => {
localStorage.removeItem('user');
navigate('/login');
};
return (
<div style={{ width: '100%', background: '#fff', borderBottom: '1px solid #eee', position: 'sticky', top: 0, zIndex: 100 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', maxWidth: 1200, margin: '0 auto', height: 70, padding: '0 32px' }}>
{/* LOGO+菜单 靠左 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 32 }}>
{/* 推荐用 import 静态资源方式引用 logo */}
<img src={require('../logo.svg').default} alt="logo" style={{ height: 28, marginRight: 10 }} />
<span style={{ color: '#222', fontWeight: 800, fontSize: 20, letterSpacing: 1, marginRight: 18 }}>AI 应用平台</span>
<Link to="/" style={{ color: '#222', fontWeight: 500, fontSize: 16, textDecoration: 'none', padding: '4px 0', marginRight: 10 }}>首页</Link>
{user && (
<Link to="/profile" style={{ color: '#222', fontWeight: 500, fontSize: 16, textDecoration: 'none', padding: '4px 0' }}>个人中心</Link>
)}
</div>
{/* 用户区 右侧 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
{user && (
<span style={{ color: '#222', fontWeight: 400, fontSize: 15, borderRadius: 8, padding: '2px 12px', marginRight: 4, transition: 'box-shadow 0.4s', boxShadow: highlight ? '0 0 12px 2px #ffe06688' : undefined, background: highlight ? '#fffbe6' : '#f5f5f5' }}>
{user.username} | 余额: <span style={{ color: highlight ? '#faad14' : '#ffd700', fontWeight: 700, transition: 'color 0.4s' }}>{user.balance}</span>
</span>
)}
{user ? (
<Button type="primary" ghost size="small" onClick={handleLogout} style={{ borderRadius: 18, color: '#222', borderColor: '#ddd', fontWeight: 500, background: '#fafafa' }}>
退出登录
</Button>
) : (
<>
<Button type="primary" ghost size="small" style={{ borderRadius: 6, marginRight: 6 }} onClick={() => navigate('/login')}>登录</Button>
<Button type="primary" ghost size="small" style={{ borderRadius: 6 }} onClick={() => navigate('/register')}>注册</Button>
</>
)}
</div>
</div>
</div>
);
}
// 响应式样式
const navResponsiveStyle = `
@media (max-width: 600px) {
/* 顶部导航栏移动端纵向堆叠,居中 */
div[style*='max-width: 1200px'] {
flex-direction: column !important;
height: auto !important;
padding: 0 2vw !important;
align-items: center !important;
gap: 10px !important;
}
div[style*='max-width: 1200px'] > div {
justify-content: center !important;
margin: 0 !important;
gap: 8px !important;
}
div[style*='max-width: 1200px'] span, div[style*='max-width: 1200px'] a {
font-size: 16px !important;
}
div[style*='max-width: 1200px'] .ant-btn {
font-size: 15px !important;
padding: 8px 18px !important;
border-radius: 16px !important;
margin: 0 2vw !important;
}
}
.navbar-responsive {
height: 54px !important;
}
.navbar-responsive > div {
padding: 0 8px !important;
height: 54px !important;
}
.navbar-responsive img {
height: 22px !important;
margin-right: 6px !important;
}
.navbar-responsive span, .navbar-responsive a {
font-size: 15px !important;
}
.navbar-responsive .ant-btn {
font-size: 13px !important;
padding: 2px 10px !important;
border-radius: 14px !important;
}
.navbar-responsive div[style*='gap: 32px'] {
gap: 12px !important;
}
}
@media (max-width: 600px) {
.navbar-responsive {
height: auto !important;
flex-direction: column !important;
align-items: stretch !important;
border-bottom: 1px solid #eee !important;
padding-bottom: 8px !important;
}
.navbar-responsive > div {
flex-direction: column !important;
align-items: center !important;
width: 100vw !important;
padding: 0 2vw !important;
height: auto !important;
gap: 10px !important;
}
.navbar-responsive span, .navbar-responsive a {
font-size: 16px !important;
margin: 0 2vw !important;
}
.navbar-responsive .ant-btn {
font-size: 15px !important;
padding: 8px 18px !important;
border-radius: 16px !important;
margin: 0 2vw !important;
}
.navbar-responsive div[style*='gap: 32px'] {
gap: 10px !important;
flex-direction: column !important;
align-items: center !important;
}
}
.navbar-responsive {
height: auto !important;
flex-direction: column !important;
align-items: flex-start !important;
border-bottom: 1px solid #eee !important;
}
.navbar-responsive > div {
flex-direction: column !important;
align-items: flex-start !important;
width: 100vw !important;
padding: 0 2vw !important;
height: auto !important;
gap: 6px !important;
}
.navbar-responsive span, .navbar-responsive a {
font-size: 14px !important;
}
.navbar-responsive .ant-btn {
font-size: 12px !important;
padding: 2px 8px !important;
border-radius: 12px !important;
}
.navbar-responsive div[style*='gap: 32px'] {
gap: 8px !important;
flex-direction: column !important;
align-items: flex-start !important;
}
}
`;
if (typeof window !== 'undefined' && !document.getElementById('nav-responsive-style')) {
const style = document.createElement('style');
style.id = 'nav-responsive-style';
style.innerHTML = navResponsiveStyle;
document.head.appendChild(style);
}
export default NavBar;

View File

@@ -0,0 +1,244 @@
import React, { useState } from 'react';
import { Card, Button, Input, Spin, Alert, Tabs, Table, Typography, Tag } from 'antd';
import { StockOutlined, GlobalOutlined, BarChartOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import { authFetch } from '../auth';
const { TextArea } = Input;
const { TabPane } = Tabs;
const { Text, Title } = Typography;
const NewsStockAnalysis = ({ onClose }) => {
const [newsContent, setNewsContent] = useState('');
const [loading, setLoading] = useState(false);
const [analysisResult, setAnalysisResult] = useState(null);
const [error, setError] = useState('');
const handleAnalyze = async () => {
if (!newsContent.trim()) {
setError('请输入新闻内容');
return;
}
setLoading(true);
setError('');
try {
const response = await authFetch('/news-stock/analyze', {
method: 'POST',
body: { news_content: newsContent }
});
setAnalysisResult(response);
} catch (err) {
console.error('分析失败:', err);
setError(err.message || '分析失败,请重试');
} finally {
setLoading(false);
}
};
const handleReset = () => {
setNewsContent('');
setAnalysisResult(null);
setError('');
};
// 公司表格列定义
const companyColumns = [
{
title: '股票代码',
dataIndex: 'code',
key: 'code',
width: 100,
render: (text) => <Text strong style={{color: '#1890ff'}}>{text}</Text>
},
{
title: '公司名称',
dataIndex: 'name',
key: 'name',
width: 150,
render: (text) => <Text strong>{text}</Text>
},
{
title: '所属行业',
dataIndex: 'industry',
key: 'industry',
width: 120,
render: (text) => <Tag color="blue">{text}</Tag>
},
{
title: '主营业务',
dataIndex: 'business',
key: 'business',
ellipsis: true,
render: (text) => <Text style={{fontSize: '12px'}}>{text}</Text>
},
{
title: '相关行业',
dataIndex: 'related_industry',
key: 'related_industry',
width: 100,
render: (text) => <Tag color="green">{text}</Tag>
}
];
return (
<div style={{ background: '#f8f9fa', minHeight: '600px', padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<StockOutlined style={{ fontSize: '24px', color: '#1890ff' }} />
<Title level={3} style={{ margin: 0, color: '#1890ff' }}>热点新闻选股分析</Title>
</div>
<Button onClick={onClose} type="text" style={{ fontSize: '16px' }}>
</Button>
</div>
<Card style={{ marginBottom: '16px', borderRadius: '8px' }}>
<div style={{ marginBottom: '16px' }}>
<Text strong style={{ fontSize: '16px', color: '#333' }}>
<GlobalOutlined style={{ marginRight: '8px', color: '#1890ff' }} />
请输入热点新闻内容
</Text>
</div>
<TextArea
value={newsContent}
onChange={(e) => setNewsContent(e.target.value)}
placeholder="请粘贴或输入您想要分析的热点新闻内容..."
rows={6}
style={{ marginBottom: '16px', fontSize: '14px' }}
/>
{error && (
<Alert
message={error}
type="error"
style={{ marginBottom: '16px' }}
icon={<ExclamationCircleOutlined />}
/>
)}
<div style={{ display: 'flex', gap: '12px' }}>
<Button
type="primary"
onClick={handleAnalyze}
loading={loading}
disabled={!newsContent.trim()}
icon={<BarChartOutlined />}
style={{ borderRadius: '6px' }}
>
{loading ? '分析中...' : '开始分析'}
</Button>
<Button onClick={handleReset} style={{ borderRadius: '6px' }}>
重置
</Button>
</div>
</Card>
{loading && (
<Card style={{ textAlign: 'center', padding: '40px', borderRadius: '8px' }}>
<Spin size="large" />
<div style={{ marginTop: '16px', fontSize: '16px', color: '#666' }}>
正在分析新闻对股票市场的影响请稍候...
</div>
</Card>
)}
{analysisResult && !loading && (
<Card style={{ borderRadius: '8px' }}>
<Tabs defaultActiveKey="1" style={{ minHeight: '400px' }}>
<TabPane
tab={
<span>
<GlobalOutlined />
影响行业 ({analysisResult.industries?.length || 0})
</span>
}
key="1"
>
<div style={{ marginBottom: '16px' }}>
<Title level={4} style={{ color: '#1890ff', marginBottom: '12px' }}>
受影响行业
</Title>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginBottom: '20px' }}>
{analysisResult.industries?.map((industry, index) => (
<Tag key={index} color="blue" style={{ fontSize: '14px', padding: '4px 12px' }}>
{industry}
</Tag>
))}
</div>
</div>
</TabPane>
<TabPane
tab={
<span>
<StockOutlined />
相关公司 ({analysisResult.companies?.length || 0})
</span>
}
key="2"
>
<Table
dataSource={analysisResult.companies?.map((company, index) => ({
...company,
key: index
}))}
columns={companyColumns}
pagination={{ pageSize: 8, showSizeChanger: false }}
scroll={{ x: true }}
size="small"
style={{ marginTop: '16px' }}
/>
</TabPane>
<TabPane
tab={
<span>
<BarChartOutlined />
详细分析
</span>
}
key="3"
>
<div style={{
background: '#fafafa',
padding: '20px',
borderRadius: '6px',
border: '1px solid #f0f0f0',
maxHeight: '400px',
overflowY: 'auto'
}}>
<pre style={{
whiteSpace: 'pre-wrap',
fontSize: '14px',
lineHeight: '1.6',
margin: 0,
color: '#333'
}}>
{analysisResult.analysis_details}
</pre>
</div>
<div style={{
marginTop: '16px',
padding: '12px',
background: '#f6f8fa',
borderRadius: '6px',
border: '1px solid #e1e4e8'
}}>
<Text type="secondary" style={{ fontSize: '12px' }}>
分析时间{analysisResult.timestamp}
</Text>
</div>
</TabPane>
</Tabs>
</Card>
)}
</div>
);
};
export default NewsStockAnalysis;

View File

@@ -0,0 +1,81 @@
import React, { useEffect, useState } from 'react';
import { Alert, Tag } from 'antd';
import { CheckCircleFilled, InfoCircleFilled } from '@ant-design/icons';
/**
* 自定义通知组件,实现类似图片中的提醒效果
* @param {Object} props
* @param {string} props.type - 通知类型: 'success', 'warning', 'info'
* @param {string} props.message - 通知内容
* @param {boolean} props.visible - 是否显示
* @param {function} props.onClose - 关闭通知的回调函数
* @param {number} props.duration - 自动关闭的时间(毫秒)默认3000ms
*/
const Notification = ({ type = 'info', message, visible = false, onClose, duration = 3000 }) => {
const [isVisible, setIsVisible] = useState(visible);
useEffect(() => {
setIsVisible(visible);
// 自动关闭
if (visible && duration > 0) {
const timer = setTimeout(() => {
setIsVisible(false);
if (onClose) onClose();
}, duration);
return () => clearTimeout(timer);
}
}, [visible, duration, onClose]);
if (!isVisible) return null;
// 不同类型的样式和图标
const styles = {
success: {
backgroundColor: '#fff',
borderColor: '#b7eb8f',
color: '#52c41a',
icon: <CheckCircleFilled style={{ color: '#52c41a', marginRight: 10 }} />
},
warning: {
backgroundColor: '#fff',
borderColor: '#ffe58f',
color: '#faad14',
icon: <InfoCircleFilled style={{ color: '#faad14', marginRight: 10 }} />
},
info: {
backgroundColor: '#fff',
borderColor: '#91caff',
color: '#1677ff',
icon: <InfoCircleFilled style={{ color: '#1677ff', marginRight: 10 }} />
}
};
const style = styles[type] || styles.info;
return (
<div style={{
position: 'fixed',
top: '50px',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 1000,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
borderRadius: '5px',
padding: '10px 20px',
display: 'flex',
alignItems: 'center',
backgroundColor: style.backgroundColor,
border: `1px solid ${style.borderColor}`,
minWidth: '200px',
maxWidth: '400px',
animation: 'slide-down 0.3s ease-in-out'
}}>
{style.icon}
<span style={{ color: '#333' }}>{message}</span>
</div>
);
};
export default Notification;

View File

@@ -0,0 +1,163 @@
import React, { useState } from 'react';
import { Card, Input, Button, Typography, Spin, message, Switch, Divider } from 'antd';
import { TwitterOutlined, SendOutlined } from '@ant-design/icons';
import { authFetch } from '../auth';
const { Title, Paragraph, Text } = Typography;
const TwitterPostSummary = ({ onClose, username: initialUsername = '' }) => {
const [username, setUsername] = useState(initialUsername);
const [summary, setSummary] = useState('');
const [tweetUrl, setTweetUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [postToTwitter, setPostToTwitter] = useState(false);
const [tweetPosted, setTweetPosted] = useState(false);
const handleSubmit = async () => {
if (!username) {
message.warning('请输入Twitter用户名');
return;
}
setLoading(true);
setError('');
setSummary('');
setTweetUrl('');
setTweetPosted(false);
try {
// 使用完整URL调用API
const cleanUsername = username.replace('@', '').trim();
console.log('请求Twitter发推用户名:', cleanUsername, '是否发送:', postToTwitter);
const response = await authFetch(`/twitter-post/create`, {
method: 'POST',
body: {
username: cleanUsername,
post_to_twitter: postToTwitter
}
});
console.log('收到Twitter API响应:', response);
if (response && response.summary) {
setSummary(response.summary);
// 如果成功发送推文
if (response.tweet_posted === true && response.tweet_url) {
setTweetPosted(true);
setTweetUrl(response.tweet_url);
message.success('推文已成功发送到Twitter!');
} else if (postToTwitter && response.tweet_error) {
message.error(`发送推文失败: ${response.tweet_error}`);
}
} else if (response && response.detail) {
setError(response.detail);
} else {
setError('获取摘要失败,服务器响应格式不正确');
}
} catch (err) {
console.error('操作失败:', err);
const errorMessage = err.detail || err.message || '操作失败,请稍后重试';
setError(`${errorMessage} (${err.name || 'Error'})`);
} finally {
setLoading(false);
}
};
return (
<Card
title={
<div style={{ display: 'flex', alignItems: 'center' }}>
<TwitterOutlined style={{ fontSize: 24, color: '#1DA1F2', marginRight: 12 }} />
<span>Twitter自动发推</span>
</div>
}
style={{ width: '100%', maxWidth: 600, margin: '0 auto' }}
extra={<Button type="text" onClick={onClose}>关闭</Button>}
>
<div style={{ marginBottom: 20 }}>
<Paragraph>
输入Twitter用户名获取最近推文的摘要并可选择将摘要发送到Twitter
</Paragraph>
<div style={{ display: 'flex', marginBottom: 20 }}>
<Input
placeholder="输入Twitter用户名例如elonmusk"
value={username}
onChange={(e) => setUsername(e.target.value)}
prefix={<Text type="secondary">@</Text>}
style={{ marginRight: 8 }}
/>
<Button
type="primary"
onClick={handleSubmit}
loading={loading}
icon={<SendOutlined />}
>
获取并发送
</Button>
</div>
<div style={{ marginBottom: 20 }}>
<Switch
checked={postToTwitter}
onChange={(checked) => setPostToTwitter(checked)}
/>
<Text style={{ marginLeft: 8 }}>同时发送摘要到Twitter</Text>
</div>
{loading && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 10 }}>
{postToTwitter ? '正在分析推文并发送摘要...' : '正在分析推文并生成摘要...'}
</div>
</div>
)}
{error && (
<div style={{
padding: '10px 15px',
background: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: 4,
marginBottom: 16
}}>
<Text type="danger">{error}</Text>
</div>
)}
{summary && (
<div style={{
padding: 16,
background: '#f6f6f6',
borderRadius: 8,
border: '1px solid #d9d9d9'
}}>
<Title level={4} style={{ marginTop: 0 }}>@{username} 的最近推文摘要</Title>
<Paragraph style={{ whiteSpace: 'pre-line', fontSize: 16 }}>
{summary}
</Paragraph>
{tweetPosted && tweetUrl && (
<div style={{ marginTop: 16 }}>
<Divider />
<Paragraph>
<Text strong>推文已发送成功</Text>
<br />
<a href={tweetUrl} target="_blank" rel="noopener noreferrer">
点击查看Twitter上的推文
</a>
</Paragraph>
</div>
)}
</div>
)}
</div>
</Card>
);
};
export default TwitterPostSummary;

View File

@@ -0,0 +1,152 @@
import React, { useState } from 'react';
import { Card, Input, Button, Typography, Spin, message, Modal } from 'antd';
import { TwitterOutlined } from '@ant-design/icons';
import { authFetch } from '../auth';
const { Title, Paragraph, Text } = Typography;
const TwitterSummary = ({ onClose, username: initialUsername = '' }) => {
const [username, setUsername] = useState(initialUsername);
const [summary, setSummary] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// 测试API连接
const testApiConnection = async () => {
try {
// 测试基本连接
const response = await fetch('/twitter/test');
const data = await response.json();
console.log('Twitter API测试结果:', data);
// 测试后端诊断端点
const diagnosticResponse = await fetch('/test-twitter');
const diagnosticData = await diagnosticResponse.json();
console.log('Twitter诊断结果:', diagnosticData);
return true;
} catch (err) {
console.error('API连接测试失败:', err);
return false;
}
};
const handleSubmit = async () => {
if (!username) {
message.warning('请输入Twitter用户名');
return;
}
setLoading(true);
setError('');
setSummary('');
try {
// 首先测试API连接
await testApiConnection();
// 使用try-catch包装每个请求步骤捕获并记录详细错误
try {
// 确保用户名格式正确
const cleanUsername = username.replace('@', '').trim();
console.log('请求Twitter摘要, 用户名:', cleanUsername);
// 使用完整URL调用API
const fullUrl = `/twitter/summary?username=${cleanUsername}`;
console.log('请求URL:', fullUrl);
const response = await authFetch(fullUrl);
console.log('收到Twitter API响应:', response);
if (response && response.summary) {
setSummary(response.summary);
} else {
console.error('响应缺少摘要:', response);
setError('获取摘要失败,服务器响应格式不正确');
}
} catch (fetchError) {
console.error('Fetch操作失败:', fetchError);
throw new Error(`请求失败: ${fetchError.message}`);
}
} catch (err) {
console.error('获取Twitter摘要失败:', err);
// 提供更详细的错误信息
const errorMessage = err.detail || err.message || '获取摘要失败,请稍后重试';
setError(`${errorMessage} (${err.name || 'Error'})`);
} finally {
setLoading(false);
}
};
return (
<Card
title={
<div style={{ display: 'flex', alignItems: 'center' }}>
<TwitterOutlined style={{ fontSize: 24, color: '#1DA1F2', marginRight: 12 }} />
<span>Twitter推文摘要</span>
</div>
}
style={{ width: '100%', maxWidth: 600, margin: '0 auto' }}
extra={<Button type="text" onClick={onClose}>关闭</Button>}
>
<div style={{ marginBottom: 20 }}>
<Paragraph>
输入Twitter用户名获取该用户最近推文的摘要
</Paragraph>
<div style={{ display: 'flex', marginBottom: 20 }}>
<Input
placeholder="输入Twitter用户名例如elonmusk"
value={username}
onChange={(e) => setUsername(e.target.value)}
prefix={<Text type="secondary">@</Text>}
style={{ marginRight: 8 }}
onPressEnter={handleSubmit}
/>
<Button
type="primary"
onClick={handleSubmit}
loading={loading}
>
获取摘要
</Button>
</div>
{loading && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<Spin size="large" />
<div style={{ marginTop: 10 }}>正在分析推文并生成摘要...</div>
</div>
)}
{error && (
<div style={{
padding: '10px 15px',
background: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: 4,
marginBottom: 16
}}>
<Text type="danger">{error}</Text>
</div>
)}
{summary && (
<div style={{
padding: 16,
background: '#f6f6f6',
borderRadius: 8,
border: '1px solid #d9d9d9'
}}>
<Title level={4} style={{ marginTop: 0 }}>@{username} 的最近推文摘要</Title>
<Paragraph style={{ whiteSpace: 'pre-line', fontSize: 16 }}>
{summary}
</Paragraph>
</div>
)}
</div>
</Card>
);
};
export default TwitterSummary;

13
frontend/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
body {
margin: 0;
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;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

17
frontend/src/index.js Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

37
frontend/src/logo.svg Normal file
View File

@@ -0,0 +1,37 @@
<svg xmlns="http://www.w3.org/2000/svg" width="95" height="88" fill="none">
<path fill="#FFD21E" d="M47.21 76.5a34.75 34.75 0 1 0 0-69.5 34.75 34.75 0 0 0 0 69.5Z" />
<path
fill="#FF9D0B"
d="M81.96 41.75a34.75 34.75 0 1 0-69.5 0 34.75 34.75 0 0 0 69.5 0Zm-73.5 0a38.75 38.75 0 1 1 77.5 0 38.75 38.75 0 0 1-77.5 0Z"
/>
<path
fill="#3A3B45"
d="M58.5 32.3c1.28.44 1.78 3.06 3.07 2.38a5 5 0 1 0-6.76-2.07c.61 1.15 2.55-.72 3.7-.32ZM34.95 32.3c-1.28.44-1.79 3.06-3.07 2.38a5 5 0 1 1 6.76-2.07c-.61 1.15-2.56-.72-3.7-.32Z"
/>
<path
fill="#FF323D"
d="M46.96 56.29c9.83 0 13-8.76 13-13.26 0-2.34-1.57-1.6-4.09-.36-2.33 1.15-5.46 2.74-8.9 2.74-7.19 0-13-6.88-13-2.38s3.16 13.26 13 13.26Z"
/>
<path
fill="#3A3B45"
fill-rule="evenodd"
d="M39.43 54a8.7 8.7 0 0 1 5.3-4.49c.4-.12.81.57 1.24 1.28.4.68.82 1.37 1.24 1.37.45 0 .9-.68 1.33-1.35.45-.7.89-1.38 1.32-1.25a8.61 8.61 0 0 1 5 4.17c3.73-2.94 5.1-7.74 5.1-10.7 0-2.34-1.57-1.6-4.09-.36l-.14.07c-2.31 1.15-5.39 2.67-8.77 2.67s-6.45-1.52-8.77-2.67c-2.6-1.29-4.23-2.1-4.23.29 0 3.05 1.46 8.06 5.47 10.97Z"
clip-rule="evenodd"
/>
<path
fill="#FF9D0B"
d="M70.71 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM24.21 37a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM17.52 48c-1.62 0-3.06.66-4.07 1.87a5.97 5.97 0 0 0-1.33 3.76 7.1 7.1 0 0 0-1.94-.3c-1.55 0-2.95.59-3.94 1.66a5.8 5.8 0 0 0-.8 7 5.3 5.3 0 0 0-1.79 2.82c-.24.9-.48 2.8.8 4.74a5.22 5.22 0 0 0-.37 5.02c1.02 2.32 3.57 4.14 8.52 6.1 3.07 1.22 5.89 2 5.91 2.01a44.33 44.33 0 0 0 10.93 1.6c5.86 0 10.05-1.8 12.46-5.34 3.88-5.69 3.33-10.9-1.7-15.92-2.77-2.78-4.62-6.87-5-7.77-.78-2.66-2.84-5.62-6.25-5.62a5.7 5.7 0 0 0-4.6 2.46c-1-1.26-1.98-2.25-2.86-2.82A7.4 7.4 0 0 0 17.52 48Zm0 4c.51 0 1.14.22 1.82.65 2.14 1.36 6.25 8.43 7.76 11.18.5.92 1.37 1.31 2.14 1.31 1.55 0 2.75-1.53.15-3.48-3.92-2.93-2.55-7.72-.68-8.01.08-.02.17-.02.24-.02 1.7 0 2.45 2.93 2.45 2.93s2.2 5.52 5.98 9.3c3.77 3.77 3.97 6.8 1.22 10.83-1.88 2.75-5.47 3.58-9.16 3.58-3.81 0-7.73-.9-9.92-1.46-.11-.03-13.45-3.8-11.76-7 .28-.54.75-.76 1.34-.76 2.38 0 6.7 3.54 8.57 3.54.41 0 .7-.17.83-.6.79-2.85-12.06-4.05-10.98-8.17.2-.73.71-1.02 1.44-1.02 3.14 0 10.2 5.53 11.68 5.53.11 0 .2-.03.24-.1.74-1.2.33-2.04-4.9-5.2-5.21-3.16-8.88-5.06-6.8-7.33.24-.26.58-.38 1-.38 3.17 0 10.66 6.82 10.66 6.82s2.02 2.1 3.25 2.1c.28 0 .52-.1.68-.38.86-1.46-8.06-8.22-8.56-11.01-.34-1.9.24-2.85 1.31-2.85Z"
/>
<path
fill="#FFD21E"
d="M38.6 76.69c2.75-4.04 2.55-7.07-1.22-10.84-3.78-3.77-5.98-9.3-5.98-9.3s-.82-3.2-2.69-2.9c-1.87.3-3.24 5.08.68 8.01 3.91 2.93-.78 4.92-2.29 2.17-1.5-2.75-5.62-9.82-7.76-11.18-2.13-1.35-3.63-.6-3.13 2.2.5 2.79 9.43 9.55 8.56 11-.87 1.47-3.93-1.71-3.93-1.71s-9.57-8.71-11.66-6.44c-2.08 2.27 1.59 4.17 6.8 7.33 5.23 3.16 5.64 4 4.9 5.2-.75 1.2-12.28-8.53-13.36-4.4-1.08 4.11 11.77 5.3 10.98 8.15-.8 2.85-9.06-5.38-10.74-2.18-1.7 3.21 11.65 6.98 11.76 7.01 4.3 1.12 15.25 3.49 19.08-2.12Z"
/>
<path
fill="#FF9D0B"
d="M77.4 48c1.62 0 3.07.66 4.07 1.87a5.97 5.97 0 0 1 1.33 3.76 7.1 7.1 0 0 1 1.95-.3c1.55 0 2.95.59 3.94 1.66a5.8 5.8 0 0 1 .8 7 5.3 5.3 0 0 1 1.78 2.82c.24.9.48 2.8-.8 4.74a5.22 5.22 0 0 1 .37 5.02c-1.02 2.32-3.57 4.14-8.51 6.1-3.08 1.22-5.9 2-5.92 2.01a44.33 44.33 0 0 1-10.93 1.6c-5.86 0-10.05-1.8-12.46-5.34-3.88-5.69-3.33-10.9 1.7-15.92 2.78-2.78 4.63-6.87 5.01-7.77.78-2.66 2.83-5.62 6.24-5.62a5.7 5.7 0 0 1 4.6 2.46c1-1.26 1.98-2.25 2.87-2.82A7.4 7.4 0 0 1 77.4 48Zm0 4c-.51 0-1.13.22-1.82.65-2.13 1.36-6.25 8.43-7.76 11.18a2.43 2.43 0 0 1-2.14 1.31c-1.54 0-2.75-1.53-.14-3.48 3.91-2.93 2.54-7.72.67-8.01a1.54 1.54 0 0 0-.24-.02c-1.7 0-2.45 2.93-2.45 2.93s-2.2 5.52-5.97 9.3c-3.78 3.77-3.98 6.8-1.22 10.83 1.87 2.75 5.47 3.58 9.15 3.58 3.82 0 7.73-.9 9.93-1.46.1-.03 13.45-3.8 11.76-7-.29-.54-.75-.76-1.34-.76-2.38 0-6.71 3.54-8.57 3.54-.42 0-.71-.17-.83-.6-.8-2.85 12.05-4.05 10.97-8.17-.19-.73-.7-1.02-1.44-1.02-3.14 0-10.2 5.53-11.68 5.53-.1 0-.19-.03-.23-.1-.74-1.2-.34-2.04 4.88-5.2 5.23-3.16 8.9-5.06 6.8-7.33-.23-.26-.57-.38-.98-.38-3.18 0-10.67 6.82-10.67 6.82s-2.02 2.1-3.24 2.1a.74.74 0 0 1-.68-.38c-.87-1.46 8.05-8.22 8.55-11.01.34-1.9-.24-2.85-1.31-2.85Z"
/>
<path
fill="#FFD21E"
d="M56.33 76.69c-2.75-4.04-2.56-7.07 1.22-10.84 3.77-3.77 5.97-9.3 5.97-9.3s.82-3.2 2.7-2.9c1.86.3 3.23 5.08-.68 8.01-3.92 2.93.78 4.92 2.28 2.17 1.51-2.75 5.63-9.82 7.76-11.18 2.13-1.35 3.64-.6 3.13 2.2-.5 2.79-9.42 9.55-8.55 11 .86 1.47 3.92-1.71 3.92-1.71s9.58-8.71 11.66-6.44c2.08 2.27-1.58 4.17-6.8 7.33-5.23 3.16-5.63 4-4.9 5.2.75 1.2 12.28-8.53 13.36-4.4 1.08 4.11-11.76 5.3-10.97 8.15.8 2.85 9.05-5.38 10.74-2.18 1.69 3.21-11.65 6.98-11.76 7.01-4.31 1.12-15.26 3.49-19.08-2.12Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 4.5 KiB

106
frontend/src/pages/Admin.js Normal file
View File

@@ -0,0 +1,106 @@
// 已彻底删除后台管理页面,防止任何语法错误。
navigate('/login');
return;
}
fetchApps();
}, [navigate, user]);
const fetchApps = async () => {
setLoading(true);
try {
const data = await authFetch('/api/admin/apps');
setApps(data.apps || []);
} catch (err) {
console.error('请求失败:', err);
message.error(err.message || '获取应用列表失败');
}
setLoading(false);
};
const columns = [
{
title: 'ID',
dataIndex: 'id',
key: 'id',
},
{
title: '应用名称',
dataIndex: 'name',
key: 'name',
},
{
title: '描述',
dataIndex: 'desc',
key: 'desc',
},
{
title: '价格',
dataIndex: 'price',
key: 'price',
render: (price) => `${price}`,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
},
{
title: '操作',
key: 'action',
render: (_, record) => (
<Button type="link" onClick={() => message.info('功能开发中')}>
编辑
</Button>
),
},
];
const handleLogout = () => {
localStorage.removeItem('user');
navigate('/login');
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Header style={{ background: '#fff', padding: '0 16px' }}>
<div style={{ float: 'left', color: '#1890ff', fontSize: '18px', fontWeight: 'bold' }}>
AI应用管理后台
</div>
<div style={{ float: 'right' }}>
<span style={{ marginRight: '12px' }}>管理员{user?.username}</span>
<Button type="link" onClick={handleLogout}>
退出登录
</Button>
</div>
</Header>
<Layout>
<Sider width={200} style={{ background: '#fff' }}>
<Menu
mode="inline"
defaultSelectedKeys={['apps']}
style={{ height: '100%', borderRight: 0 }}
>
<Menu.Item key="apps">应用管理</Menu.Item>
<Menu.Item key="users">用户管理</Menu.Item>
<Menu.Item key="orders">订单管理</Menu.Item>
</Menu>
</Sider>
<Layout style={{ padding: '24px' }}>
<Content style={{ background: '#fff', padding: 24, margin: 0, minHeight: 280 }}>
<Card title="应用列表" extra={<Button type="primary" onClick={() => message.info('添加功能开发中')}>添加应用</Button>}>
<Table
columns={columns}
dataSource={apps}
rowKey="id"
loading={loading}
/>
</Card>
</Content>
</Layout>
</Layout>
</Layout>
);
}
export default Admin;

View File

@@ -0,0 +1,268 @@
import React, { useEffect, useState } from 'react';
import { Table, Button, Modal, Form, Input, InputNumber, message, Select, Radio, Tooltip } from 'antd';
import { RobotOutlined, PictureOutlined, AudioOutlined, ApiOutlined, CloudOutlined, FileTextOutlined } from '@ant-design/icons';
import { authFetch } from '../../auth';
// 定义列配置函数将handleEdit作为参数传入
const getColumns = (handleEdit) => [
{ title: 'ID', dataIndex: 'id', key: 'id' },
{ title: '应用名称', dataIndex: 'name', key: 'name' },
{ title: '描述', dataIndex: 'desc', key: 'desc' },
{ title: '价格', dataIndex: 'price', key: 'price' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{
title: '操作',
key: 'action',
render: (_, record) => <Button type="link" onClick={() => handleEdit(record)}>编辑</Button>
},
];
export default function AdminApps() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editingApp, setEditingApp] = useState(null);
const [form] = Form.useForm();
const fetchApps = async () => {
setLoading(true);
try {
// 使用authFetch添加认证信息
const result = await authFetch('/apps/all');
// 后端返回的是数组而不是对象
if (Array.isArray(result)) {
setData(result);
} else if (result && result.apps) {
setData(result.apps);
} else {
console.error('应用数据格式不正确:', result);
setData([]);
}
} catch (e) {
console.error('获取应用数据失败:', e);
setData([]);
}
setLoading(false);
};
useEffect(() => { fetchApps(); }, []);
// 删除应用
const handleDelete = async (app) => {
if (window.confirm(`确定要删除应用 "${app.name}" 吗?`)) {
try {
const response = await authFetch(`/apps/delete/${app.id}`, { method: 'DELETE' });
if (response && response.msg) {
message.success(response.msg || '删除成功');
fetchApps(); // 刷新列表
} else {
message.error('删除失败,请重试');
}
} catch (error) {
console.error('删除应用出错:', error);
message.error('删除失败: ' + (error.message || '未知错误'));
}
}
};
// 打开添加应用模态框
const showModal = () => {
form.resetFields();
setIsEditing(false);
setEditingApp(null);
setIsModalVisible(true);
};
// 关闭模态框
const handleCancel = () => {
setIsModalVisible(false);
setIsEditing(false);
setEditingApp(null);
};
// 处理编辑应用
const handleEdit = (app) => {
setIsEditing(true);
setEditingApp(app);
// 将应用数据设置到表单中
form.setFieldsValue({
name: app.name,
desc: app.desc,
shortDesc: app.shortDesc || app.desc,
price: app.price,
status: app.status,
iconType: app.icon_type || 1,
appType: app.app_type || '文本生成'
});
setIsModalVisible(true);
};
// 提交表单
const handleOk = async () => {
try {
const values = await form.validateFields();
// 构建应用描述,如果有简短描述则使用,否则使用原始描述
const description = values.shortDesc ? values.shortDesc : values.desc;
if (isEditing && editingApp) {
// 编辑应用
const response = await authFetch(`/apps/edit/${editingApp.id}`, {
method: 'PUT',
body: JSON.stringify({
name: values.name,
desc: description,
price: values.price,
status: values.status,
icon_type: values.iconType || 1,
app_type: values.appType || '文本生成'
})
});
if (response && (response.msg === '修改成功' || response.msg === '编辑成功')) {
message.success('编辑应用成功');
setIsModalVisible(false);
setIsEditing(false);
setEditingApp(null);
fetchApps(); // 刷新应用列表
} else {
message.error('编辑应用失败: ' + (response?.detail || '未知错误'));
}
} else {
// 添加应用
const response = await authFetch('/apps/add', {
method: 'POST',
body: JSON.stringify({
name: values.name,
desc: description,
price: values.price,
status: values.status,
icon_type: values.iconType || 1, // 默认使用RobotOutlined图标
app_type: values.appType || '文本生成' // 默认应用类型
})
});
if (response && (response.app || response.msg === '添加成功')) {
message.success('添加应用成功');
setIsModalVisible(false);
fetchApps(); // 刷新应用列表
} else {
message.error('添加应用失败: ' + (response?.detail || '未知错误'));
}
}
} catch (error) {
console.error('表单验证或提交错误:', error);
message.error((isEditing ? '编辑' : '添加') + '应用失败: ' + (error.message || '未知错误'));
}
};
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button type="primary" style={{ marginRight: 8 }} onClick={showModal}>新增应用</Button>
<Button onClick={fetchApps}>刷新</Button>
</div>
<Table columns={getColumns(handleEdit)} dataSource={data} rowKey="id" loading={loading} />
{/* 应用模态框 */}
<Modal
title={isEditing ? '编辑应用' : '添加应用'}
open={isModalVisible}
onOk={handleOk}
onCancel={handleCancel}
okText="确定"
cancelText="取消"
footer={[
// 在编辑模式下显示删除按钮
isEditing && (
<Button key="delete" type="primary" danger onClick={() => handleDelete(editingApp)}>
删除应用
</Button>
),
<Button key="cancel" onClick={handleCancel}>取消</Button>,
<Button key="submit" type="primary" onClick={handleOk}>确定</Button>,
].filter(Boolean)}
>
<Form
form={form}
layout="vertical"
name="add_app_form"
>
<Form.Item
name="name"
label="应用名称"
rules={[{ required: true, message: '请输入应用名称' }]}
>
<Input placeholder="例如:智能文案生成" />
</Form.Item>
<Form.Item
name="shortDesc"
label={<span>简短描述 <Tooltip title="显示在首页应用卡片上的简短描述建议15字以内"></Tooltip></span>}
>
<Input placeholder="例如:输入主题,生成高质量文案" />
</Form.Item>
<Form.Item
name="desc"
label="详细描述"
rules={[{ required: true, message: '请输入应用描述' }]}
>
<Input.TextArea rows={4} placeholder="详细描述应用的功能、特点和使用场景" />
</Form.Item>
<Form.Item
name="iconType"
label="图标类型"
initialValue={1}
>
<Radio.Group>
<Radio value={1}><RobotOutlined style={{ fontSize: 24, color: '#2f54eb' }} /> 机器人</Radio>
<Radio value={2}><PictureOutlined style={{ fontSize: 24, color: '#faad14' }} /> 图片</Radio>
<Radio value={3}><AudioOutlined style={{ fontSize: 24, color: '#13c2c2' }} /> 音频</Radio>
<Radio value={4}><ApiOutlined style={{ fontSize: 24, color: '#722ed1' }} /> API</Radio>
<Radio value={5}><CloudOutlined style={{ fontSize: 24, color: '#1890ff' }} /> 云服务</Radio>
<Radio value={6}><FileTextOutlined style={{ fontSize: 24, color: '#52c41a' }} /> 文档</Radio>
</Radio.Group>
</Form.Item>
<Form.Item
name="appType"
label="应用类型"
initialValue="文本生成"
>
<Select>
<Select.Option value="文本生成">文本生成</Select.Option>
<Select.Option value="图片处理">图片处理</Select.Option>
<Select.Option value="音频转换">音频转换</Select.Option>
<Select.Option value="数据分析">数据分析</Select.Option>
<Select.Option value="其他">其他</Select.Option>
</Select>
</Form.Item>
<Form.Item
name="price"
label="价格"
rules={[{ required: true, message: '请输入应用价格' }]}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="status"
label="状态"
initialValue="上架"
>
<Select>
<Select.Option value="上架">上架</Select.Option>
<Select.Option value="下架">下架</Select.Option>
<Select.Option value="维护中">维护中</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import React, { useEffect, useState } from 'react';
import { Table, Button } from 'antd';
import { authFetch } from '../../auth';
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id' },
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '类型', dataIndex: 'type', key: 'type' },
{ title: '金额', dataIndex: 'amount', key: 'amount' },
{ title: '描述', dataIndex: 'desc', key: 'desc' },
{ title: '时间', dataIndex: 'time', key: 'time' },
];
export default function AdminFinance() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const fetchFinance = async () => {
setLoading(true);
try {
// 使用authFetch添加认证信息
const result = await authFetch('/history/all');
// 后端返回的是数组而不是对象
if (Array.isArray(result)) {
setData(result);
} else if (result && result.history) {
setData(result.history);
} else {
console.error('财务数据格式不正确:', result);
setData([]);
}
} catch (e) {
console.error('获取财务数据失败:', e);
setData([]);
}
setLoading(false);
};
useEffect(() => { fetchFinance(); }, []);
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button onClick={fetchFinance}>刷新</Button>
</div>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} />
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import AdminNavBar from '../../components/AdminNavBar';
import AdminApps from './AdminApps';
import AdminUsers from './AdminUsers';
import AdminOrders from './AdminOrders';
import AdminFinance from './AdminFinance';
import AdminSettings from './AdminSettings';
import { Spin } from 'antd';
export default function AdminHome() {
const [loading, setLoading] = useState(true);
// 添加延迟加载机制,确保认证令牌已完全加载
useEffect(() => {
// 检查localStorage中的token
const token = localStorage.getItem('token');
if (!token) {
// 如果没有token重定向到登录页面
window.location.href = '/login';
return;
}
// 设置一个短暂的延迟,确保认证令牌已完全处理
const timer = setTimeout(() => {
setLoading(false);
}, 500); // 500毫秒的延迟可以根据需要调整
return () => clearTimeout(timer);
}, []);
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" tip="加载中..." />
</div>
);
}
return (
<div>
<AdminNavBar />
<div style={{ padding: 24 }}>
<Routes>
<Route path="/" element={<Navigate to="apps" />} />
<Route path="apps" element={<AdminApps />} />
<Route path="users" element={<AdminUsers />} />
<Route path="orders" element={<AdminOrders />} />
<Route path="finance" element={<AdminFinance />} />
<Route path="settings" element={<AdminSettings />} />
</Routes>
</div>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import React, { useEffect, useState } from 'react';
import { Table, Button } from 'antd';
import { authFetch } from '../../auth';
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id' },
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '类型', dataIndex: 'type', key: 'type' },
{ title: '金额', dataIndex: 'amount', key: 'amount' },
{ title: '描述', dataIndex: 'desc', key: 'desc' },
{ title: '时间', dataIndex: 'time', key: 'time' },
{ title: '状态', dataIndex: 'status', key: 'status' },
// 删除没有功能的“查看详情”按钮
// { title: '操作', key: 'action', render: (_, record) => <a>查看详情</a> },
];
export default function AdminOrders() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const fetchOrders = async () => {
setLoading(true);
try {
// 使用authFetch添加认证信息
const result = await authFetch('/orders/all');
if (result && result.orders) {
setData(result.orders);
} else {
console.error('订单数据格式不正确:', result);
setData([]);
}
} catch (e) {
console.error('获取订单数据失败:', e);
setData([]);
}
setLoading(false);
};
useEffect(() => { fetchOrders(); }, []);
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button onClick={fetchOrders}>刷新</Button>
</div>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} />
</div>
);
}

View File

@@ -0,0 +1,10 @@
import React from 'react';
export default function AdminSettings() {
return (
<div>
<h2>系统设置</h2>
<p>这里可以放一些全局配置项或操作</p>
</div>
);
}

View File

@@ -0,0 +1,210 @@
import React, { useEffect, useState } from 'react';
import { Table, Button, Modal, Form, Input, InputNumber, message } from 'antd';
import { authFetch } from '../../auth';
// 定义列配置函数将handleEdit作为参数传入
const getColumns = (handleEdit) => [
{ title: 'ID', dataIndex: 'id', key: 'id' },
{ title: '用户名', dataIndex: 'username', key: 'username' },
{ title: '余额', dataIndex: 'balance', key: 'balance' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{
title: '操作',
key: 'action',
render: (_, record) => <Button type="link" onClick={() => handleEdit(record)}>编辑</Button>
},
];
export default function AdminUsers() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
const [isModalVisible, setIsModalVisible] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [form] = Form.useForm();
const fetchUsers = async () => {
setLoading(true);
try {
// 使用authFetch添加认证信息
const result = await authFetch('/users/');
if (result) {
setData(result);
} else {
console.error('用户数据格式不正确:', result);
setData([]);
}
} catch (e) {
console.error('获取用户数据失败:', e);
setData([]);
}
setLoading(false);
};
useEffect(() => { fetchUsers(); }, []);
// 删除用户
const handleDelete = async (user) => {
if (window.confirm(`确定要删除用户 "${user.username}" 吗?`)) {
try {
const response = await authFetch(`/users/delete/${user.id}`, { method: 'DELETE' });
if (response && response.msg) {
message.success(response.msg || '删除成功');
fetchUsers(); // 刷新列表
} else {
message.error('删除失败,请重试');
}
} catch (error) {
console.error('删除用户出错:', error);
message.error('删除失败: ' + (error.message || '未知错误'));
}
}
};
// 打开添加用户模态框
const showModal = () => {
form.resetFields();
setIsEditing(false);
setEditingUser(null);
setIsModalVisible(true);
};
// 关闭模态框
const handleCancel = () => {
setIsModalVisible(false);
setIsEditing(false);
setEditingUser(null);
};
// 处理编辑用户
const handleEdit = (user) => {
setIsEditing(true);
setEditingUser(user);
form.setFieldsValue({
username: user.username,
balance: user.balance,
is_admin: user.is_admin || false
});
setIsModalVisible(true);
};
// 提交表单
const handleOk = async () => {
try {
const values = await form.validateFields();
if (isEditing && editingUser) {
// 编辑用户
const response = await authFetch(`/users/update/${editingUser.id}`, {
method: 'PUT',
body: JSON.stringify({
username: values.username,
balance: values.balance,
is_admin: values.is_admin
})
});
if (response && (response.id || response.msg === '更新成功')) {
message.success('编辑用户成功');
setIsModalVisible(false);
setIsEditing(false);
setEditingUser(null);
fetchUsers(); // 刷新用户列表
} else {
message.error('编辑用户失败: ' + (response?.detail || '未知错误'));
}
} else {
// 添加用户
const response = await authFetch('/users/create', {
method: 'POST',
body: JSON.stringify({
username: values.username,
password: values.password,
balance: values.balance || 0,
is_admin: values.is_admin || false
})
});
if (response && response.id) {
message.success('添加用户成功');
setIsModalVisible(false);
fetchUsers(); // 刷新用户列表
} else {
message.error('添加用户失败: ' + (response?.detail || '未知错误'));
}
}
} catch (error) {
console.error('表单验证或提交错误:', error);
message.error((isEditing ? '编辑' : '添加') + '用户失败: ' + (error.message || '未知错误'));
}
};
return (
<div>
<div style={{ marginBottom: 16 }}>
<Button type="primary" style={{ marginRight: 8 }} onClick={showModal}>新增用户</Button>
<Button onClick={fetchUsers}>刷新</Button>
</div>
<Table columns={getColumns(handleEdit)} dataSource={data} rowKey="id" loading={loading} />
{/* 用户模态框 */}
<Modal
title={isEditing ? '编辑用户' : '添加用户'}
open={isModalVisible}
onOk={handleOk}
onCancel={handleCancel}
okText="确定"
cancelText="取消"
footer={[
// 在编辑模式下显示删除按钮
isEditing && (
<Button key="delete" type="primary" danger onClick={() => handleDelete(editingUser)}>
删除用户
</Button>
),
<Button key="cancel" onClick={handleCancel}>取消</Button>,
<Button key="submit" type="primary" onClick={handleOk}>确定</Button>,
].filter(Boolean)}
>
<Form
form={form}
layout="vertical"
name="user_form"
>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input />
</Form.Item>
{!isEditing && (
<Form.Item
name="password"
label="密码"
rules={[{ required: !isEditing, message: '请输入密码' }]}
>
<Input.Password />
</Form.Item>
)}
<Form.Item
name="balance"
label={isEditing ? '余额' : '初始余额'}
rules={[{ required: true, message: '请输入余额' }]}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="is_admin"
label="管理员权限"
valuePropName="checked"
>
<Input type="checkbox" />
</Form.Item>
</Form>
</Modal>
</div>
);
}

436
frontend/src/pages/Home.js Normal file
View File

@@ -0,0 +1,436 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Layout, Card, Button, Modal } from 'antd';
import { authFetch } from '../auth';
import { RobotOutlined, PictureOutlined, AudioOutlined, TwitterOutlined, StockOutlined } from '@ant-design/icons';
import TwitterSummary from '../components/TwitterSummary';
import TwitterPostSummary from '../components/TwitterPostSummary';
import NewsStockAnalysis from '../components/NewsStockAnalysis';
import Notification from '../components/Notification';
import AIChatbot from '../components/AIChatbot';
const { Content, Footer } = Layout;
// 添加全局样式确保shake动画在整个应用中可用
const addGlobalStyles = () => {
if (typeof window !== 'undefined' && !document.getElementById('global-shake-style')) {
const style = document.createElement('style');
style.id = 'global-shake-style';
style.innerHTML = `
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-3px, 0, 0); }
40%, 60% { transform: translate3d(3px, 0, 0); }
}
.shake { animation: shake 0.6s cubic-bezier(.36,.07,.19,.97) both; }
@keyframes slide-down {
from { opacity: 0; transform: translate(-50%, -20px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
`;
document.head.appendChild(style);
}
};
function Home() {
const [user, setUser] = useState(JSON.parse(localStorage.getItem('user') || 'null'));
const [apps, setApps] = useState([]);
const [loadingAppId, setLoadingAppId] = useState(null);
const [showTwitterModal, setShowTwitterModal] = useState(false);
const [showTwitterPostModal, setShowTwitterPostModal] = useState(false);
const [showNewsStockModal, setShowNewsStockModal] = useState(false);
const [twitterUsername, setTwitterUsername] = useState('');
const navigate = useNavigate();
// 通知状态
const [notification, setNotification] = useState({
visible: false,
type: 'info',
message: '',
});
// 显示通知的函数
const showNotification = (type, message, duration = 3000) => {
setNotification({
visible: true,
type,
message,
duration
});
};
// 关闭通知的函数
const closeNotification = () => {
setNotification(prev => ({ ...prev, visible: false }));
};
useEffect(() => {
// 确保全局样式已加载
addGlobalStyles();
// 使用authFetch获取应用列表
authFetch('/apps/list')
.then(data => setApps(data.apps || []))
.catch(err => {
console.error('获取应用列表失败:', err);
showNotification('warning', '获取应用列表失败');
});
}, []);
const handleUseApp = async (appId, price, appName) => {
if (!user) {
showNotification('warning', '请先登录');
setTimeout(() => navigate('/login'), 800);
return;
}
// 检查用户余额是否足够
if (user.balance < price) {
// 使用自定义通知代替alert
showNotification('warning', '您的余额不足,请先充值');
// 按钮抖动动画
const btn = document.querySelector(`#use-app-btn-${appId}`);
if(btn) {
btn.classList.add('shake');
setTimeout(()=>btn.classList.remove('shake'), 600);
}
return;
}
// 特殊处理Twitter推文摘要应用
if (appName === 'Twitter推文摘要') {
setLoadingAppId(appId);
try {
const data = await authFetch('/apps/use', {
method: 'POST',
body: { app_id: appId }
});
// 使用自定义通知提示成功
showNotification('success', `成功调用 ${appName}!已扣除${price}元。`);
// 更新本地用户余额
const newUser = { ...user, balance: data.balance };
setUser(newUser);
localStorage.setItem('user', JSON.stringify(newUser));
// 显示Twitter摘要界面
setShowTwitterModal(true);
} catch (err) {
console.error('请求失败:', err);
showNotification('warning', err.message || '调用失败');
// 按钮抖动动画
const btn = document.querySelector(`#use-app-btn-${appId}`);
if(btn) {
btn.classList.add('shake');
setTimeout(()=>btn.classList.remove('shake'), 600);
}
}
setLoadingAppId(null);
return;
}
// 特殊处理Twitter自动发推应用
if (appName === 'Twitter自动发推') {
setLoadingAppId(appId);
try {
const data = await authFetch('/apps/use', {
method: 'POST',
body: { app_id: appId }
});
// 使用自定义通知提示成功
showNotification('success', `成功调用 ${appName}!已扣除${price}元。`);
// 更新本地用户余额
const newUser = { ...user, balance: data.balance };
setUser(newUser);
localStorage.setItem('user', JSON.stringify(newUser));
// 显示Twitter自动发推界面
setShowTwitterPostModal(true);
} catch (err) {
console.error('请求失败:', err);
showNotification('warning', err.message || '调用失败');
// 按钮抖动动画
const btn = document.querySelector(`#use-app-btn-${appId}`);
if(btn) {
btn.classList.add('shake');
setTimeout(()=>btn.classList.remove('shake'), 600);
}
}
setLoadingAppId(null);
return;
}
// 特殊处理热点新闻选股应用
if (appName === '热点新闻选股') {
setLoadingAppId(appId);
try {
const data = await authFetch('/apps/use', {
method: 'POST',
body: { app_id: appId }
});
// 使用自定义通知提示成功
showNotification('success', `成功调用 ${appName}!已扣除${price}元。`);
// 更新本地用户余额
const newUser = { ...user, balance: data.balance };
setUser(newUser);
localStorage.setItem('user', JSON.stringify(newUser));
// 显示热点新闻选股界面
setShowNewsStockModal(true);
} catch (err) {
console.error('请求失败:', err);
showNotification('warning', err.message || '调用失败');
// 按钮抖动动画
const btn = document.querySelector(`#use-app-btn-${appId}`);
if(btn) {
btn.classList.add('shake');
setTimeout(()=>btn.classList.remove('shake'), 600);
}
}
setLoadingAppId(null);
return;
}
// 处理其他应用
setLoadingAppId(appId);
try {
const data = await authFetch('/apps/use', {
method: 'POST',
body: { app_id: appId }
});
// 使用自定义通知提示成功
showNotification('success', `成功调用 ${appName}!已扣除${price}元。`);
// 更新本地用户余额(不使用刷新页面的方式,直接更新状态)
const newUser = { ...user, balance: data.balance };
setUser(newUser);
localStorage.setItem('user', JSON.stringify(newUser));
} catch (err) {
console.error('请求失败:', err);
showNotification('warning', err.message || '调用失败');
// 按钮抖动动画
const btn = document.querySelector(`#use-app-btn-${appId}`);
if(btn) {
btn.classList.add('shake');
setTimeout(()=>btn.classList.remove('shake'), 600);
}
}
setLoadingAppId(null);
};
// icon映射
const iconMap = {
1: <TwitterOutlined style={{ fontSize: 40, color: '#1DA1F2' }} />, // Twitter推文摘要
2: <TwitterOutlined style={{ fontSize: 40, color: '#1DA1F2' }} />, // Twitter自动发推
3: <StockOutlined style={{ fontSize: 40, color: '#f5222d' }} />, // 热点新闻选股
};
return (
<Layout style={{ minHeight: '100vh', background: 'linear-gradient(135deg, #e0e7ff 0%, #f0f5ff 100%)' }}>
{/* 通知组件 */}
<Notification
visible={notification.visible}
type={notification.type}
message={notification.message}
duration={notification.duration}
onClose={closeNotification}
/>
<Content style={{ padding: 0, minHeight: 600, background: '#f7f8fa' }}>
{/* Tabela风格主视觉区 */}
<div className="home-hero-responsive" style={{ maxWidth: 1040, margin: '0 auto', padding: '56px 0 0 0', display: 'flex', flexWrap: 'nowrap', alignItems: 'flex-start', gap: 36 }}>
{/* 左侧标题区 */}
<div style={{ flex: 1, minWidth: 320, maxWidth: 520 }}>
<div style={{ position: 'relative', marginBottom: 24 }}>
<h1 style={{ fontWeight: 800, fontSize: 44, color: '#222', margin: 0, lineHeight: 1.18, textAlign: 'left' }}>
您的
<span style={{ position: 'relative', display: 'inline-block', margin: '0 10px' }}>
<svg width="120" height="48" style={{ position: 'absolute', left: '-18px', top: '-20px', zIndex: 0 }}>
<ellipse cx="60" cy="24" rx="56" ry="18" fill="none" stroke="#ffe066" strokeWidth="6" style={{ filter: 'blur(0.5px)' }} />
</svg>
<span style={{ position: 'relative', zIndex: 1, color: '#222', fontWeight: 900, fontSize: 48 }}>终极</span>
</span>
AI应用平台
</h1>
<div style={{ fontSize: 32, color: '#222', fontWeight: 700, margin: '18px 0 0 0', textAlign: 'left' }}>管理与体验尽在一站</div>
</div>
<div style={{ fontSize: 17, color: '#555', margin: '18px 0 0 0', textAlign: 'left', maxWidth: 420 }}>
所有AI应用管理与体验功能尽在一个统一平台
</div>
</div>
{/* 右侧功能描述或插画区 */}
<div style={{ flex: 1, minWidth: 260, maxWidth: 520, display: 'flex', flexDirection: 'column', gap: 18 }}>
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: '24px 20px', minHeight: 70, display: 'flex', alignItems: 'center', gap: 14 }}>
<RobotOutlined style={{ fontSize: 28, color: '#2f54eb' }} />
<span style={{ fontWeight: 600, color: '#222', fontSize: 17 }}>多种AI应用一站式体验</span>
</div>
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: '24px 20px', minHeight: 70, display: 'flex', alignItems: 'center', gap: 14 }}>
<PictureOutlined style={{ fontSize: 28, color: '#faad14' }} />
<span style={{ fontWeight: 600, color: '#222', fontSize: 17 }}>充值即可一键使用</span>
</div>
<div style={{ background: '#fff', borderRadius: 18, boxShadow: '0 2px 16px #0001', padding: '24px 20px', minHeight: 70, display: 'flex', alignItems: 'center', gap: 14 }}>
<AudioOutlined style={{ fontSize: 28, color: '#13c2c2' }} />
<span style={{ fontWeight: 600, color: '#222', fontSize: 17 }}>安全支付余额透明</span>
</div>
</div>
</div>
{/* 卡片区Tabela风格大留白+阴影+简化内容 */}
<div id="ai-apps" className="home-apps-responsive" style={{ maxWidth: 1040, margin: '56px auto 0 auto', padding: '0 16px 48px 16px', display: 'flex', flexWrap: 'wrap', gap: 36, justifyContent: 'flex-start' }}>
{apps.map(app => (
<Card
key={app.id}
style={{
width: 300,
minHeight: 210,
borderRadius: 22,
boxShadow: '0 8px 32px #0001',
cursor: 'pointer',
marginLeft: 0, // 保证卡片左对齐
background: '#fff',
border: 'none',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.18s, box-shadow 0.18s'
}}
bodyStyle={{ padding: 26, textAlign: 'center' }}
hoverable
onMouseOver={e => (e.currentTarget.style.transform = 'translateY(-7px) scale(1.025)')}
onMouseOut={e => (e.currentTarget.style.transform = 'none')}
>
<div style={{ marginBottom: 10 }}>
{iconMap[app.id] || <RobotOutlined style={{ fontSize: 38, color: '#aaa' }} />}
</div>
<div style={{ fontWeight: 700, fontSize: 20, marginBottom: 6 }}>{app.name}</div>
<div style={{ color: '#888', fontSize: 15, marginBottom: 8, minHeight: 36 }}>{app.desc}</div>
<div style={{ fontWeight: 600, fontSize: 16, marginBottom: 12 }}>价格<span style={{ color: '#faad14', fontWeight: 800 }}>{app.price} </span></div>
<Button
id={`use-app-btn-${app.id}`}
type="primary"
size="middle"
shape="round"
style={{ width: '90%', letterSpacing: 2, fontWeight: 600, background: '#222', border: 'none', fontSize: 16 }}
onClick={() => handleUseApp(app.id, app.price, app.name)}
loading={loadingAppId === app.id}
>
立即体验
</Button>
</Card>
))}
</div>
</Content>
<Footer style={{ textAlign: 'center', background: 'transparent', color: '#888', fontSize: 16 }}>
2025 AI Platform &nbsp;|&nbsp; 体验AI未来
© 2025 AI Platform &nbsp;|&nbsp; 体验AI未来
</Footer>
{/* Twitter推文摘要模态框 */}
<Modal
visible={showTwitterModal}
footer={null}
onCancel={() => setShowTwitterModal(false)}
closeIcon={null}
width={650}
bodyStyle={{ padding: 0 }}
destroyOnClose={true}
>
<TwitterSummary
onClose={() => setShowTwitterModal(false)}
username={twitterUsername}
/>
</Modal>
{/* Twitter自动发推模态框 */}
<Modal
visible={showTwitterPostModal}
footer={null}
onCancel={() => setShowTwitterPostModal(false)}
closeIcon={null}
width={650}
bodyStyle={{ padding: 0 }}
destroyOnClose={true}
>
<TwitterPostSummary
onClose={() => setShowTwitterPostModal(false)}
username={twitterUsername}
/>
</Modal>
{/* 热点新闻选股模态框 */}
<Modal
visible={showNewsStockModal}
footer={null}
onCancel={() => setShowNewsStockModal(false)}
closeIcon={null}
width={800}
bodyStyle={{ padding: 0 }}
destroyOnClose={true}
>
<NewsStockAnalysis
onClose={() => setShowNewsStockModal(false)}
/>
</Modal>
{/* AI智能客服组件 */}
<AIChatbot />
</Layout>
);
}
// 响应式样式
const responsiveStyle = `
@media (max-width: 900px) {
.home-hero-responsive {
flex-direction: column !important;
gap: 24px !important;
padding: 36px 0 0 0 !important;
align-items: stretch !important;
}
.home-hero-responsive > div {
min-width: 0 !important;
max-width: 100% !important;
}
@media (max-width: 600px) {
.home-hero-responsive {
flex-direction: column !important;
gap: 12px !important;
padding: 18px 0 0 0 !important;
}
.home-hero-responsive h1 {
font-size: 1.3rem !important;
}
.home-apps-responsive {
flex-direction: column !important;
gap: 16px !important;
padding: 0 4px 24px 4px !important;
margin: 32px auto 0 auto !important;
}
.home-apps-responsive .ant-card {
width: 98vw !important;
min-width: 0 !important;
max-width: 100vw !important;
margin: 0 auto 8px auto !important;
box-sizing: border-box !important;
}
}
`;
// 在组件头部插入style标签
if (typeof window !== 'undefined' && !document.getElementById('home-responsive-style')) {
const style = document.createElement('style');
style.id = 'home-responsive-style';
style.innerHTML = responsiveStyle;
document.head.appendChild(style);
}
export default Home;

View File

@@ -0,0 +1,74 @@
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f0f2f5;
background-image: linear-gradient(135deg, #f5f7fa 0%, #e4ebf5 100%);
}
.login-box {
width: 100%;
max-width: 400px;
padding: 40px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
}
.login-box h2 {
text-align: center;
margin-bottom: 30px;
color: #1890ff;
font-weight: 600;
}
.login-links {
text-align: center;
margin-top: 16px;
}
.login-links a {
color: #1890ff;
text-decoration: none;
font-size: 14px;
}
.login-links a:hover {
color: #40a9ff;
text-decoration: underline;
}
/* 添加抖动动画效果 */
.shake {
animation: shake 0.6s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes shake {
10%, 90% {
transform: translate3d(-1px, 0, 0);
}
20%, 80% {
transform: translate3d(2px, 0, 0);
}
30%, 50%, 70% {
transform: translate3d(-3px, 0, 0);
}
40%, 60% {
transform: translate3d(3px, 0, 0);
}
}
/* 添加响应式样式 */
@media (max-width: 576px) {
.login-box {
width: 90%;
padding: 30px 20px;
}
}
/* 按钮样式统一 */
.login-box .ant-btn {
height: 40px;
font-size: 16px;
}

View File

@@ -0,0 +1,71 @@
import React, { useState } from 'react';
import { Button, Form, Input, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { authFetch } from '../auth';
import './Login.css';
export default function Login() {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const onFinish = async (values) => {
setLoading(true);
try {
const data = await authFetch('/users/login', {
method: 'POST',
body: {
username: values.username,
password: values.password
}
});
if (data && data.access_token) {
localStorage.setItem('token', data.access_token);
localStorage.setItem('user', JSON.stringify(data.user));
message.success('登录成功!');
navigate('/');
}
} catch (error) {
console.error('登录失败:', error);
message.error(error.message || '登录失败,请重试');
// 添加表单抖动动画
const form = document.querySelector('#login-form');
if (form) {
form.classList.add('shake');
setTimeout(() => form.classList.remove('shake'), 600);
}
}
setLoading(false);
};
return (
<div className="login-container">
<div className="login-box">
<h2>用户登录</h2>
<Form id="login-form" onFinish={onFinish}>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input placeholder="用户名" size="large" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password placeholder="密码" size="large" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large">
登录
</Button>
</Form.Item>
<div className="login-links">
<a href="/register" onClick={(e) => {e.preventDefault(); navigate('/register');}}>没有账号去注册</a>
</div>
</Form>
</div>
</div>
);
}

View File

@@ -0,0 +1,130 @@
import React, { useEffect, useState } from 'react';
import { Card, Typography, Button, message, Input, Tabs, Table } from 'antd';
import { authFetch } from '../auth';
import { useNavigate } from 'react-router-dom';
const { Title, Text } = Typography;
const { TabPane } = Tabs;
function Profile() {
const [user, setUser] = useState(null);
const [amount, setAmount] = useState('');
const [loading, setLoading] = useState(false);
const [history, setHistory] = useState([]);
const [tabKey, setTabKey] = useState('profile');
const navigate = useNavigate();
// 获取用户信息和历史记录
const fetchUserData = async () => {
try {
// 获取历史记录
const data = await authFetch('/history/list');
setHistory(data.history || []);
} catch (err) {
console.error('获取数据失败:', err);
message.error(err.message || '获取历史记录失败');
}
};
useEffect(() => {
const userStr = localStorage.getItem('user');
if (userStr) {
const u = JSON.parse(userStr);
setUser(u);
fetchUserData();
} else {
message.warning('请先登录');
navigate('/login');
}
}, [navigate]);
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
message.success('已退出登录');
navigate('/login');
};
const handleRecharge = async () => {
if (!amount || isNaN(amount) || Number(amount) <= 0) {
message.error('请输入正确的充值金额');
return;
}
setLoading(true);
try {
const data = await authFetch('/balance/recharge', {
method: 'POST',
body: { amount: Number(amount) }
});
message.success('充值成功');
// 更新本地用户余额
const newUser = { ...user, balance: data.balance };
setUser(newUser);
localStorage.setItem('user', JSON.stringify(newUser));
setAmount('');
// 重新获取历史记录
fetchUserData();
} catch (err) {
console.error('充值失败:', err);
message.error(err.message || '充值失败');
}
setLoading(false);
};
if (!user) return null;
const columns = [
{
title: '类型',
dataIndex: 'type',
key: 'type',
render: t => t === 'recharge' ? '充值' : '消费'
},
{
title: '金额',
dataIndex: 'amount',
key: 'amount',
render: v => (v > 0 ? '+' : '') + v
},
{ title: '说明', dataIndex: 'desc', key: 'desc' },
{ title: '时间', dataIndex: 'timestamp', key: 'timestamp' }
];
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '80vh' }}>
<Card style={{ width: 500 }}>
<Tabs activeKey={tabKey} onChange={setTabKey}>
<TabPane tab="个人信息" key="profile">
<Title level={3}>个人中心</Title>
<Text strong>用户名</Text> <Text>{user.username}</Text>
<br /><br />
<Text strong>余额</Text> <Text>{user.balance}</Text>
<br /><br />
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Input
type="number"
placeholder="充值金额"
value={amount}
onChange={e => setAmount(e.target.value)}
style={{ width: 180 }}
/>
<Button type="primary" loading={loading} onClick={handleRecharge}>充值</Button>
</div>
<Button type="primary" danger block onClick={handleLogout}>退出登录</Button>
</TabPane>
<TabPane tab="历史记录" key="history">
<Table
columns={columns}
dataSource={history}
rowKey={(r, i) => i}
pagination={{ pageSize: 5 }}
/>
</TabPane>
</Tabs>
</Card>
</div>
);
}
export default Profile;

View File

@@ -0,0 +1,90 @@
import React, { useState } from 'react';
import { Button, Form, Input, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import './Login.css'; // 引入Login的CSS样式
const BASE_URL = '';
// 使用相对路径,注册请求也会自动走 Nginx 代理
function Register() {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const onFinish = async (values) => {
setLoading(true);
try {
console.log('开始注册请求:', values.username);
const res = await fetch(`${BASE_URL}/users/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(values),
credentials: 'include'
});
if (res.ok) {
message.success('注册成功,请登录');
setTimeout(() => navigate('/login'), 1000);
} else {
const data = await res.json();
console.error('注册错误:', data);
// 处理用户名已注册的情况
if (data.detail && (
data.detail.includes('用户名已被注册') ||
data.detail.includes('already registered') ||
data.detail.includes('已注册')
)) {
message.error({
content: '用户名已被注册,请更换用户名',
className: 'shake',
style: { fontSize: '14px' },
});
// 添加表单抖动动画
const form = document.querySelector('#register-form');
if (form) {
form.classList.add('shake');
setTimeout(() => form.classList.remove('shake'), 600);
}
} else {
message.error(data.detail || '注册失败');
}
}
} catch (err) {
console.error('注册请求失败:', err);
message.error('请求出错,请稍后再试');
}
setLoading(false);
};
return (
<div className="login-container">
<div className="login-box">
<h2>用户注册</h2>
<Form id="register-form" onFinish={onFinish}>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input placeholder="用户名" size="large" />
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password placeholder="密码" size="large" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block size="large">
注册
</Button>
</Form.Item>
<div className="login-links">
<a href="/login" onClick={(e) => {e.preventDefault(); navigate('/login');}}>已有账号去登录</a>
</div>
</Form>
</div>
</div>
);
}
export default Register;

View File

@@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';