Compare commits
No commits in common. "main" and "v1.1.21" have entirely different histories.
203
README.md
|
|
@ -1,100 +1,115 @@
|
||||||
# 甲辰藏品管理系统
|
# 甲辰藏品管理系统
|
||||||
|
|
||||||
> 生肖纪念钞收藏管理系统
|
**版本**: v1.0.0
|
||||||
|
**代号**: 新生
|
||||||
|
**发布日期**: 2026-03-16
|
||||||
|
|
||||||
## 版本信息
|
生肖纪念钞收藏管理系统 - 支持藏品管理、OCR 识别、统计分析等功能。
|
||||||
|
|
||||||
| 项目 | 内容 |
|
|
||||||
|------|------|
|
|
||||||
| **版本** | v1.2.38 |
|
|
||||||
| **代号** | 最终优化版本 |
|
|
||||||
| **发布日期** | 2026-03-31 |
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- **后端**: FastAPI + PostgreSQL + 阿里云OSS + 阿里云短信
|
|
||||||
- **前端**: React + Vite + Tailwind CSS
|
|
||||||
- **部署**: Nginx
|
|
||||||
|
|
||||||
## 核心功能
|
|
||||||
|
|
||||||
- 用户管理(管理员/普通用户)
|
|
||||||
- 藏品管理(CRUD、多条件筛选)
|
|
||||||
- OCR识别(阿里云视觉智能)
|
|
||||||
- 统计分析
|
|
||||||
- 图片上传(阿里云OSS)
|
|
||||||
- 短信验证码
|
|
||||||
- 用户协议
|
|
||||||
- 密码强度验证
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
jiachenlong/
|
|
||||||
├── backend/ # FastAPI后端
|
|
||||||
│ ├── app/
|
|
||||||
│ │ ├── routers/ # API路由
|
|
||||||
│ │ ├── services/ # 业务服务
|
|
||||||
│ │ └── modules/ # 业务模块
|
|
||||||
│ └── config/
|
|
||||||
├── frontend/ # React前端
|
|
||||||
│ ├── src/pages/ # 页面组件
|
|
||||||
│ └── dist/ # 构建产物
|
|
||||||
└── config/ # 版本配置
|
|
||||||
```
|
|
||||||
|
|
||||||
## 版本历史
|
|
||||||
|
|
||||||
| 版本 | 日期 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| v1.2.38 | 2026-03-31 | 最终优化版本 - Admin页面显示28字段、登录AI配号统计、修复OCR/短信/注册提示 |
|
|
||||||
| v1.2.35 | 2026-03-31 | Admin页面显示28字段,登录/AI/配号次数统计 |
|
|
||||||
| v1.2.30 | 2026-03-25 | 资讯功能页面 + 管理后台V2 |
|
|
||||||
| v1.2.2 | 2026-03-20 | 列表布局优化 |
|
|
||||||
| v1.2.1 | 2026-03-20 | 密码强度验证、用户协议 |
|
|
||||||
| v1.2.0 | 2026-03-20 | 管理员查看用户藏品 |
|
|
||||||
|
|
||||||
## 快速开始
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 后端
|
|
||||||
cd backend && pip install -r requirements.txt
|
|
||||||
python -m uvicorn app.main:app --reload
|
|
||||||
|
|
||||||
# 前端
|
|
||||||
cd frontend && npm install && npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
## API接口
|
|
||||||
|
|
||||||
### 认证
|
|
||||||
- POST /api/auth/register - 注册
|
|
||||||
- POST /api/auth/login - 登录
|
|
||||||
- GET /api/auth/me - 当前用户
|
|
||||||
|
|
||||||
### 藏品
|
|
||||||
- GET /api/collections - 列表
|
|
||||||
- POST /api/collections - 创建
|
|
||||||
- PUT /api/collections/{id} - 更新
|
|
||||||
- DELETE /api/collections/{id} - 删除
|
|
||||||
|
|
||||||
## 开发团队
|
|
||||||
|
|
||||||
| 角色 | 姓名 | 职责 |
|
|
||||||
|------|------|------|
|
|
||||||
| 产品负责人 | 酷博特 | 项目总负责 |
|
|
||||||
| 项目经理 | 龙大 | 项目整体管理 |
|
|
||||||
| 开发 | 龙二 | 本地开发 |
|
|
||||||
| 测试 | 龙A | 生产A环境负责人 |
|
|
||||||
| 测试 | 龙B | 生产B环境负责人 |
|
|
||||||
| 运维 | 龙运 | 运维负责人 |
|
|
||||||
| 运维 | 龙镜 | 镜像版本管理负责人 |
|
|
||||||
|
|
||||||
## 文档
|
|
||||||
|
|
||||||
- [部署手册](https://qcn9r1i8r51d.feishu.cn/docx/ZvEadXZ7XoxUtYxGLoIcMCfgn7g)
|
|
||||||
- [Git仓库](http://47.253.189.47:3000/coolbot/jiachenlong)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
© 2026 甲辰收藏
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
jiachenlong/
|
||||||
|
├── backend/ # FastAPI 后端服务
|
||||||
|
├── frontend/ # React 移动端前端
|
||||||
|
├── static/ # 静态资源(图片、图标、字体)
|
||||||
|
├── config/ # 配置文件
|
||||||
|
├── docs/ # 文档
|
||||||
|
├── scripts/ # 部署脚本
|
||||||
|
└── README.md # 本文件
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 后端启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 配置环境变量(修改数据库密码等)
|
||||||
|
vi .env
|
||||||
|
|
||||||
|
# 启动服务
|
||||||
|
python -m uvicorn app.main:app --port 3000 --host 0.0.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 开发模式
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 生产构建
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
所有配置文件在 `config/` 目录:
|
||||||
|
|
||||||
|
- `config/VERSION` - 版本号配置
|
||||||
|
- `config/docker-compose.yml` - Docker 部署配置
|
||||||
|
- `.gitignore` - Git 忽略配置(根目录)
|
||||||
|
|
||||||
|
### 静态资源
|
||||||
|
|
||||||
|
所有静态资源在 `static/` 目录:
|
||||||
|
|
||||||
|
- `static/images/` - 图片资源(Logo、背景图等)
|
||||||
|
- `static/icons/` - 图标资源(favicon、应用图标等)
|
||||||
|
- `static/fonts/` - 字体文件
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 文档
|
||||||
|
|
||||||
|
所有文档都在 `docs/` 目录下:
|
||||||
|
|
||||||
|
- `docs/DEPLOYMENT_v1.0.0.md` - 部署指南
|
||||||
|
- `docs/RELEASE_v1.0.0.md` - 发布说明
|
||||||
|
- `docs/ERROR_CODES.md` - 错误码
|
||||||
|
- `docs/部署手册.md` - 完整部署手册
|
||||||
|
- `docs/BACKEND_SERVICE_GUIDE.md` - 后端服务指南
|
||||||
|
- `docs/TEST_REPORT.md` - 测试报告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
**后端**:
|
||||||
|
- FastAPI + SQLAlchemy
|
||||||
|
- PostgreSQL
|
||||||
|
- JWT 认证
|
||||||
|
- DashScope OCR
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
- React + Vite
|
||||||
|
- 移动端适配
|
||||||
|
- 暗色主题
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 核心功能
|
||||||
|
|
||||||
|
- ✅ 用户管理(管理员/普通用户)
|
||||||
|
- ✅ 藏品管理(CRUD)
|
||||||
|
- ✅ OCR 识别
|
||||||
|
- ✅ 统计分析
|
||||||
|
- ✅ 图片上传
|
||||||
|
- ✅ 移动端适配
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**开发团队**: 酷博特 + 菜鸟小 D 🤖
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
# 数据库配置
|
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac
|
|
||||||
|
|
||||||
# JWT配置
|
|
||||||
SECRET_KEY=your-production-secret-key-change-this
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
|
||||||
|
|
||||||
# 阿里云 DashScope OCR API(必须配置!)
|
|
||||||
DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f
|
|
||||||
|
|
||||||
# 阿里云 OSS配置
|
|
||||||
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
|
|
||||||
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
|
|
||||||
OSS_BUCKET_NAME=jiachenlong-oss
|
|
||||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
|
||||||
OSS_PUBLIC_URL=https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com
|
|
||||||
|
|
||||||
# 阿里云短信配置(必须配置!)
|
|
||||||
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
|
|
||||||
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
|
|
||||||
SMS_SIGN_NAME=苏州算力
|
|
||||||
SMS_TEMPLATE_CODE=SMS_501590956
|
|
||||||
|
|
||||||
# 服务配置
|
|
||||||
PORT=3000
|
|
||||||
HOST=0.0.0.0
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
1.2.98
|
|
||||||
|
|
@ -11,11 +11,9 @@ from app.core.database import SessionLocal
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
|
|
||||||
# 配置
|
# 配置
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
|
||||||
if not SECRET_KEY:
|
|
||||||
raise ValueError("SECRET_KEY environment variable is not set. Please configure it in production!")
|
|
||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "10080")) # 7天
|
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "60"))
|
||||||
|
|
||||||
# HTTP Bearer 认证
|
# HTTP Bearer 认证
|
||||||
security = HTTPBearer(auto_error=False)
|
security = HTTPBearer(auto_error=False)
|
||||||
|
|
@ -59,10 +57,14 @@ def decode_access_token(token: str) -> Optional[dict]:
|
||||||
def get_current_user(
|
def get_current_user(
|
||||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||||
db: Session = Depends(lambda: SessionLocal())
|
db: Session = Depends(lambda: SessionLocal())
|
||||||
) -> Optional[User]:
|
) -> User:
|
||||||
"""获取当前用户(可返回None)"""
|
"""获取当前用户"""
|
||||||
if not credentials:
|
if not credentials:
|
||||||
return None
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="未提供认证信息",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
token = credentials.credentials
|
token = credentials.credentials
|
||||||
payload = decode_access_token(token)
|
payload = decode_access_token(token)
|
||||||
|
|
@ -84,6 +86,10 @@ def get_current_user(
|
||||||
|
|
||||||
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
||||||
if not user:
|
if not user:
|
||||||
return None
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="用户不存在",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
import os
|
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from sqlalchemy.pool import QueuePool
|
|
||||||
|
|
||||||
COOLBOT_DB_URL = os.getenv(
|
|
||||||
"COOLBOT_DB_URL",
|
|
||||||
"postgresql://coolbot:Coolbot123@pgm-bp1t1008h019ez6cno.pg.rds.aliyuncs.com:5432/coolbot_data"
|
|
||||||
)
|
|
||||||
|
|
||||||
coolbot_engine = create_engine(
|
|
||||||
COOLBOT_DB_URL,
|
|
||||||
poolclass=QueuePool,
|
|
||||||
pool_size=10,
|
|
||||||
max_overflow=20,
|
|
||||||
pool_timeout=30,
|
|
||||||
pool_recycle=1800,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
echo=False,
|
|
||||||
connect_args={
|
|
||||||
"connect_timeout": 10,
|
|
||||||
"application_name": "zodiac-coolbot"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
CoolbotSession = sessionmaker(autocommit=False, autoflush=False, bind=coolbot_engine)
|
|
||||||
|
|
||||||
def get_coolbot_db():
|
|
||||||
"""获取coolbot数据库会话"""
|
|
||||||
db = CoolbotSession()
|
|
||||||
try:
|
|
||||||
yield db
|
|
||||||
except Exception:
|
|
||||||
db.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
@ -1,69 +1,32 @@
|
||||||
import os
|
import os
|
||||||
import time
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy import create_engine, event
|
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from sqlalchemy.pool import QueuePool
|
|
||||||
from typing import Generator
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
# 支持 MySQL, PostgreSQL
|
||||||
DATABASE_URL = os.getenv(
|
DATABASE_URL = os.getenv(
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"postgresql://postgres:postgres@127.0.0.1:5432/zodiac"
|
"postgresql://postgres:postgres@localhost:5432/zodiac"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 增强版数据库引擎配置
|
# 数据库引擎配置
|
||||||
engine = create_engine(
|
engine = create_engine(
|
||||||
DATABASE_URL,
|
DATABASE_URL,
|
||||||
# 连接池配置
|
pool_pre_ping=True,
|
||||||
poolclass=QueuePool,
|
pool_size=10,
|
||||||
pool_size=20, # 常规连接数
|
max_overflow=20,
|
||||||
max_overflow=40, # 允许超出的连接数(高并发时)
|
echo=False
|
||||||
pool_timeout=30, # 获取连接超时时间(秒)
|
|
||||||
pool_recycle=1800, # 连接回收时间(30分钟),避免连接过期
|
|
||||||
pool_pre_ping=True, # 每次获取连接前检查连接是否有效
|
|
||||||
echo=False,
|
|
||||||
# 连接参数优化
|
|
||||||
connect_args={
|
|
||||||
"connect_timeout": 10,
|
|
||||||
"application_name": "zodiac-api",
|
|
||||||
"options": "-c statement_timeout=30000" # 查询超时30秒
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 添加连接事件监听器
|
|
||||||
@event.listens_for(engine, "connect")
|
|
||||||
def set_connect_timeout(dbapi_conn, connection_record):
|
|
||||||
"""设置连接参数"""
|
|
||||||
cursor = dbapi_conn.cursor()
|
|
||||||
cursor.execute("SET statement_timeout = 30000")
|
|
||||||
cursor.close()
|
|
||||||
|
|
||||||
@event.listens_for(engine, "checkout")
|
|
||||||
def check_connection(dbapi_conn, connection_record, connection_proxy):
|
|
||||||
"""检出连接时检查"""
|
|
||||||
try:
|
|
||||||
cursor = dbapi_conn.cursor()
|
|
||||||
cursor.execute("SELECT 1")
|
|
||||||
cursor.close()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"连接检查失败: {e}")
|
|
||||||
raise Exception("数据库连接无效")
|
|
||||||
|
|
||||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
def get_db() -> Generator:
|
|
||||||
"""获取数据库会话,带错误处理"""
|
def get_db():
|
||||||
|
"""获取数据库会话"""
|
||||||
db = SessionLocal(expire_on_commit=False)
|
db = SessionLocal(expire_on_commit=False)
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"数据库会话错误: {e}")
|
|
||||||
db.rollback()
|
|
||||||
raise
|
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,6 @@ from app.middleware.logging import logging_middleware
|
||||||
from app.routers import auth, collections, operations
|
from app.routers import auth, collections, operations
|
||||||
from app.routers import ocr as ocr_router
|
from app.routers import ocr as ocr_router
|
||||||
from app.routers import users as users_router
|
from app.routers import users as users_router
|
||||||
from app.routers import information as information_router
|
|
||||||
from app.routers import yichens as yichens_router
|
|
||||||
from app.routers import seek as seek_router
|
|
||||||
from app.routers import deal as deal_router
|
|
||||||
|
|
||||||
# 版本信息 - 从 config/VERSION 文件读取
|
# 版本信息 - 从 config/VERSION 文件读取
|
||||||
def get_version():
|
def get_version():
|
||||||
|
|
@ -54,11 +50,10 @@ app = FastAPI(
|
||||||
# 设置全局错误处理器
|
# 设置全局错误处理器
|
||||||
setup_error_handlers(app)
|
setup_error_handlers(app)
|
||||||
|
|
||||||
# CORS 配置 - 生产环境限制域名
|
# CORS 配置
|
||||||
ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://47.103.29.111,http://120.55.81.21,https://socoolbot.com").split(",")
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=ALLOWED_ORIGINS,
|
allow_origins=["*"], # 生产环境应该限制域名
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|
@ -69,10 +64,10 @@ uploads_dir = "uploads"
|
||||||
os.makedirs(uploads_dir, exist_ok=True)
|
os.makedirs(uploads_dir, exist_ok=True)
|
||||||
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
|
||||||
|
|
||||||
# 挂载项目静态资源目录
|
# 挂载项目静态资源目录(可选,生产环境建议用 Nginx)
|
||||||
static_dir = Path(__file__).parent.parent / "static"
|
# static_dir = Path(__file__).parent.parent.parent / "static"
|
||||||
if static_dir.exists():
|
# if static_dir.exists():
|
||||||
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
# app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
# 添加日志中间件
|
# 添加日志中间件
|
||||||
app.middleware("http")(logging_middleware)
|
app.middleware("http")(logging_middleware)
|
||||||
|
|
@ -84,10 +79,6 @@ app.include_router(operations.router)
|
||||||
app.include_router(ocr_router.router) # OCR 识别
|
app.include_router(ocr_router.router) # OCR 识别
|
||||||
app.include_router(users_router.router) # 当前用户接口
|
app.include_router(users_router.router) # 当前用户接口
|
||||||
app.include_router(users_router.admin_router) # 管理员用户管理
|
app.include_router(users_router.admin_router) # 管理员用户管理
|
||||||
app.include_router(information_router.router)
|
|
||||||
app.include_router(yichens_router.router) # 一尘看板
|
|
||||||
app.include_router(seek_router.router) # 寻配号
|
|
||||||
app.include_router(deal_router.router) # 成交行情
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|
@ -110,6 +101,3 @@ if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
port = int(os.getenv("PORT", "3000"))
|
port = int(os.getenv("PORT", "3000"))
|
||||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||||
|
|
||||||
from app.routers import seek as seek_router
|
|
||||||
from app.routers import deal as deal_router
|
|
||||||
|
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
from sqlalchemy import Column, String, Text, Float, Date, Boolean, Integer, DateTime
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from app.core.database import Base
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
def generate_uuid():
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
||||||
class DealInfo(Base):
|
|
||||||
__tablename__ = "deal_info"
|
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
user_id = Column(String(36), nullable=True, index=True)
|
|
||||||
|
|
||||||
# 标题和内容
|
|
||||||
title = Column(String(255), nullable=False)
|
|
||||||
content = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# 成交信息
|
|
||||||
deal_price = Column(Float, nullable=True) # 成交价格
|
|
||||||
deal_date = Column(Date, nullable=True) # 成交日期
|
|
||||||
deal_no = Column(String(20), nullable=True, index=True) # 行情编号(从A000001开始递增)
|
|
||||||
|
|
||||||
# 包装和分类
|
|
||||||
packaging = Column(String(50), nullable=True) # 包装(标百/标十/单张)
|
|
||||||
category = Column(String(100), nullable=True) # 分类
|
|
||||||
|
|
||||||
# 评级相关
|
|
||||||
is_graded = Column(Boolean, default=False) # 是否评级
|
|
||||||
grading_company = Column(String(100), nullable=True) # 评级机构
|
|
||||||
grading_score = Column(String(50), nullable=True) # 评级分数
|
|
||||||
|
|
||||||
# 号码特征
|
|
||||||
tail_number = Column(String(10), nullable=True) # 尾号
|
|
||||||
size_type = Column(String(20), nullable=True) # 大小号
|
|
||||||
|
|
||||||
# 版别
|
|
||||||
version = Column(String(50), nullable=True) # 版别
|
|
||||||
|
|
||||||
# 交易信息
|
|
||||||
platform = Column(String(50), nullable=True) # 成交平台
|
|
||||||
seller = Column(String(100), nullable=True) # 出售者
|
|
||||||
buyer = Column(String(100), nullable=True) # 购买者
|
|
||||||
|
|
||||||
# 状态
|
|
||||||
status = Column(String(20), default="active")
|
|
||||||
|
|
||||||
# 统计
|
|
||||||
view_count = Column(Integer, default=0)
|
|
||||||
contact_count = Column(Integer, default=0)
|
|
||||||
|
|
||||||
# 时间
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
# 数据库模型 - 使用字段编码
|
# 数据库模型 - 使用字段编码
|
||||||
from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey, Date, UniqueConstraint
|
from sqlalchemy import Column, String, Float, Boolean, DateTime, Integer, Text, ForeignKey
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
|
|
@ -16,7 +16,6 @@ class User(Base):
|
||||||
|
|
||||||
# f99 系统字段
|
# f99 系统字段
|
||||||
f99_90_id = Column(String(36), primary_key=True, default=generate_uuid)
|
f99_90_id = Column(String(36), primary_key=True, default=generate_uuid)
|
||||||
user_code = Column(String(10), unique=True, nullable=True, index=True) # 用户编码
|
|
||||||
f99_91_user_id = Column(String(36), unique=True, nullable=False, index=True)
|
f99_91_user_id = Column(String(36), unique=True, nullable=False, index=True)
|
||||||
f01_01_name = Column(String(255), unique=True, nullable=False, index=True) # username
|
f01_01_name = Column(String(255), unique=True, nullable=False, index=True) # username
|
||||||
email = Column(String(255), unique=True, nullable=True, index=True)
|
email = Column(String(255), unique=True, nullable=True, index=True)
|
||||||
|
|
@ -26,26 +25,9 @@ class User(Base):
|
||||||
bio = Column(Text, nullable=True)
|
bio = Column(Text, nullable=True)
|
||||||
password = Column(String(255), nullable=False)
|
password = Column(String(255), nullable=False)
|
||||||
role = Column(String(50), default="user")
|
role = Column(String(50), default="user")
|
||||||
# 时间字段
|
last_login = Column(DateTime(timezone=True), nullable=True) # 最后登录时间
|
||||||
f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now())
|
f99_92_created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
f99_93_updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
# 新增字段
|
|
||||||
f99_94_level = Column(String(20), default="青铜") # 会员等级
|
|
||||||
f99_95_ai_count = Column(Integer, default=0) # ai识别次数
|
|
||||||
f99_96_search_count = Column(Integer, default=0) # 寻号使用次数
|
|
||||||
f99_97_collection_count = Column(Integer, default=0) # 藏品数量
|
|
||||||
f01_06_phone_verified = Column(Boolean, default=False) # 手机号已核验
|
|
||||||
f99_98_login_count = Column(Integer, default=0) # 登录次数
|
|
||||||
f99_99_last_login = Column(DateTime(timezone=True), nullable=True) # 最后登录时间
|
|
||||||
f01_07_gender = Column(String(10), nullable=True) # 性别
|
|
||||||
f01_08_birthday = Column(Date, nullable=True) # 生日
|
|
||||||
f01_09_region = Column(String(100), nullable=True) # 地区
|
|
||||||
f01_10_realname_verified = Column(Boolean, default=False) # 实名认证
|
|
||||||
f99_100_points = Column(Integer, default=0) # 积分
|
|
||||||
f01_11_balance = Column(Float, default=0) # 余额
|
|
||||||
f01_12_total_amount = Column(Float, default=0) # 累计金额
|
|
||||||
f01_13_invite_code = Column(String(20), nullable=True) # 邀请码(自己的邀请码)
|
|
||||||
f99_101_invited_count = Column(Integer, default=0) # 通过自己邀请码注册的用户数量
|
|
||||||
|
|
||||||
collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")
|
collections = relationship("Collection", back_populates="user", cascade="all, delete-orphan")
|
||||||
operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan")
|
operations = relationship("Operation", back_populates="user", cascade="all, delete-orphan")
|
||||||
|
|
@ -73,7 +55,6 @@ class Collection(Base):
|
||||||
f02_11_version = Column(String(100), nullable=True, index=True)
|
f02_11_version = Column(String(100), nullable=True, index=True)
|
||||||
f02_12_packaging = Column(String(100), nullable=True, index=True)
|
f02_12_packaging = Column(String(100), nullable=True, index=True)
|
||||||
f02_13_rarity = Column(String(50), nullable=True, index=True) # 珍惜度
|
f02_13_rarity = Column(String(50), nullable=True, index=True) # 珍惜度
|
||||||
f02_14_number_category = Column(String(20), nullable=True, index=True) # 号码分类
|
|
||||||
|
|
||||||
# f03 评级信息
|
# f03 评级信息
|
||||||
f03_20_is_graded = Column(Boolean, default=False, index=True)
|
f03_20_is_graded = Column(Boolean, default=False, index=True)
|
||||||
|
|
@ -147,122 +128,3 @@ class CustomField(Base):
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
user = relationship("User", back_populates="custom_fields")
|
user = relationship("User", back_populates="custom_fields")
|
||||||
|
|
||||||
|
|
||||||
# 资讯模型
|
|
||||||
class Information(Base):
|
|
||||||
__tablename__ = "information"
|
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
|
|
||||||
# 信息类型: seek-寻配号, deal-成交数据, publish-发布
|
|
||||||
info_type = Column(String(20), nullable=False, index=True)
|
|
||||||
|
|
||||||
# 标题
|
|
||||||
title = Column(String(255), nullable=False)
|
|
||||||
|
|
||||||
# 内容描述
|
|
||||||
content = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# 关联藏品ID
|
|
||||||
collection_id = Column(String(36), ForeignKey("collections.f99_90_id", ondelete="SET NULL"), nullable=True)
|
|
||||||
|
|
||||||
# 期望条件 (寻配号用)
|
|
||||||
expect_category = Column(String(100), nullable=True) # 期望类别
|
|
||||||
expect_version = Column(String(100), nullable=True) # 期望版别
|
|
||||||
expect_packaging = Column(String(100), nullable=True) # 期望包装
|
|
||||||
expect_number = Column(String(50), nullable=True) # 期望号码
|
|
||||||
expect_price_min = Column(Float, nullable=True) # 期望价格区间
|
|
||||||
expect_price_max = Column(Float, nullable=True)
|
|
||||||
|
|
||||||
# 成交价格 (成交数据用)
|
|
||||||
deal_price = Column(Float, nullable=True)
|
|
||||||
deal_date = Column(Date, nullable=True)
|
|
||||||
|
|
||||||
# 评级相关字段
|
|
||||||
packaging = Column(String(50), nullable=True)
|
|
||||||
is_graded = Column(Boolean, default=False)
|
|
||||||
grading_company = Column(String(100), nullable=True)
|
|
||||||
grading_score = Column(String(50), nullable=True)
|
|
||||||
category = Column(String(100), nullable=True)
|
|
||||||
|
|
||||||
# 行情编号
|
|
||||||
deal_no = Column(String(50), nullable=True, index=True)
|
|
||||||
|
|
||||||
# 状态: active-有效, closed-已关闭, expired-已过期
|
|
||||||
status = Column(String(20), default="active", index=True)
|
|
||||||
|
|
||||||
# 匹配状态: pending-尚未匹配, matched-已经匹配
|
|
||||||
is_matched = Column(String(20), default="pending", index=True)
|
|
||||||
|
|
||||||
# 匹配的用户ID(当用户愿意交换联系方式时)
|
|
||||||
matched_user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="SET NULL"), nullable=True)
|
|
||||||
|
|
||||||
# 匹配者的联系方式(只有发布者和匹配者可见)
|
|
||||||
matched_contact = Column(String(100), nullable=True)
|
|
||||||
|
|
||||||
# 浏览/联系次数
|
|
||||||
view_count = Column(Integer, default=0)
|
|
||||||
contact_count = Column(Integer, default=0)
|
|
||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
||||||
user = relationship("User", foreign_keys=[user_id])
|
|
||||||
collection = relationship("Collection")
|
|
||||||
|
|
||||||
|
|
||||||
# 资讯评论/留言
|
|
||||||
class InformationComment(Base):
|
|
||||||
__tablename__ = "information_comments"
|
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
content = Column(Text, nullable=False)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
user = relationship("User", foreign_keys=[user_id])
|
|
||||||
|
|
||||||
|
|
||||||
# 资讯联系方式查看记录
|
|
||||||
class InformationContactView(Base):
|
|
||||||
__tablename__ = "information_contact_views"
|
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
viewer_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
viewer = relationship("User", foreign_keys=[viewer_id])
|
|
||||||
|
|
||||||
|
|
||||||
# 资讯关联用户 (收藏/点赞)
|
|
||||||
class InformationLike(Base):
|
|
||||||
__tablename__ = "information_likes"
|
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
information_id = Column(String(36), ForeignKey("information.id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
user_id = Column(String(36), ForeignKey("users.f99_90_id", ondelete="CASCADE"), nullable=False, index=True)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint('information_id', 'user_id', name='uq_information_user'),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# 角色常量
|
|
||||||
class UserRole:
|
|
||||||
ADMIN = "admin" # 管理员:全部权限
|
|
||||||
EDITOR = "editor" # 信息员:可发布信息、管理资讯
|
|
||||||
USER = "user" # 普通用户:基本功能
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_role_name(cls, role):
|
|
||||||
names = {
|
|
||||||
cls.ADMIN: "管理员",
|
|
||||||
cls.EDITOR: "信息员",
|
|
||||||
cls.USER: "用户"
|
|
||||||
}
|
|
||||||
return names.get(role, "用户")
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
from sqlalchemy import Column, String, Text, Float, Date, Boolean, Integer, DateTime
|
|
||||||
from sqlalchemy.sql import func
|
|
||||||
from app.core.database import Base
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
def generate_uuid():
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
||||||
class SeekInfo(Base):
|
|
||||||
__tablename__ = "seek_info"
|
|
||||||
|
|
||||||
id = Column(String(36), primary_key=True, default=generate_uuid)
|
|
||||||
user_id = Column(String(36), nullable=False, index=True)
|
|
||||||
|
|
||||||
# 标题和内容
|
|
||||||
title = Column(String(255), nullable=False)
|
|
||||||
content = Column(Text, nullable=True)
|
|
||||||
|
|
||||||
# 期望条件(求购条件)
|
|
||||||
expect_category = Column(String(100), nullable=True) # 期望类别
|
|
||||||
expect_version = Column(String(100), nullable=True) # 期望版别
|
|
||||||
expect_packaging = Column(String(100), nullable=True) # 期望包装
|
|
||||||
expect_number = Column(String(50), nullable=True) # 期望号码
|
|
||||||
expect_price_min = Column(Float, nullable=True) # 期望最低价
|
|
||||||
expect_price_max = Column(Float, nullable=True) # 期望最高价
|
|
||||||
|
|
||||||
# 匹配状态
|
|
||||||
status = Column(String(20), default="active") # active/closed/expired
|
|
||||||
is_matched = Column(String(10), default="false") # 是否已匹配
|
|
||||||
matched_user_id = Column(String(36), nullable=True) # 匹配的用户ID
|
|
||||||
matched_contact = Column(String(100), nullable=True) # 匹配的联系方式
|
|
||||||
|
|
||||||
# 统计
|
|
||||||
view_count = Column(Integer, default=0)
|
|
||||||
contact_count = Column(Integer, default=0)
|
|
||||||
|
|
||||||
# 时间
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
@ -1,489 +1,167 @@
|
||||||
# auth - 认证路由
|
# 认证路由 - 使用字段编码
|
||||||
# Version: 1.2.90
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
from fastapi import APIRouter
|
|
||||||
# Version: 1.2.x
|
|
||||||
# 更新:
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
from fastapi import APIRouter, Depends, HTTPException, status, Body
|
||||||
# 更新:
|
|
||||||
from fastapi.security import OAuth2PasswordRequestForm
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
# 更新:
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
# 更新:
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
# 更新:
|
|
||||||
from app.core.auth import verify_password, create_access_token, get_password_hash, get_current_user
|
from app.core.auth import verify_password, create_access_token, get_password_hash, get_current_user
|
||||||
# 更新:
|
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
# 更新:
|
|
||||||
from app.schemas.schemas import Token, UserCreate, UserResponse
|
from app.schemas.schemas import Token, UserCreate, UserResponse
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
router = APIRouter(prefix="/api/auth", tags=["认证"])
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
def generate_user_code(db):
|
|
||||||
# 更新:
|
|
||||||
"""生成用户编码,从201开始,按自然数顺序递增,跳过已存在的"""
|
|
||||||
# 更新:
|
|
||||||
# 查找最大的user_code
|
|
||||||
# 更新:
|
|
||||||
max_code = db.query(User.user_code).filter(User.user_code != None).order_by(User.user_code.desc()).first()
|
|
||||||
# 更新:
|
|
||||||
if max_code and max_code[0]:
|
|
||||||
# 更新:
|
|
||||||
try:
|
|
||||||
# 更新:
|
|
||||||
num = int(max_code[0]) + 1
|
|
||||||
# 更新:
|
|
||||||
if num < 201:
|
|
||||||
# 更新:
|
|
||||||
num = 201
|
|
||||||
# 更新:
|
|
||||||
# 检查是否已存在,如果存在则继续递增
|
|
||||||
# 更新:
|
|
||||||
while db.query(User).filter(User.user_code == str(num)).first():
|
|
||||||
# 更新:
|
|
||||||
num += 1
|
|
||||||
# 更新:
|
|
||||||
return str(num)
|
|
||||||
# 更新:
|
|
||||||
except:
|
|
||||||
# 更新:
|
|
||||||
pass
|
|
||||||
# 更新:
|
|
||||||
return "201"
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("/register", response_model=UserResponse)
|
@router.post("/register", response_model=UserResponse)
|
||||||
# 更新:
|
|
||||||
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
def register(user_data: UserCreate, db: Session = Depends(get_db)):
|
||||||
# 更新:
|
|
||||||
"""用户注册"""
|
"""用户注册"""
|
||||||
# 更新:
|
|
||||||
# 检查用户名是否已存在
|
# 检查用户名是否已存在
|
||||||
# 更新:
|
|
||||||
existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first()
|
existing_user = db.query(User).filter(User.f01_01_name == user_data.f01_01_name).first()
|
||||||
# 更新:
|
|
||||||
if existing_user:
|
if existing_user:
|
||||||
# 更新:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
# 更新:
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
# 更新:
|
|
||||||
detail="f01_01_name: 用户名已存在"
|
detail="f01_01_name: 用户名已存在"
|
||||||
# 更新:
|
|
||||||
)
|
)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 检查邮箱是否已存在
|
# 检查邮箱是否已存在
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 检查手机号是否已存在
|
|
||||||
# 更新:
|
|
||||||
if user_data.phone:
|
|
||||||
# 更新:
|
|
||||||
existing_phone = db.query(User).filter(User.phone == user_data.phone).first()
|
|
||||||
# 更新:
|
|
||||||
if existing_phone:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(
|
|
||||||
# 更新:
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
# 更新:
|
|
||||||
detail="E00040:该手机号已被注册,请更换手机号"
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
if user_data.email:
|
if user_data.email:
|
||||||
# 更新:
|
|
||||||
existing_email = db.query(User).filter(User.email == user_data.email).first()
|
existing_email = db.query(User).filter(User.email == user_data.email).first()
|
||||||
# 更新:
|
|
||||||
if existing_email:
|
if existing_email:
|
||||||
# 更新:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
# 更新:
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
# 更新:
|
detail="邮箱已被注册"
|
||||||
detail="E00041:该邮箱已被注册,请更换邮箱"
|
|
||||||
# 更新:
|
|
||||||
)
|
)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 处理邀请码
|
|
||||||
# 更新:
|
|
||||||
invited_by_user = None
|
|
||||||
# 更新:
|
|
||||||
if user_data.invite_code:
|
|
||||||
# 更新:
|
|
||||||
# 查找邀请人
|
|
||||||
# 更新:
|
|
||||||
invited_by_user = db.query(User).filter(User.user_code == user_data.invite_code).first()
|
|
||||||
# 更新:
|
|
||||||
if not invited_by_user:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(
|
|
||||||
# 更新:
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
# 更新:
|
|
||||||
detail="E00042:邀请码无效"
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 创建用户
|
# 创建用户
|
||||||
# 更新:
|
|
||||||
import uuid
|
import uuid
|
||||||
# 更新:
|
|
||||||
hashed_password = get_password_hash(user_data.password)
|
hashed_password = get_password_hash(user_data.password)
|
||||||
# 更新:
|
|
||||||
generated_code = generate_user_code(db)
|
|
||||||
# 更新:
|
|
||||||
user = User(
|
user = User(
|
||||||
# 更新:
|
|
||||||
f99_90_id=str(uuid.uuid4()),
|
f99_90_id=str(uuid.uuid4()),
|
||||||
# 更新:
|
f99_91_user_id=str(uuid.uuid4()), # 生成唯一 user_id
|
||||||
f99_91_user_id=str(uuid.uuid4()),
|
|
||||||
# 更新:
|
|
||||||
user_code=generated_code,
|
|
||||||
# 更新:
|
|
||||||
f01_01_name=user_data.f01_01_name,
|
f01_01_name=user_data.f01_01_name,
|
||||||
# 更新:
|
|
||||||
email=user_data.email,
|
email=user_data.email,
|
||||||
# 更新:
|
|
||||||
phone=user_data.phone,
|
phone=user_data.phone,
|
||||||
# 更新:
|
|
||||||
avatar=user_data.avatar,
|
avatar=user_data.avatar,
|
||||||
# 更新:
|
|
||||||
address=user_data.address,
|
address=user_data.address,
|
||||||
# 更新:
|
|
||||||
bio=user_data.bio,
|
bio=user_data.bio,
|
||||||
# 更新:
|
|
||||||
password=hashed_password,
|
password=hashed_password,
|
||||||
# 更新:
|
|
||||||
role="user"
|
role="user"
|
||||||
# 更新:
|
|
||||||
)
|
)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
db.add(user)
|
db.add(user)
|
||||||
# 更新:
|
|
||||||
db.flush() # 确保获取user ID
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 更新邀请人、被邀请人的关联关系
|
|
||||||
# 更新:
|
|
||||||
if invited_by_user:
|
|
||||||
# 更新:
|
|
||||||
# 记录是被谁邀请的
|
|
||||||
# 更新:
|
|
||||||
user.f01_13_invite_code = invited_by_user.user_code
|
|
||||||
# 更新:
|
|
||||||
# 增加邀请人的邀请计数
|
|
||||||
# 更新:
|
|
||||||
invited_by_user.f99_101_invited_count = (invited_by_user.f99_101_invited_count or 0) + 1
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 生成自己的邀请码(用自己的user_code)
|
|
||||||
# 更新:
|
|
||||||
user.f01_13_invite_code = generated_code
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
db.commit()
|
||||||
# 更新:
|
|
||||||
db.refresh(user)
|
db.refresh(user)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
return user
|
||||||
# 返回用户信息(避免Pydantic序列化问题)
|
|
||||||
# 更新:
|
|
||||||
return {
|
|
||||||
# 更新:
|
|
||||||
"id": user.f99_90_id,
|
|
||||||
# 更新:
|
|
||||||
"username": user.f01_01_name,
|
|
||||||
# 更新:
|
|
||||||
"user_code": user.user_code,
|
|
||||||
# 更新:
|
|
||||||
"email": user.email,
|
|
||||||
# 更新:
|
|
||||||
"phone": user.phone,
|
|
||||||
# 更新:
|
|
||||||
"avatar": user.avatar,
|
|
||||||
# 更新:
|
|
||||||
"role": user.role,
|
|
||||||
# 更新:
|
|
||||||
"level": user.f99_94_level,
|
|
||||||
# 更新:
|
|
||||||
"aiCount": user.f99_95_ai_count or 0,
|
|
||||||
# 更新:
|
|
||||||
"searchCount": user.f99_96_search_count or 0,
|
|
||||||
# 更新:
|
|
||||||
"collectionCount": user.f99_97_collection_count or 0
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("/login", response_model=Token)
|
@router.post("/login", response_model=Token)
|
||||||
# 更新:
|
|
||||||
def login(
|
def login(
|
||||||
# 更新:
|
|
||||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
# 更新:
|
|
||||||
):
|
):
|
||||||
# 更新:
|
"""用户登录"""
|
||||||
"""用户登录 - 支持用户名或用户编码登录"""
|
# 查找用户
|
||||||
# 更新:
|
|
||||||
# 先尝试用户名登录
|
|
||||||
# 更新:
|
|
||||||
user = db.query(User).filter(User.f01_01_name == form_data.username).first()
|
user = db.query(User).filter(User.f01_01_name == form_data.username).first()
|
||||||
# 更新:
|
|
||||||
# 如果用户名不存在,尝试用户编码登录
|
|
||||||
# 更新:
|
|
||||||
if not user:
|
if not user:
|
||||||
# 更新:
|
|
||||||
user = db.query(User).filter(User.user_code == form_data.username).first()
|
|
||||||
# 更新:
|
|
||||||
if not user:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
# 更新:
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
# 更新:
|
|
||||||
detail="E00011: 用户名或密码错误",
|
detail="E00011: 用户名或密码错误",
|
||||||
# 更新:
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
# 更新:
|
|
||||||
)
|
)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 验证密码
|
# 验证密码
|
||||||
# 更新:
|
|
||||||
if not verify_password(form_data.password, user.password):
|
if not verify_password(form_data.password, user.password):
|
||||||
# 更新:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
# 更新:
|
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
# 更新:
|
|
||||||
detail="E00011: 用户名或密码错误",
|
detail="E00011: 用户名或密码错误",
|
||||||
# 更新:
|
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
# 更新:
|
|
||||||
)
|
)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 更新登录次数和最后登录时间
|
|
||||||
# 更新:
|
|
||||||
from datetime import datetime
|
|
||||||
# 更新:
|
|
||||||
user.f99_98_login_count = (user.f99_98_login_count or 0) + 1
|
|
||||||
# 更新:
|
|
||||||
user.f99_99_last_login = datetime.now()
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 生成 token
|
# 生成 token
|
||||||
# 更新:
|
|
||||||
access_token = create_access_token(data={"sub": user.f99_90_id})
|
access_token = create_access_token(data={"sub": user.f99_90_id})
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {
|
return {
|
||||||
# 更新:
|
|
||||||
"access_token": access_token,
|
"access_token": access_token,
|
||||||
# 更新:
|
|
||||||
"token_type": "bearer"
|
"token_type": "bearer"
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
# 更新:
|
|
||||||
def get_current_user_info(
|
def get_current_user_info(
|
||||||
# 更新:
|
|
||||||
current_user: User = Depends(lambda: None)
|
current_user: User = Depends(lambda: None)
|
||||||
# 更新:
|
|
||||||
):
|
):
|
||||||
# 更新:
|
|
||||||
"""获取当前用户信息"""
|
"""获取当前用户信息"""
|
||||||
# 更新:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
# 更新:
|
|
||||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||||
# 更新:
|
|
||||||
detail="请使用正确的依赖注入"
|
detail="请使用正确的依赖注入"
|
||||||
# 更新:
|
|
||||||
)
|
)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("/change-password")
|
@router.post("/change-password")
|
||||||
# 更新:
|
|
||||||
def change_password(
|
def change_password(
|
||||||
# 更新:
|
|
||||||
old_password: str = Body(...),
|
old_password: str = Body(...),
|
||||||
# 更新:
|
|
||||||
new_password: str = Body(...),
|
new_password: str = Body(...),
|
||||||
# 更新:
|
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
# 更新:
|
|
||||||
):
|
):
|
||||||
# 更新:
|
|
||||||
"""修改当前用户密码"""
|
"""修改当前用户密码"""
|
||||||
# 更新:
|
|
||||||
from app.core.auth import verify_password, get_password_hash
|
from app.core.auth import verify_password, get_password_hash
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 在当前session中重新查询用户
|
# 在当前session中重新查询用户
|
||||||
# 更新:
|
|
||||||
user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first()
|
user = db.query(User).filter(User.f99_90_id == current_user.f99_90_id).first()
|
||||||
# 更新:
|
|
||||||
if not user:
|
if not user:
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=404, detail="用户不存在")
|
raise HTTPException(status_code=404, detail="用户不存在")
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 验证旧密码
|
# 验证旧密码
|
||||||
# 更新:
|
|
||||||
if not verify_password(old_password, user.password):
|
if not verify_password(old_password, user.password):
|
||||||
# 更新:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
# 更新:
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
# 更新:
|
|
||||||
detail="当前密码错误"
|
detail="当前密码错误"
|
||||||
# 更新:
|
|
||||||
)
|
)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 更新密码
|
# 更新密码
|
||||||
# 更新:
|
|
||||||
user.password = get_password_hash(new_password)
|
user.password = get_password_hash(new_password)
|
||||||
# 更新:
|
|
||||||
db.commit()
|
db.commit()
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {"message": "密码修改成功"}
|
return {"message": "密码修改成功"}
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ 短信验证码接口 ============
|
# ============ 短信验证码接口 ============
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("/send-verification-code")
|
@router.post("/send-verification-code")
|
||||||
# 更新:
|
|
||||||
def send_verification_code(
|
def send_verification_code(
|
||||||
# 更新:
|
|
||||||
phone: str = Body(..., min_length=11, max_length=11),
|
phone: str = Body(..., min_length=11, max_length=11),
|
||||||
# 更新:
|
|
||||||
purpose: str = Body("register") # register | login | reset_password
|
purpose: str = Body("register") # register | login | reset_password
|
||||||
# 更新:
|
|
||||||
):
|
):
|
||||||
# 更新:
|
|
||||||
"""发送短信验证码"""
|
"""发送短信验证码"""
|
||||||
# 更新:
|
|
||||||
from app.services.sms import send_verification_code as send_sms
|
from app.services.sms import send_verification_code as send_sms
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 验证手机号格式
|
# 验证手机号格式
|
||||||
# 更新:
|
|
||||||
if not phone.startswith("1") or len(phone) != 11:
|
if not phone.startswith("1") or len(phone) != 11:
|
||||||
# 更新:
|
|
||||||
return {"success": False, "message": "手机号格式不正确"}
|
return {"success": False, "message": "手机号格式不正确"}
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
result = send_sms(phone)
|
result = send_sms(phone)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if result["success"]:
|
if result["success"]:
|
||||||
# 更新:
|
|
||||||
return {
|
return {
|
||||||
# 更新:
|
|
||||||
"success": True,
|
"success": True,
|
||||||
# 更新:
|
|
||||||
"message": f"验证码已发送到 {phone[:3]}****{phone[7:]}",
|
"message": f"验证码已发送到 {phone[:3]}****{phone[7:]}",
|
||||||
# 更新:
|
|
||||||
"expire": result.get("expire", 300)
|
"expire": result.get("expire", 300)
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
else:
|
else:
|
||||||
# 更新:
|
|
||||||
return result
|
return result
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("/verify-code")
|
@router.post("/verify-code")
|
||||||
# 更新:
|
|
||||||
def verify_code(
|
def verify_code(
|
||||||
# 更新:
|
|
||||||
phone: str = Body(...),
|
phone: str = Body(...),
|
||||||
# 更新:
|
|
||||||
code: str = Body(..., min_length=6, max_length=6)
|
code: str = Body(..., min_length=6, max_length=6)
|
||||||
# 更新:
|
|
||||||
):
|
):
|
||||||
# 更新:
|
|
||||||
"""验证短信验证码(仅验证,不执行后续操作)"""
|
"""验证短信验证码(仅验证,不执行后续操作)"""
|
||||||
# 更新:
|
|
||||||
from app.services.sms import verify_code as check_code
|
from app.services.sms import verify_code as check_code
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
is_valid = check_code(phone, code)
|
is_valid = check_code(phone, code)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if is_valid:
|
if is_valid:
|
||||||
# 更新:
|
|
||||||
return {"success": True, "message": "验证成功"}
|
return {"success": True, "message": "验证成功"}
|
||||||
# 更新:
|
|
||||||
else:
|
else:
|
||||||
# 更新:
|
|
||||||
return {"success": False, "message": "验证码错误或已过期"}
|
return {"success": False, "message": "验证码错误或已过期"}
|
||||||
# 更新:
|
|
||||||
|
|
|
||||||
|
|
@ -1,502 +0,0 @@
|
||||||
# deal - 成交行情路由
|
|
||||||
# Version: 1.2.85
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
|
||||||
# 更新:
|
|
||||||
# Version: 1.2.x
|
|
||||||
# 更新:
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
# 更新:
|
|
||||||
from pydantic import BaseModel
|
|
||||||
# 更新:
|
|
||||||
from typing import Optional
|
|
||||||
# 更新:
|
|
||||||
from datetime import datetime, date
|
|
||||||
# 更新:
|
|
||||||
from app.core.database import get_db
|
|
||||||
# 更新:
|
|
||||||
from app.core.auth import get_current_user
|
|
||||||
# 更新:
|
|
||||||
from app.models.deal_info import DealInfo
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
router = APIRouter(prefix="/api/deal", tags=["成交行情"])
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ Schema ============
|
|
||||||
# 更新:
|
|
||||||
class DealInfoCreate(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
title: str
|
|
||||||
# 更新:
|
|
||||||
content: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
deal_price: Optional[float] = None
|
|
||||||
# 更新:
|
|
||||||
deal_date: Optional[str] = None # YYYY-MM-DD
|
|
||||||
# 更新:
|
|
||||||
packaging: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
category: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
is_graded: Optional[bool] = False
|
|
||||||
# 更新:
|
|
||||||
grading_company: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
grading_score: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
tail_number: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
size_type: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
version: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
platform: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
seller: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
buyer: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
class DealInfoUpdate(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
title: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
content: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
deal_price: Optional[float] = None
|
|
||||||
# 更新:
|
|
||||||
deal_date: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
packaging: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
category: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
is_graded: Optional[bool] = None
|
|
||||||
# 更新:
|
|
||||||
grading_company: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
grading_score: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
tail_number: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
size_type: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
version: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
platform: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
seller: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
buyer: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
status: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
class DealInfoResponse(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
id: str
|
|
||||||
# 更新:
|
|
||||||
user_id: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
title: str
|
|
||||||
# 更新:
|
|
||||||
content: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
deal_price: Optional[float]
|
|
||||||
# 更新:
|
|
||||||
deal_date: Optional[date]
|
|
||||||
# 更新:
|
|
||||||
deal_no: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
packaging: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
category: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
is_graded: Optional[bool]
|
|
||||||
# 更新:
|
|
||||||
grading_company: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
grading_score: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
tail_number: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
size_type: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
version: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
platform: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
seller: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
buyer: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
status: str
|
|
||||||
# 更新:
|
|
||||||
view_count: int
|
|
||||||
# 更新:
|
|
||||||
contact_count: int
|
|
||||||
# 更新:
|
|
||||||
created_at: Optional[datetime]
|
|
||||||
# 更新:
|
|
||||||
updated_at: Optional[datetime]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
class Config:
|
|
||||||
# 更新:
|
|
||||||
from_attributes = True
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 生成行情编号
|
|
||||||
# 更新:
|
|
||||||
def generate_deal_no(db: Session):
|
|
||||||
# 更新:
|
|
||||||
"""生成行情编号,从A000001开始递增"""
|
|
||||||
# 更新:
|
|
||||||
last = db.query(DealInfo).order_by(DealInfo.deal_no.desc()).first()
|
|
||||||
# 更新:
|
|
||||||
if last and last.deal_no:
|
|
||||||
# 更新:
|
|
||||||
# 例如 A000001 -> 2 -> A000002
|
|
||||||
# 更新:
|
|
||||||
num = int(last.deal_no[1:]) + 1
|
|
||||||
# 更新:
|
|
||||||
return f"A{num:06d}"
|
|
||||||
# 更新:
|
|
||||||
return "A000001"
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ API ============
|
|
||||||
# 更新:
|
|
||||||
@router.get("/list", response_model=list[DealInfoResponse])
|
|
||||||
# 更新:
|
|
||||||
def get_deal_list(
|
|
||||||
# 更新:
|
|
||||||
status: str = Query("active"),
|
|
||||||
# 更新:
|
|
||||||
deal_date: Optional[str] = Query(None),
|
|
||||||
# 更新:
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
# 更新:
|
|
||||||
page_size: int = Query(20, ge=1, le=1000),
|
|
||||||
# 更新:
|
|
||||||
user_only: bool = Query(False), # 是否只查看自己的
|
|
||||||
# 更新:
|
|
||||||
current_user: Optional = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""获取成交行情列表"""
|
|
||||||
# 更新:
|
|
||||||
query = db.query(DealInfo).filter(DealInfo.status == status)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 我的行情:只查看自己的(管理员也只看自己的)
|
|
||||||
# 更新:
|
|
||||||
if user_only and current_user:
|
|
||||||
# 更新:
|
|
||||||
query = query.filter(DealInfo.user_id == current_user.f99_90_id)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 成交日期过滤
|
|
||||||
# 更新:
|
|
||||||
if deal_date:
|
|
||||||
# 更新:
|
|
||||||
query = query.filter(DealInfo.deal_date == deal_date)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 排序:优先成交日期倒序,同日按编号倒序
|
|
||||||
# 更新:
|
|
||||||
query = query.order_by(DealInfo.deal_date.desc().nullslast(), DealInfo.deal_no.desc().nullslast())
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 分页
|
|
||||||
# 更新:
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
# 更新:
|
|
||||||
items = query.offset(offset).limit(page_size).all()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return items
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/stats")
|
|
||||||
# 更新:
|
|
||||||
def get_deal_stats(
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""获取成交行情统计"""
|
|
||||||
# 更新:
|
|
||||||
total = db.query(DealInfo).filter(DealInfo.status == "active").count()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 按日期统计
|
|
||||||
# 更新:
|
|
||||||
from sqlalchemy import func
|
|
||||||
# 更新:
|
|
||||||
date_stats = db.query(
|
|
||||||
# 更新:
|
|
||||||
DealInfo.deal_date,
|
|
||||||
# 更新:
|
|
||||||
func.count(DealInfo.id).label('count')
|
|
||||||
# 更新:
|
|
||||||
).filter(
|
|
||||||
# 更新:
|
|
||||||
DealInfo.status == "active",
|
|
||||||
# 更新:
|
|
||||||
DealInfo.deal_date.isnot(None)
|
|
||||||
# 更新:
|
|
||||||
).group_by(DealInfo.deal_date).order_by(DealInfo.deal_date.desc()).limit(10).all()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {
|
|
||||||
# 更新:
|
|
||||||
"total": total,
|
|
||||||
# 更新:
|
|
||||||
"by_date": [{"date": str(d.deal_date), "count": d.count} for d in date_stats]
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("", response_model=DealInfoResponse)
|
|
||||||
# 更新:
|
|
||||||
def create_deal(
|
|
||||||
# 更新:
|
|
||||||
data: DealInfoCreate,
|
|
||||||
# 更新:
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""创建成交行情"""
|
|
||||||
# 更新:
|
|
||||||
if not current_user:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=401, detail="请先登录")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 生成行情编号
|
|
||||||
# 更新:
|
|
||||||
deal_no = generate_deal_no(db)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 解析日期
|
|
||||||
# 更新:
|
|
||||||
deal_date = None
|
|
||||||
# 更新:
|
|
||||||
if data.deal_date:
|
|
||||||
# 更新:
|
|
||||||
try:
|
|
||||||
# 更新:
|
|
||||||
deal_date = datetime.strptime(data.deal_date, "%Y-%m-%d").date()
|
|
||||||
# 更新:
|
|
||||||
except:
|
|
||||||
# 更新:
|
|
||||||
pass
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
deal = DealInfo(
|
|
||||||
# 更新:
|
|
||||||
user_id=current_user.f99_90_id if current_user else None,
|
|
||||||
# 更新:
|
|
||||||
title=data.title,
|
|
||||||
# 更新:
|
|
||||||
content=data.content,
|
|
||||||
# 更新:
|
|
||||||
deal_price=data.deal_price,
|
|
||||||
# 更新:
|
|
||||||
deal_date=deal_date,
|
|
||||||
# 更新:
|
|
||||||
deal_no=deal_no,
|
|
||||||
# 更新:
|
|
||||||
packaging=data.packaging,
|
|
||||||
# 更新:
|
|
||||||
category=data.category,
|
|
||||||
# 更新:
|
|
||||||
is_graded=data.is_graded or False,
|
|
||||||
# 更新:
|
|
||||||
grading_company=data.grading_company,
|
|
||||||
# 更新:
|
|
||||||
grading_score=data.grading_score,
|
|
||||||
# 更新:
|
|
||||||
tail_number=data.tail_number,
|
|
||||||
# 更新:
|
|
||||||
size_type=data.size_type,
|
|
||||||
# 更新:
|
|
||||||
version=data.version,
|
|
||||||
# 更新:
|
|
||||||
platform=data.platform,
|
|
||||||
# 更新:
|
|
||||||
seller=data.seller,
|
|
||||||
# 更新:
|
|
||||||
buyer=data.buyer,
|
|
||||||
# 更新:
|
|
||||||
status="active"
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
db.add(deal)
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
db.refresh(deal)
|
|
||||||
# 更新:
|
|
||||||
return deal
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/{deal_id}", response_model=DealInfoResponse)
|
|
||||||
# 更新:
|
|
||||||
def get_deal(
|
|
||||||
# 更新:
|
|
||||||
deal_id: str,
|
|
||||||
# 更新:
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""获取成交行情详情"""
|
|
||||||
# 更新:
|
|
||||||
deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first()
|
|
||||||
# 更新:
|
|
||||||
if not deal:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=404, detail="成交行情不存在")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 增加浏览数
|
|
||||||
# 更新:
|
|
||||||
deal.view_count += 1
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return deal
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.put("/{deal_id}", response_model=DealInfoResponse)
|
|
||||||
# 更新:
|
|
||||||
def update_deal(
|
|
||||||
# 更新:
|
|
||||||
deal_id: str,
|
|
||||||
# 更新:
|
|
||||||
data: DealInfoUpdate,
|
|
||||||
# 更新:
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""更新成交行情"""
|
|
||||||
# 更新:
|
|
||||||
if not current_user:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=401, detail="请先登录")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first()
|
|
||||||
# 更新:
|
|
||||||
if not deal:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=404, detail="成交行情不存在")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 处理日期
|
|
||||||
# 更新:
|
|
||||||
if data.deal_date:
|
|
||||||
# 更新:
|
|
||||||
try:
|
|
||||||
# 更新:
|
|
||||||
data.deal_date = datetime.strptime(data.deal_date, "%Y-%m-%d").date()
|
|
||||||
# 更新:
|
|
||||||
except:
|
|
||||||
# 更新:
|
|
||||||
data.deal_date = None
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
for key, value in data.model_dump(exclude_unset=True).items():
|
|
||||||
# 更新:
|
|
||||||
setattr(deal, key, value)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
db.refresh(deal)
|
|
||||||
# 更新:
|
|
||||||
return deal
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.delete("/{deal_id}")
|
|
||||||
# 更新:
|
|
||||||
def delete_deal(
|
|
||||||
# 更新:
|
|
||||||
deal_id: str,
|
|
||||||
# 更新:
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""删除成交行情"""
|
|
||||||
# 更新:
|
|
||||||
if not current_user:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=401, detail="请先登录")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
deal = db.query(DealInfo).filter(DealInfo.id == deal_id).first()
|
|
||||||
# 更新:
|
|
||||||
if not deal:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=404, detail="成交行情不存在")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
deal.status = "deleted"
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {"message": "删除成功"}
|
|
||||||
# 更新:
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
# news - 新闻路由
|
|
||||||
# Version: 1.2.80
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
# 更新:
|
|
||||||
# Version: 1.2.x
|
|
||||||
# 更新:
|
|
||||||
from sqlalchemy import Table, MetaData
|
|
||||||
# 更新:
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
# 更新:
|
|
||||||
from pydantic import BaseModel
|
|
||||||
# 更新:
|
|
||||||
from typing import Optional, List
|
|
||||||
# 更新:
|
|
||||||
from datetime import datetime, date
|
|
||||||
# 更新:
|
|
||||||
from app.core.database import get_db, engine
|
|
||||||
# 更新:
|
|
||||||
from app.models.models import User
|
|
||||||
# 更新:
|
|
||||||
from app.routers.auth import get_current_user
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
router = APIRouter(prefix="/api/news", tags=["资讯"])
|
|
||||||
# 更新:
|
|
||||||
metadata = MetaData()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 分类表
|
|
||||||
# 更新:
|
|
||||||
categories_table = Table('news_categories', metadata, autoload_with=engine)
|
|
||||||
# 更新:
|
|
||||||
news_table = Table('news', metadata, autoload_with=engine)
|
|
||||||
# 更新:
|
|
||||||
user_posts_table = Table('user_posts', metadata, autoload_with=engine)
|
|
||||||
# 更新:
|
|
||||||
users_table = Table('users', metadata, autoload_with=engine)
|
|
||||||
# 更新:
|
|
||||||
deals_table = Table('deals', metadata, autoload_with=engine)
|
|
||||||
# 更新:
|
|
||||||
notifications_table = Table('notifications', metadata, autoload_with=engine)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ 获取分类 ============
|
|
||||||
# 更新:
|
|
||||||
@router.get("/categories")
|
|
||||||
# 更新:
|
|
||||||
def get_categories(db: Session = Depends(get_db)):
|
|
||||||
# 更新:
|
|
||||||
results = db.query(categories_table).order_by(categories_table.c.sort_order).all()
|
|
||||||
# 更新:
|
|
||||||
return [dict(r._mapping) for r in results]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ 获取资讯 ============
|
|
||||||
# 更新:
|
|
||||||
@router.get("")
|
|
||||||
# 更新:
|
|
||||||
def get_news(
|
|
||||||
# 更新:
|
|
||||||
category_id: Optional[int] = None,
|
|
||||||
# 更新:
|
|
||||||
page: int = 1,
|
|
||||||
# 更新:
|
|
||||||
limit: int = 20,
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
query = db.query(news_table)
|
|
||||||
# 更新:
|
|
||||||
if category_id:
|
|
||||||
# 更新:
|
|
||||||
query = query.filter(news_table.c.category_id == category_id)
|
|
||||||
# 更新:
|
|
||||||
offset = (page - 1) * limit
|
|
||||||
# 更新:
|
|
||||||
results = query.order_by(news_table.c.created_at.desc()).offset(offset).limit(limit).all()
|
|
||||||
# 更新:
|
|
||||||
return [dict(r._mapping) for r in results]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ 获取用户发布 ============
|
|
||||||
# 更新:
|
|
||||||
@router.get("/posts")
|
|
||||||
# 更新:
|
|
||||||
def get_posts(
|
|
||||||
# 更新:
|
|
||||||
post_type: Optional[str] = None,
|
|
||||||
# 更新:
|
|
||||||
status: str = "active",
|
|
||||||
# 更新:
|
|
||||||
page: int = 1,
|
|
||||||
# 更新:
|
|
||||||
limit: int = 20,
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
query = db.query(user_posts_table).filter(user_posts_table.c.status == status)
|
|
||||||
# 更新:
|
|
||||||
if post_type:
|
|
||||||
# 更新:
|
|
||||||
query = query.filter(user_posts_table.c.post_type == post_type)
|
|
||||||
# 更新:
|
|
||||||
offset = (page - 1) * limit
|
|
||||||
# 更新:
|
|
||||||
results = query.order_by(user_posts_table.c.created_at.desc()).offset(offset).limit(limit).all()
|
|
||||||
# 更新:
|
|
||||||
return [dict(r._mapping) for r in results]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ 创建发布 ============
|
|
||||||
# 更新:
|
|
||||||
class PostCreate(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
post_type: str
|
|
||||||
# 更新:
|
|
||||||
title: str
|
|
||||||
# 更新:
|
|
||||||
content: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
zodiac_type: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
packaging: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("/posts")
|
|
||||||
# 更新:
|
|
||||||
def create_post(
|
|
||||||
# 更新:
|
|
||||||
post: PostCreate,
|
|
||||||
# 更新:
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
result = db.execute(user_posts_table.insert().values(
|
|
||||||
# 更新:
|
|
||||||
user_id=current_user.f99_90_id,
|
|
||||||
# 更新:
|
|
||||||
post_type=post.post_type,
|
|
||||||
# 更新:
|
|
||||||
title=post.title,
|
|
||||||
# 更新:
|
|
||||||
content=post.content,
|
|
||||||
# 更新:
|
|
||||||
zodiac_type=post.zodiac_type,
|
|
||||||
# 更新:
|
|
||||||
packaging=post.packaging,
|
|
||||||
# 更新:
|
|
||||||
status="pending"
|
|
||||||
# 更新:
|
|
||||||
))
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
return {"success": True, "id": result.inserted_primary_key[0]}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ 成交数据 ============
|
|
||||||
# 更新:
|
|
||||||
@router.get("/deals")
|
|
||||||
# 更新:
|
|
||||||
def get_deals(
|
|
||||||
# 更新:
|
|
||||||
zodiac_type: Optional[str] = None,
|
|
||||||
# 更新:
|
|
||||||
limit: int = 20,
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
query = db.query(deals_table)
|
|
||||||
# 更新:
|
|
||||||
if zodiac_type:
|
|
||||||
# 更新:
|
|
||||||
query = query.filter(deals_table.c.zodiac_type == zodiac_type)
|
|
||||||
# 更新:
|
|
||||||
results = query.order_by(deals_table.c.deal_date.desc()).limit(limit).all()
|
|
||||||
# 更新:
|
|
||||||
return [dict(r._mapping) for r in results]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ 通知 ============
|
|
||||||
# 更新:
|
|
||||||
@router.get("/notifications")
|
|
||||||
# 更新:
|
|
||||||
def get_notifications(limit: int = 10, db: Session = Depends(get_db)):
|
|
||||||
# 更新:
|
|
||||||
results = db.query(notifications_table).filter(
|
|
||||||
# 更新:
|
|
||||||
notifications_table.c.is_published == True
|
|
||||||
# 更新:
|
|
||||||
).order_by(notifications_table.c.created_at.desc()).limit(limit).all()
|
|
||||||
# 更新:
|
|
||||||
return [dict(r._mapping) for r in results]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ 首页数据 ============
|
|
||||||
# 更新:
|
|
||||||
@router.get("/home")
|
|
||||||
# 更新:
|
|
||||||
def get_home(db: Session = Depends(get_db)):
|
|
||||||
# 更新:
|
|
||||||
# 推荐发布
|
|
||||||
# 更新:
|
|
||||||
posts = db.query(user_posts_table).filter(
|
|
||||||
# 更新:
|
|
||||||
user_posts_table.c.status == "active"
|
|
||||||
# 更新:
|
|
||||||
).order_by(user_posts_table.c.created_at.desc()).limit(10).all()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 成交
|
|
||||||
# 更新:
|
|
||||||
deals = db.query(deals_table).order_by(
|
|
||||||
# 更新:
|
|
||||||
deals_table.c.deal_date.desc()
|
|
||||||
# 更新:
|
|
||||||
).limit(10).all()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 通知
|
|
||||||
# 更新:
|
|
||||||
notices = db.query(notifications_table).filter(
|
|
||||||
# 更新:
|
|
||||||
notifications_table.c.is_published == True
|
|
||||||
# 更新:
|
|
||||||
).order_by(notifications_table.c.created_at.desc()).limit(5).all()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {
|
|
||||||
# 更新:
|
|
||||||
"posts": [dict(p._mapping) for p in posts],
|
|
||||||
# 更新:
|
|
||||||
"deals": [dict(d._mapping) for d in deals],
|
|
||||||
# 更新:
|
|
||||||
"notices": [dict(n._mapping) for n in notices]
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
|
|
@ -1,770 +1,376 @@
|
||||||
# ocr - OCR识别路由
|
# OCR 识别路由 - 专业人民币生肖纪念钞鉴定
|
||||||
# Version: 1.2.75
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
import os
|
import os
|
||||||
# 更新:
|
|
||||||
import uuid
|
import uuid
|
||||||
# 更新:
|
|
||||||
import base64
|
import base64
|
||||||
# 更新:
|
|
||||||
import httpx
|
import httpx
|
||||||
# 更新:
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
# 更新:
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
# 更新:
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
# 更新:
|
|
||||||
from app.core.auth import get_current_user
|
from app.core.auth import get_current_user
|
||||||
# 更新:
|
|
||||||
from app.models.models import User
|
from app.models.models import User
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
router = APIRouter(prefix="/api/ocr", tags=["OCR 识别"])
|
router = APIRouter(prefix="/api/ocr", tags=["OCR 识别"])
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 阿里云 DashScope API 配置
|
# 阿里云 DashScope API 配置
|
||||||
# 更新:
|
|
||||||
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "sk-9389024a37da4f7bb455ac9a6b28776f")
|
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "sk-9389024a37da4f7bb455ac9a6b28776f")
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 阿里云 OSS 配置
|
# 阿里云 OSS 配置
|
||||||
# 更新:
|
|
||||||
OSS_CONFIG = {
|
OSS_CONFIG = {
|
||||||
# 更新:
|
|
||||||
"access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
|
"access_key_id": os.getenv("OSS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
|
||||||
# 更新:
|
|
||||||
"access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
|
"access_key_secret": os.getenv("OSS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
|
||||||
# 更新:
|
|
||||||
"bucket_name": "jiachenlong-oss",
|
"bucket_name": "jiachenlong-oss",
|
||||||
# 更新:
|
|
||||||
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
|
"endpoint": "oss-cn-hangzhou.aliyuncs.com",
|
||||||
# 更新:
|
|
||||||
"public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com"
|
"public_url": "https://jiachenlong-oss.oss-cn-hangzhou.aliyuncs.com"
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 临时上传目录(用于OCR识别本地备选)
|
# 临时上传目录(用于OCR识别本地备选)
|
||||||
# 更新:
|
|
||||||
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "temp")
|
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "temp")
|
||||||
# 更新:
|
|
||||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
def get_oss_path(file_type: str, user_id: str = None, collection_id: str = None, filename: str = None):
|
def get_oss_path(file_type: str, user_id: str = None, collection_id: str = None, filename: str = None):
|
||||||
# 更新:
|
|
||||||
"""生成OSS路径 - 按年/月/日分类"""
|
"""生成OSS路径 - 按年/月/日分类"""
|
||||||
# 更新:
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
# 更新:
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
# 更新:
|
|
||||||
year = now.strftime("%Y")
|
year = now.strftime("%Y")
|
||||||
# 更新:
|
|
||||||
month = now.strftime("%m")
|
month = now.strftime("%m")
|
||||||
# 更新:
|
|
||||||
day = now.strftime("%d")
|
day = now.strftime("%d")
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if file_type == "temp":
|
if file_type == "temp":
|
||||||
# 更新:
|
|
||||||
# 临时文件: temp/{year}/{month}/{day}/{uuid}.{ext}
|
# 临时文件: temp/{year}/{month}/{day}/{uuid}.{ext}
|
||||||
# 更新:
|
|
||||||
import uuid
|
import uuid
|
||||||
# 更新:
|
|
||||||
unique_id = str(uuid.uuid4())
|
unique_id = str(uuid.uuid4())
|
||||||
# 更新:
|
|
||||||
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
|
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
|
||||||
# 更新:
|
|
||||||
return f"temp/{year}/{month}/{day}/{unique_id}.{ext}", unique_id
|
return f"temp/{year}/{month}/{day}/{unique_id}.{ext}", unique_id
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
elif file_type == "collection":
|
elif file_type == "collection":
|
||||||
# 更新:
|
|
||||||
# 藏品文件: collections/{user_id}/{year}/{collection_id}/{filename}
|
# 藏品文件: collections/{user_id}/{year}/{collection_id}/{filename}
|
||||||
# 更新:
|
|
||||||
if not user_id or not collection_id:
|
if not user_id or not collection_id:
|
||||||
# 更新:
|
|
||||||
raise ValueError("user_id and collection_id required for collection")
|
raise ValueError("user_id and collection_id required for collection")
|
||||||
# 更新:
|
|
||||||
return f"collections/{user_id}/{year}/{collection_id}/{filename}"
|
return f"collections/{user_id}/{year}/{collection_id}/{filename}"
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
elif file_type == "avatar":
|
elif file_type == "avatar":
|
||||||
# 更新:
|
|
||||||
# 头像: avatars/{user_id}/avatar.{ext}
|
# 头像: avatars/{user_id}/avatar.{ext}
|
||||||
# 更新:
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
# 更新:
|
|
||||||
raise ValueError("user_id required for avatar")
|
raise ValueError("user_id required for avatar")
|
||||||
# 更新:
|
|
||||||
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
|
ext = filename.split('.')[-1] if filename and '.' in filename else 'jpg'
|
||||||
# 更新:
|
|
||||||
return f"avatars/{user_id}/avatar.{ext}"
|
return f"avatars/{user_id}/avatar.{ext}"
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return None
|
return None
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 上传图片到OSS - 使用服务层(带压缩)
|
# 上传图片到OSS - 使用服务层(带压缩)
|
||||||
# 更新:
|
|
||||||
from app.services.oss import upload_to_oss as oss_upload
|
from app.services.oss import upload_to_oss as oss_upload
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
def upload_to_oss(file_data, oss_key):
|
def upload_to_oss(file_data, oss_key):
|
||||||
# 更新:
|
|
||||||
"""上传文件到阿里云OSS(带自动压缩)"""
|
"""上传文件到阿里云OSS(带自动压缩)"""
|
||||||
# 更新:
|
|
||||||
return oss_upload(file_data, oss_key)
|
return oss_upload(file_data, oss_key)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 专业提示词
|
# 专业提示词
|
||||||
# 更新:
|
|
||||||
PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。
|
PROFESSIONAL_PROMPT = """你是一名专业的人民币生肖纪念钞鉴定专家。请严格按照以下步骤分析这张图片,并提取准确信息。
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
【识别流程】
|
【识别流程】
|
||||||
# 更新:
|
|
||||||
1. 判断类型是否评级钞:首先确认是否为裸钞还是评级钞(有封装盒和标签)
|
1. 判断类型是否评级钞:首先确认是否为裸钞还是评级钞(有封装盒和标签)
|
||||||
# 更新:
|
|
||||||
2. 验证纪念钞特征:对照生肖纪念钞特征进行确认
|
2. 验证纪念钞特征:对照生肖纪念钞特征进行确认
|
||||||
# 更新:
|
|
||||||
3. 验证评级类型:有'标十'字眼的为标十,有'百连'字眼的为标百,其他为单张
|
3. 验证评级类型:有'标十'字眼的为标十,有'百连'字眼的为标百,其他为单张
|
||||||
# 更新:
|
|
||||||
4. 提取信息:仔细阅读标签上的所有文字内容
|
4. 提取信息:仔细阅读标签上的所有文字内容
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
【版别格式要求】
|
【版别格式要求】
|
||||||
# 更新:
|
|
||||||
只需要:年份 + 属相,例如:
|
只需要:年份 + 属相,例如:
|
||||||
# 更新:
|
|
||||||
- 2024 龙
|
- 2024 龙
|
||||||
# 更新:
|
|
||||||
- 2025 蛇
|
- 2025 蛇
|
||||||
# 更新:
|
|
||||||
- 2026 马
|
- 2026 马
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
【输出要求】
|
【输出要求】
|
||||||
# 更新:
|
|
||||||
严格按照以下格式输出,每个字段必须填写具体值:
|
严格按照以下格式输出,每个字段必须填写具体值:
|
||||||
# 更新:
|
|
||||||
✅ 1 发行机构:中国人民银行
|
✅ 1 发行机构:中国人民银行
|
||||||
# 更新:
|
|
||||||
✅ 2 发行版别:2024 龙
|
✅ 2 发行版别:2024 龙
|
||||||
# 更新:
|
|
||||||
✅ 3 面额:贰拾圆
|
✅ 3 面额:贰拾圆
|
||||||
# 更新:
|
|
||||||
✅ 4 是否评级:是/否
|
✅ 4 是否评级:是/否
|
||||||
# 更新:
|
|
||||||
✅ 5 封装类型:裸钞/单张/标十/标百
|
✅ 5 封装类型:裸钞/单张/标十/标百
|
||||||
# 更新:
|
|
||||||
✅ 6 冠字序号:J0xxxxxxxx
|
✅ 6 冠字序号:J0xxxxxxxx
|
||||||
# 更新:
|
|
||||||
✅ 7 评级机构:ACG/PCGS/PMG
|
✅ 7 评级机构:ACG/PCGS/PMG
|
||||||
# 更新:
|
|
||||||
✅ 8 评级分数:67/68/69
|
✅ 8 评级分数:67/68/69
|
||||||
# 更新:
|
|
||||||
✅ 9 是否三星:是/否
|
✅ 9 是否三星:是/否
|
||||||
# 更新:
|
|
||||||
✅ 10 特殊标识:金山标/天马标/红绳版等
|
✅ 10 特殊标识:金山标/天马标/红绳版等
|
||||||
# 更新:
|
|
||||||
✅ 11 号码特征:金山号 2 张,天马号 3 张等
|
✅ 11 号码特征:金山号 2 张,天马号 3 张等
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
现在请仔细分析提供的图片,按上述格式输出结果。"""
|
现在请仔细分析提供的图片,按上述格式输出结果。"""
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("/recognize")
|
@router.post("/recognize")
|
||||||
# 更新:
|
|
||||||
async def recognize_image(
|
async def recognize_image(
|
||||||
# 更新:
|
|
||||||
image: UploadFile = File(...),
|
image: UploadFile = File(...),
|
||||||
# 更新:
|
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
# 更新:
|
|
||||||
):
|
):
|
||||||
# 更新:
|
|
||||||
"""OCR 图片识别 - 识别后自动保存图片到OSS临时目录"""
|
"""OCR 图片识别 - 识别后自动保存图片到OSS临时目录"""
|
||||||
# 更新:
|
|
||||||
try:
|
try:
|
||||||
# 更新:
|
|
||||||
# 读取图片数据
|
# 读取图片数据
|
||||||
# 更新:
|
|
||||||
image_data = await image.read()
|
image_data = await image.read()
|
||||||
# 更新:
|
|
||||||
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 生成OSS临时路径: temp/{year}/{month}/{day}/{uuid}.{ext}
|
# 生成OSS临时路径: temp/{year}/{month}/{day}/{uuid}.{ext}
|
||||||
# 更新:
|
|
||||||
oss_key, temp_id = get_oss_path("temp", filename=image.filename)
|
oss_key, temp_id = get_oss_path("temp", filename=image.filename)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 初始化temp_path为空
|
|
||||||
# 更新:
|
|
||||||
temp_path = None
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 上传到OSS
|
# 上传到OSS
|
||||||
# 更新:
|
|
||||||
try:
|
try:
|
||||||
# 更新:
|
|
||||||
image_url = upload_to_oss(image_data, oss_key)
|
image_url = upload_to_oss(image_data, oss_key)
|
||||||
# 更新:
|
|
||||||
except Exception as oss_err:
|
except Exception as oss_err:
|
||||||
# 更新:
|
|
||||||
# OSS失败时保存到本地作为备选
|
# OSS失败时保存到本地作为备选
|
||||||
# 更新:
|
|
||||||
temp_path = os.path.join(UPLOAD_DIR, oss_key.split('/')[-1])
|
temp_path = os.path.join(UPLOAD_DIR, oss_key.split('/')[-1])
|
||||||
# 更新:
|
|
||||||
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
|
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
|
||||||
# 更新:
|
|
||||||
with open(temp_path, 'wb') as f:
|
with open(temp_path, 'wb') as f:
|
||||||
# 更新:
|
|
||||||
f.write(image_data)
|
f.write(image_data)
|
||||||
# 更新:
|
|
||||||
image_url = f"/uploads/temp/{oss_key.split('/')[-1]}"
|
image_url = f"/uploads/temp/{oss_key.split('/')[-1]}"
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
headers = {
|
headers = {
|
||||||
# 更新:
|
|
||||||
"Authorization": f"Bearer {DASHSCOPE_API_KEY}",
|
"Authorization": f"Bearer {DASHSCOPE_API_KEY}",
|
||||||
# 更新:
|
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 阿里云 DashScope API 格式 (qwen-vl-plus 视觉模型)
|
# 阿里云 DashScope API 格式 (qwen-vl-plus 视觉模型)
|
||||||
# 更新:
|
|
||||||
payload = {
|
payload = {
|
||||||
# 更新:
|
|
||||||
"model": "qwen-vl-plus",
|
"model": "qwen-vl-plus",
|
||||||
# 更新:
|
|
||||||
"input": {
|
"input": {
|
||||||
# 更新:
|
|
||||||
"messages": [{
|
"messages": [{
|
||||||
# 更新:
|
|
||||||
"role": "user",
|
"role": "user",
|
||||||
# 更新:
|
|
||||||
"content": [
|
"content": [
|
||||||
# 更新:
|
|
||||||
{
|
{
|
||||||
# 更新:
|
|
||||||
"image": f"data:{image.content_type};base64,{image_base64}"
|
"image": f"data:{image.content_type};base64,{image_base64}"
|
||||||
# 更新:
|
|
||||||
},
|
},
|
||||||
# 更新:
|
|
||||||
{
|
{
|
||||||
# 更新:
|
|
||||||
"text": PROFESSIONAL_PROMPT
|
"text": PROFESSIONAL_PROMPT
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
]
|
]
|
||||||
# 更新:
|
|
||||||
}]
|
}]
|
||||||
# 更新:
|
|
||||||
},
|
},
|
||||||
# 更新:
|
|
||||||
"parameters": {
|
"parameters": {
|
||||||
# 更新:
|
|
||||||
"max_tokens": 1000
|
"max_tokens": 1000
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
# 更新:
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
# 更新:
|
|
||||||
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
|
"https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation",
|
||||||
# 更新:
|
|
||||||
json=payload,
|
json=payload,
|
||||||
# 更新:
|
|
||||||
headers=headers
|
headers=headers
|
||||||
# 更新:
|
|
||||||
)
|
)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if response.status_code != 200:
|
if response.status_code != 200:
|
||||||
# 更新:
|
|
||||||
# 识别失败,删除临时文件
|
# 识别失败,删除临时文件
|
||||||
# 更新:
|
|
||||||
if temp_path and os.path.exists(temp_path):
|
|
||||||
# 更新:
|
|
||||||
os.remove(temp_path)
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=500, detail=f"OCR API 调用失败:{response.text[:200]}")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
ocr_result = response.json()
|
|
||||||
# 更新:
|
|
||||||
text_content = ""
|
|
||||||
# 更新:
|
|
||||||
# 新版API返回格式
|
|
||||||
# 更新:
|
|
||||||
if "output" in ocr_result and "choices" in ocr_result["output"]:
|
|
||||||
# 更新:
|
|
||||||
choices = ocr_result["output"]["choices"]
|
|
||||||
# 更新:
|
|
||||||
if choices and len(choices) > 0:
|
|
||||||
# 更新:
|
|
||||||
content = choices[0].get("message", {}).get("content", [])
|
|
||||||
# 更新:
|
|
||||||
if content and len(content) > 0:
|
|
||||||
# 更新:
|
|
||||||
text_content = content[0].get("text", "")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
fields = extract_fields(text_content)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 更新用户AI识别次数
|
|
||||||
# 更新:
|
|
||||||
current_user.f99_95_ai_count = (current_user.f99_95_ai_count or 0) + 1
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 返回识别结果和临时图片路径
|
|
||||||
# 更新:
|
|
||||||
return {
|
|
||||||
# 更新:
|
|
||||||
"success": True,
|
|
||||||
# 更新:
|
|
||||||
"text": text_content,
|
|
||||||
# 更新:
|
|
||||||
"fields": fields,
|
|
||||||
# 更新:
|
|
||||||
"aiCount": current_user.f99_95_ai_count,
|
|
||||||
# 更新:
|
|
||||||
"temp_image": {
|
|
||||||
# 更新:
|
|
||||||
"id": temp_id,
|
|
||||||
# 更新:
|
|
||||||
"filename": oss_key.split('/')[-1],
|
|
||||||
# 更新:
|
|
||||||
"path": image_url,
|
|
||||||
# 更新:
|
|
||||||
"original_name": image.filename,
|
|
||||||
# 更新:
|
|
||||||
"is_oss": image_url.startswith("https://")
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
except Exception as e:
|
|
||||||
# 更新:
|
|
||||||
import traceback
|
|
||||||
# 更新:
|
|
||||||
error_detail = f"识别失败:{str(e)}\n{traceback.format_exc()}"
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=500, detail=error_detail)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
def extract_fields(text: str) -> dict:
|
|
||||||
# 更新:
|
|
||||||
"""从 OCR 文本中提取字段 - 直接返回 AI 识别结果"""
|
|
||||||
# 更新:
|
|
||||||
import re
|
|
||||||
# 更新:
|
|
||||||
fields = {}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 解析结构化输出
|
|
||||||
# 更新:
|
|
||||||
patterns = {
|
|
||||||
# 更新:
|
|
||||||
'issuer': r'✅.*?1.*?发行机构.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
# 更新:
|
|
||||||
'version': r'✅.*?2.*?发行版别.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
# 更新:
|
|
||||||
'denomination': r'✅.*?3.*?面额.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
# 更新:
|
|
||||||
'is_graded_text': r'✅.*?4.*?是否评级.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
# 更新:
|
|
||||||
'packaging': r'✅.*?5.*?封装类型.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
# 更新:
|
|
||||||
'prefix_serial': r'✅.*?6.*?冠字序号.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
# 更新:
|
|
||||||
'grading_company': r'✅.*?7.*?评级机构.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
# 更新:
|
|
||||||
'grading_score': r'✅.*?8.*?评级分数.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
# 更新:
|
|
||||||
'three_star_text': r'✅.*?9.*?是否三星.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
# 更新:
|
|
||||||
'special_mark': r'✅.*?10.*?特殊标识.*?[::]\s*(.+?)(?:\n|$)',
|
|
||||||
# 更新:
|
|
||||||
'serial_feature': r'✅.*?11.*?号码特征.*?[::]\s*(.+?)(?:\n|$)'
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
for field, pattern in patterns.items():
|
|
||||||
# 更新:
|
|
||||||
match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
|
|
||||||
# 更新:
|
|
||||||
if match:
|
|
||||||
# 更新:
|
|
||||||
value = match.group(1).strip()
|
|
||||||
# 更新:
|
|
||||||
# 保留所有值,包括"无"和"未识别",让前端处理
|
|
||||||
# 更新:
|
|
||||||
fields[field] = value
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 处理是否评级
|
|
||||||
# 更新:
|
|
||||||
if 'is_graded_text' in fields:
|
|
||||||
# 更新:
|
|
||||||
fields['is_graded'] = '是' in fields.pop('is_graded_text')
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 处理是否三星
|
|
||||||
# 更新:
|
|
||||||
if 'three_star_text' in fields:
|
|
||||||
# 更新:
|
|
||||||
fields['three_star'] = '是' in fields.pop('three_star_text')
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 简化版别字段(2024 龙年贺岁纪念钞(标十) → 2024 龙)
|
|
||||||
# 更新:
|
|
||||||
if 'version' in fields:
|
|
||||||
# 更新:
|
|
||||||
version = fields['version']
|
|
||||||
# 更新:
|
|
||||||
# 提取年份和生肖
|
|
||||||
# 更新:
|
|
||||||
year_match = re.search(r'(20\d{2})', version)
|
|
||||||
# 更新:
|
|
||||||
animal = ''
|
|
||||||
# 更新:
|
|
||||||
if '龙' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '龙'
|
|
||||||
# 更新:
|
|
||||||
elif '蛇' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '蛇'
|
|
||||||
# 更新:
|
|
||||||
elif '马' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '马'
|
|
||||||
# 更新:
|
|
||||||
elif '羊' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '羊'
|
|
||||||
# 更新:
|
|
||||||
elif '猴' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '猴'
|
|
||||||
# 更新:
|
|
||||||
elif '鸡' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '鸡'
|
|
||||||
# 更新:
|
|
||||||
elif '狗' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '狗'
|
|
||||||
# 更新:
|
|
||||||
elif '猪' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '猪'
|
|
||||||
# 更新:
|
|
||||||
elif '鼠' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '鼠'
|
|
||||||
# 更新:
|
|
||||||
elif '牛' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '牛'
|
|
||||||
# 更新:
|
|
||||||
elif '虎' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '虎'
|
|
||||||
# 更新:
|
|
||||||
elif '兔' in version:
|
|
||||||
# 更新:
|
|
||||||
animal = '兔'
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if year_match and animal:
|
|
||||||
# 更新:
|
|
||||||
fields['version'] = f"{year_match.group(1)}{animal}"
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return fields
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("/claim-temp-image")
|
|
||||||
# 更新:
|
|
||||||
async def claim_temp_image(
|
|
||||||
# 更新:
|
|
||||||
temp_id: str,
|
|
||||||
# 更新:
|
|
||||||
collection_id: str,
|
|
||||||
# 更新:
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""将临时图片移动到正式藏品目录 - 按用户/年/藏品ID分类"""
|
|
||||||
# 更新:
|
|
||||||
from app.models.models import Collection, CollectionImage
|
|
||||||
# 更新:
|
|
||||||
from datetime import datetime
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 验证藏品是否存在
|
|
||||||
# 更新:
|
|
||||||
collection = db.query(Collection).filter(
|
|
||||||
# 更新:
|
|
||||||
Collection.f99_90_id == collection_id,
|
|
||||||
# 更新:
|
|
||||||
Collection.f99_91_user_id == current_user.f99_90_id
|
|
||||||
# 更新:
|
|
||||||
).first()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if not collection:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=404, detail="藏品不存在")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 生成正式文件名和OSS路径: collections/{user_id}/{year}/{collection_id}/{filename}
|
|
||||||
# 更新:
|
|
||||||
code = collection.f01_02_code or "0000"
|
|
||||||
# 更新:
|
|
||||||
prefix = collection.f02_10_prefix_serial or ""
|
|
||||||
# 更新:
|
|
||||||
username = current_user.f01_01_name
|
|
||||||
# 更新:
|
|
||||||
import time
|
|
||||||
# 更新:
|
|
||||||
final_filename = f"{username}-{code}-{prefix}-{int(time.time())}.jpg"
|
|
||||||
# 更新:
|
|
||||||
oss_key = get_oss_path("collection", user_id=current_user.f99_90_id, collection_id=collection_id, filename=final_filename)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径
|
|
||||||
# 更新:
|
|
||||||
temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF']
|
|
||||||
# 更新:
|
|
||||||
temp_content = None
|
|
||||||
# 更新:
|
|
||||||
found_key = None
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 尝试最近7天的路径
|
|
||||||
# 更新:
|
|
||||||
from datetime import timedelta
|
|
||||||
# 更新:
|
|
||||||
for i in range(7):
|
|
||||||
# 更新:
|
|
||||||
date = datetime.now() - timedelta(days=i)
|
|
||||||
# 更新:
|
|
||||||
year = date.strftime("%Y")
|
|
||||||
# 更新:
|
|
||||||
month = date.strftime("%m")
|
|
||||||
# 更新:
|
|
||||||
day = date.strftime("%d")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
for ext in temp_extensions:
|
|
||||||
# 更新:
|
|
||||||
try:
|
|
||||||
# 更新:
|
|
||||||
temp_oss_key = f"temp/{year}/{month}/{day}/{temp_id}.{ext}"
|
|
||||||
# 更新:
|
|
||||||
import oss2
|
|
||||||
# 更新:
|
|
||||||
auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"])
|
|
||||||
# 更新:
|
|
||||||
bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"])
|
|
||||||
# 更新:
|
|
||||||
temp_content = bucket.get_object(temp_oss_key).read()
|
|
||||||
# 更新:
|
|
||||||
found_key = temp_oss_key
|
|
||||||
# 更新:
|
|
||||||
break
|
|
||||||
# 更新:
|
|
||||||
except:
|
|
||||||
# 更新:
|
|
||||||
continue
|
|
||||||
# 更新:
|
|
||||||
if temp_content:
|
|
||||||
# 更新:
|
|
||||||
break
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if temp_content:
|
|
||||||
# 更新:
|
|
||||||
# 上传到正式目录
|
|
||||||
# 更新:
|
|
||||||
bucket.put_object(oss_key, temp_content)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 删除临时图片
|
|
||||||
# 更新:
|
|
||||||
try:
|
|
||||||
# 更新:
|
|
||||||
bucket.delete_object(found_key)
|
|
||||||
# 更新:
|
|
||||||
except:
|
|
||||||
# 更新:
|
|
||||||
pass
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# OSS URL
|
|
||||||
# 更新:
|
|
||||||
image_path = f"{OSS_CONFIG['public_url']}/{oss_key}"
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
else:
|
|
||||||
# 更新:
|
|
||||||
# OSS失败,使用本地文件
|
|
||||||
# 更新:
|
|
||||||
temp_path = None
|
|
||||||
# 更新:
|
|
||||||
for ext in temp_extensions:
|
|
||||||
# 更新:
|
|
||||||
temp_path = os.path.join(UPLOAD_DIR, f"{temp_id}.{ext}")
|
|
||||||
# 更新:
|
|
||||||
if os.path.exists(temp_path):
|
if os.path.exists(temp_path):
|
||||||
# 更新:
|
os.remove(temp_path)
|
||||||
break
|
raise HTTPException(status_code=500, detail=f"OCR API 调用失败:{response.text[:200]}")
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
ocr_result = response.json()
|
||||||
if not temp_path or not os.path.exists(temp_path):
|
text_content = ""
|
||||||
# 更新:
|
# 新版API返回格式
|
||||||
raise HTTPException(status_code=404, detail="临时图片不存在或已过期")
|
if "output" in ocr_result and "choices" in ocr_result["output"]:
|
||||||
# 更新:
|
choices = ocr_result["output"]["choices"]
|
||||||
|
if choices and len(choices) > 0:
|
||||||
|
content = choices[0].get("message", {}).get("content", [])
|
||||||
|
if content and len(content) > 0:
|
||||||
|
text_content = content[0].get("text", "")
|
||||||
|
|
||||||
# 更新:
|
fields = extract_fields(text_content)
|
||||||
# 保存到本地
|
|
||||||
# 更新:
|
# 返回识别结果和临时图片路径
|
||||||
collection_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "collections")
|
return {
|
||||||
# 更新:
|
"success": True,
|
||||||
os.makedirs(collection_dir, exist_ok=True)
|
"text": text_content,
|
||||||
# 更新:
|
"fields": fields,
|
||||||
|
"temp_image": {
|
||||||
|
"id": temp_id,
|
||||||
|
"filename": oss_key.split('/')[-1],
|
||||||
|
"path": image_url,
|
||||||
|
"original_name": image.filename,
|
||||||
|
"is_oss": image_url.startswith("https://")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
error_detail = f"识别失败:{str(e)}\n{traceback.format_exc()}"
|
||||||
|
raise HTTPException(status_code=500, detail=error_detail)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_fields(text: str) -> dict:
|
||||||
|
"""从 OCR 文本中提取字段 - 直接返回 AI 识别结果"""
|
||||||
|
import re
|
||||||
|
fields = {}
|
||||||
|
|
||||||
|
# 解析结构化输出
|
||||||
|
patterns = {
|
||||||
|
'issuer': r'✅.*?1.*?发行机构.*?[::]\s*(.+?)(?:\n|$)',
|
||||||
|
'version': r'✅.*?2.*?发行版别.*?[::]\s*(.+?)(?:\n|$)',
|
||||||
|
'denomination': r'✅.*?3.*?面额.*?[::]\s*(.+?)(?:\n|$)',
|
||||||
|
'is_graded_text': r'✅.*?4.*?是否评级.*?[::]\s*(.+?)(?:\n|$)',
|
||||||
|
'packaging': r'✅.*?5.*?封装类型.*?[::]\s*(.+?)(?:\n|$)',
|
||||||
|
'prefix_serial': r'✅.*?6.*?冠字序号.*?[::]\s*(.+?)(?:\n|$)',
|
||||||
|
'grading_company': r'✅.*?7.*?评级机构.*?[::]\s*(.+?)(?:\n|$)',
|
||||||
|
'grading_score': r'✅.*?8.*?评级分数.*?[::]\s*(.+?)(?:\n|$)',
|
||||||
|
'three_star_text': r'✅.*?9.*?是否三星.*?[::]\s*(.+?)(?:\n|$)',
|
||||||
|
'special_mark': r'✅.*?10.*?特殊标识.*?[::]\s*(.+?)(?:\n|$)',
|
||||||
|
'serial_feature': r'✅.*?11.*?号码特征.*?[::]\s*(.+?)(?:\n|$)'
|
||||||
|
}
|
||||||
|
|
||||||
|
for field, pattern in patterns.items():
|
||||||
|
match = re.search(pattern, text, re.IGNORECASE | re.DOTALL)
|
||||||
|
if match:
|
||||||
|
value = match.group(1).strip()
|
||||||
|
# 保留所有值,包括"无"和"未识别",让前端处理
|
||||||
|
fields[field] = value
|
||||||
|
|
||||||
|
# 处理是否评级
|
||||||
|
if 'is_graded_text' in fields:
|
||||||
|
fields['is_graded'] = '是' in fields.pop('is_graded_text')
|
||||||
|
|
||||||
|
# 处理是否三星
|
||||||
|
if 'three_star_text' in fields:
|
||||||
|
fields['three_star'] = '是' in fields.pop('three_star_text')
|
||||||
|
|
||||||
|
# 简化版别字段(2024 龙年贺岁纪念钞(标十) → 2024 龙)
|
||||||
|
if 'version' in fields:
|
||||||
|
version = fields['version']
|
||||||
|
# 提取年份和生肖
|
||||||
|
year_match = re.search(r'(20\d{2})', version)
|
||||||
|
animal = ''
|
||||||
|
if '龙' in version:
|
||||||
|
animal = '龙'
|
||||||
|
elif '蛇' in version:
|
||||||
|
animal = '蛇'
|
||||||
|
elif '马' in version:
|
||||||
|
animal = '马'
|
||||||
|
elif '羊' in version:
|
||||||
|
animal = '羊'
|
||||||
|
elif '猴' in version:
|
||||||
|
animal = '猴'
|
||||||
|
elif '鸡' in version:
|
||||||
|
animal = '鸡'
|
||||||
|
elif '狗' in version:
|
||||||
|
animal = '狗'
|
||||||
|
elif '猪' in version:
|
||||||
|
animal = '猪'
|
||||||
|
elif '鼠' in version:
|
||||||
|
animal = '鼠'
|
||||||
|
elif '牛' in version:
|
||||||
|
animal = '牛'
|
||||||
|
elif '虎' in version:
|
||||||
|
animal = '虎'
|
||||||
|
elif '兔' in version:
|
||||||
|
animal = '兔'
|
||||||
|
|
||||||
|
if year_match and animal:
|
||||||
|
fields['version'] = f"{year_match.group(1)}{animal}"
|
||||||
|
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/claim-temp-image")
|
||||||
|
async def claim_temp_image(
|
||||||
|
temp_id: str,
|
||||||
|
collection_id: str,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""将临时图片移动到正式藏品目录 - 按用户/年/藏品ID分类"""
|
||||||
|
from app.models.models import Collection, CollectionImage
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 验证藏品是否存在
|
||||||
|
collection = db.query(Collection).filter(
|
||||||
|
Collection.f99_90_id == collection_id,
|
||||||
|
Collection.f99_91_user_id == current_user.f99_90_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not collection:
|
||||||
|
raise HTTPException(status_code=404, detail="藏品不存在")
|
||||||
|
|
||||||
|
# 生成正式文件名和OSS路径: collections/{user_id}/{year}/{collection_id}/{filename}
|
||||||
|
code = collection.f01_02_code or "0000"
|
||||||
|
prefix = collection.f02_10_prefix_serial or ""
|
||||||
|
username = current_user.f01_01_name
|
||||||
|
import time
|
||||||
|
final_filename = f"{username}-{code}-{prefix}-{int(time.time())}.jpg"
|
||||||
|
oss_key = get_oss_path("collection", user_id=current_user.f99_90_id, collection_id=collection_id, filename=final_filename)
|
||||||
|
|
||||||
|
# 尝试从OSS获取临时图片 - 尝试多种扩展名和日期路径
|
||||||
|
temp_extensions = ['jpg', 'jpeg', 'png', 'gif']
|
||||||
|
temp_content = None
|
||||||
|
found_key = None
|
||||||
|
|
||||||
|
# 尝试最近7天的路径
|
||||||
|
from datetime import timedelta
|
||||||
|
for i in range(7):
|
||||||
|
date = datetime.now() - timedelta(days=i)
|
||||||
|
year = date.strftime("%Y")
|
||||||
|
month = date.strftime("%m")
|
||||||
|
day = date.strftime("%d")
|
||||||
|
|
||||||
|
for ext in temp_extensions:
|
||||||
|
try:
|
||||||
|
temp_oss_key = f"temp/{year}/{month}/{day}/{temp_id}.{ext}"
|
||||||
|
import oss2
|
||||||
|
auth = oss2.Auth(OSS_CONFIG["access_key_id"], OSS_CONFIG["access_key_secret"])
|
||||||
|
bucket = oss2.Bucket(auth, OSS_CONFIG["endpoint"], OSS_CONFIG["bucket_name"])
|
||||||
|
temp_content = bucket.get_object(temp_oss_key).read()
|
||||||
|
found_key = temp_oss_key
|
||||||
|
break
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
if temp_content:
|
||||||
|
break
|
||||||
|
|
||||||
|
if temp_content:
|
||||||
|
# 上传到正式目录
|
||||||
|
bucket.put_object(oss_key, temp_content)
|
||||||
|
|
||||||
|
# 删除临时图片
|
||||||
|
try:
|
||||||
|
bucket.delete_object(found_key)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# OSS URL
|
||||||
|
image_path = f"{OSS_CONFIG['public_url']}/{oss_key}"
|
||||||
|
|
||||||
|
else:
|
||||||
|
# OSS失败,使用本地文件
|
||||||
|
temp_path = None
|
||||||
|
for ext in temp_extensions:
|
||||||
|
temp_path = os.path.join(UPLOAD_DIR, f"{temp_id}.{ext}")
|
||||||
|
if os.path.exists(temp_path):
|
||||||
|
break
|
||||||
|
|
||||||
|
if not temp_path or not os.path.exists(temp_path):
|
||||||
|
raise HTTPException(status_code=404, detail="临时图片不存在或已过期")
|
||||||
|
|
||||||
|
# 保存到本地
|
||||||
|
collection_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads", "collections")
|
||||||
|
os.makedirs(collection_dir, exist_ok=True)
|
||||||
|
|
||||||
|
new_path = os.path.join(collection_dir, final_filename)
|
||||||
|
import shutil
|
||||||
|
shutil.move(temp_path, new_path)
|
||||||
|
image_path = f"uploads/collections/{final_filename}"
|
||||||
|
|
||||||
|
# 创建图片记录
|
||||||
|
image_record = CollectionImage(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
collection_id=collection.f99_90_id,
|
||||||
|
filename=final_filename,
|
||||||
|
original_name=temp_id,
|
||||||
|
path=image_path
|
||||||
|
)
|
||||||
|
db.add(image_record)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
# 更新:
|
|
||||||
new_path = os.path.join(collection_dir, final_filename)
|
|
||||||
# 更新:
|
|
||||||
import shutil
|
|
||||||
# 更新:
|
|
||||||
shutil.move(temp_path, new_path)
|
|
||||||
# 更新:
|
|
||||||
image_path = f"uploads/collections/{final_filename}"
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 创建图片记录
|
|
||||||
# 更新:
|
|
||||||
image_record = CollectionImage(
|
|
||||||
# 更新:
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
# 更新:
|
|
||||||
collection_id=collection.f99_90_id,
|
|
||||||
# 更新:
|
|
||||||
filename=final_filename,
|
|
||||||
# 更新:
|
|
||||||
original_name=temp_id,
|
|
||||||
# 更新:
|
|
||||||
path=image_path
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
db.add(image_record)
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {
|
return {
|
||||||
# 更新:
|
|
||||||
"success": True,
|
"success": True,
|
||||||
# 更新:
|
|
||||||
"image": {
|
"image": {
|
||||||
# 更新:
|
|
||||||
"id": image_record.id,
|
"id": image_record.id,
|
||||||
# 更新:
|
|
||||||
"filename": image_record.filename,
|
"filename": image_record.filename,
|
||||||
# 更新:
|
|
||||||
"path": image_record.path
|
"path": image_record.path
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
|
|
|
||||||
|
|
@ -1,210 +1,104 @@
|
||||||
# operations - 运营操作路由
|
# 操作路由
|
||||||
# Version: 1.2.70
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
# 更新:
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||||
# 更新:
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
# 更新:
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
# 更新:
|
|
||||||
from app.core.auth import get_current_user
|
from app.core.auth import get_current_user
|
||||||
# 更新:
|
|
||||||
from app.models.models import User, Collection, Operation
|
from app.models.models import User, Collection, Operation
|
||||||
# 更新:
|
|
||||||
from app.schemas.schemas import OperationCreate, OperationResponse
|
from app.schemas.schemas import OperationCreate, OperationResponse
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
router = APIRouter(prefix="/api", tags=["操作"])
|
router = APIRouter(prefix="/api", tags=["操作"])
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/operations", response_model=List[OperationResponse])
|
@router.get("/operations", response_model=List[OperationResponse])
|
||||||
# 更新:
|
|
||||||
def get_operations(
|
def get_operations(
|
||||||
# 更新:
|
|
||||||
collection_id: Optional[str] = None,
|
collection_id: Optional[str] = None,
|
||||||
# 更新:
|
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
# 更新:
|
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
# 更新:
|
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
# 更新:
|
|
||||||
):
|
):
|
||||||
# 更新:
|
|
||||||
"""获取操作历史"""
|
"""获取操作历史"""
|
||||||
# 更新:
|
|
||||||
query = db.query(Operation).filter(Operation.user_id == current_user.id)
|
query = db.query(Operation).filter(Operation.user_id == current_user.id)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if collection_id:
|
if collection_id:
|
||||||
# 更新:
|
|
||||||
query = query.filter(Operation.collection_id == collection_id)
|
query = query.filter(Operation.collection_id == collection_id)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
operations = query.order_by(Operation.created_at.desc()) \
|
operations = query.order_by(Operation.created_at.desc()) \
|
||||||
# 更新:
|
|
||||||
.offset((page - 1) * limit) \
|
.offset((page - 1) * limit) \
|
||||||
# 更新:
|
|
||||||
.limit(limit) \
|
.limit(limit) \
|
||||||
# 更新:
|
|
||||||
.all()
|
.all()
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return operations
|
return operations
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/operations/history")
|
@router.get("/operations/history")
|
||||||
# 更新:
|
|
||||||
def get_operation_history(
|
def get_operation_history(
|
||||||
# 更新:
|
|
||||||
collection_id: Optional[str] = None,
|
collection_id: Optional[str] = None,
|
||||||
# 更新:
|
|
||||||
type: Optional[str] = None,
|
type: Optional[str] = None,
|
||||||
# 更新:
|
|
||||||
start_date: Optional[str] = None,
|
start_date: Optional[str] = None,
|
||||||
# 更新:
|
|
||||||
end_date: Optional[str] = None,
|
end_date: Optional[str] = None,
|
||||||
# 更新:
|
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
# 更新:
|
|
||||||
limit: int = Query(50, ge=1, le=100),
|
limit: int = Query(50, ge=1, le=100),
|
||||||
# 更新:
|
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
# 更新:
|
|
||||||
):
|
):
|
||||||
# 更新:
|
|
||||||
"""获取操作历史(带统计)"""
|
"""获取操作历史(带统计)"""
|
||||||
# 更新:
|
|
||||||
query = db.query(Operation).filter(Operation.user_id == current_user.id)
|
query = db.query(Operation).filter(Operation.user_id == current_user.id)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if collection_id:
|
if collection_id:
|
||||||
# 更新:
|
|
||||||
query = query.filter(Operation.collection_id == collection_id)
|
query = query.filter(Operation.collection_id == collection_id)
|
||||||
# 更新:
|
|
||||||
if type:
|
if type:
|
||||||
# 更新:
|
|
||||||
query = query.filter(Operation.type == type)
|
query = query.filter(Operation.type == type)
|
||||||
# 更新:
|
|
||||||
if start_date:
|
if start_date:
|
||||||
# 更新:
|
|
||||||
query = query.filter(Operation.created_at >= start_date)
|
query = query.filter(Operation.created_at >= start_date)
|
||||||
# 更新:
|
|
||||||
if end_date:
|
if end_date:
|
||||||
# 更新:
|
|
||||||
query = query.filter(Operation.created_at <= end_date)
|
query = query.filter(Operation.created_at <= end_date)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
total = query.count()
|
total = query.count()
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
data = query.order_by(Operation.created_at.desc()) \
|
data = query.order_by(Operation.created_at.desc()) \
|
||||||
# 更新:
|
|
||||||
.offset((page - 1) * limit) \
|
.offset((page - 1) * limit) \
|
||||||
# 更新:
|
|
||||||
.limit(limit) \
|
.limit(limit) \
|
||||||
# 更新:
|
|
||||||
.all()
|
.all()
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {
|
return {
|
||||||
# 更新:
|
|
||||||
"data": data,
|
"data": data,
|
||||||
# 更新:
|
|
||||||
"pagination": {
|
"pagination": {
|
||||||
# 更新:
|
|
||||||
"page": page,
|
"page": page,
|
||||||
# 更新:
|
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
# 更新:
|
|
||||||
"total": total,
|
"total": total,
|
||||||
# 更新:
|
|
||||||
"pages": (total + limit - 1) // limit
|
"pages": (total + limit - 1) // limit
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
}
|
}
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("/operations", response_model=OperationResponse)
|
@router.post("/operations", response_model=OperationResponse)
|
||||||
# 更新:
|
|
||||||
def create_operation(
|
def create_operation(
|
||||||
# 更新:
|
|
||||||
operation_data: OperationCreate,
|
operation_data: OperationCreate,
|
||||||
# 更新:
|
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
# 更新:
|
|
||||||
):
|
):
|
||||||
# 更新:
|
|
||||||
"""创建操作记录"""
|
"""创建操作记录"""
|
||||||
# 更新:
|
|
||||||
# 验证藏品存在
|
# 验证藏品存在
|
||||||
# 更新:
|
|
||||||
collection = db.query(Collection).filter(
|
collection = db.query(Collection).filter(
|
||||||
# 更新:
|
|
||||||
Collection.id == operation_data.collection_id,
|
Collection.id == operation_data.collection_id,
|
||||||
# 更新:
|
|
||||||
Collection.user_id == current_user.id
|
Collection.user_id == current_user.id
|
||||||
# 更新:
|
|
||||||
).first()
|
).first()
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if not collection:
|
if not collection:
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=404, detail="藏品不存在")
|
raise HTTPException(status_code=404, detail="藏品不存在")
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
operation = Operation(
|
operation = Operation(
|
||||||
# 更新:
|
|
||||||
collection_id=operation_data.collection_id,
|
collection_id=operation_data.collection_id,
|
||||||
# 更新:
|
|
||||||
user_id=current_user.id,
|
user_id=current_user.id,
|
||||||
# 更新:
|
|
||||||
type=operation_data.type,
|
type=operation_data.type,
|
||||||
# 更新:
|
|
||||||
price=operation_data.price,
|
price=operation_data.price,
|
||||||
# 更新:
|
|
||||||
note=operation_data.note
|
note=operation_data.note
|
||||||
# 更新:
|
|
||||||
)
|
)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
db.add(operation)
|
db.add(operation)
|
||||||
# 更新:
|
|
||||||
db.commit()
|
db.commit()
|
||||||
# 更新:
|
|
||||||
db.refresh(operation)
|
db.refresh(operation)
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return operation
|
return operation
|
||||||
# 更新:
|
|
||||||
|
|
|
||||||
|
|
@ -1,384 +0,0 @@
|
||||||
# seek - 寻号匹配路由
|
|
||||||
# Version: 1.2.70
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
|
||||||
# 更新:
|
|
||||||
# Version: 1.2.x
|
|
||||||
# 更新:
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
# 更新:
|
|
||||||
from pydantic import BaseModel
|
|
||||||
# 更新:
|
|
||||||
from typing import Optional
|
|
||||||
# 更新:
|
|
||||||
from datetime import datetime
|
|
||||||
# 更新:
|
|
||||||
from app.core.database import get_db
|
|
||||||
# 更新:
|
|
||||||
from app.core.auth import get_current_user
|
|
||||||
# 更新:
|
|
||||||
from app.models.seek_info import SeekInfo
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
router = APIRouter(prefix="/api/seek", tags=["寻配号"])
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ Schema ============
|
|
||||||
# 更新:
|
|
||||||
class SeekInfoCreate(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
title: str
|
|
||||||
# 更新:
|
|
||||||
content: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
expect_category: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
expect_version: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
expect_packaging: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
expect_number: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
expect_price_min: Optional[float] = None
|
|
||||||
# 更新:
|
|
||||||
expect_price_max: Optional[float] = None
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
class SeekInfoUpdate(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
title: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
content: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
expect_category: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
expect_version: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
expect_packaging: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
expect_number: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
expect_price_min: Optional[float] = None
|
|
||||||
# 更新:
|
|
||||||
expect_price_max: Optional[float] = None
|
|
||||||
# 更新:
|
|
||||||
status: Optional[str] = None
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
class SeekInfoResponse(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
id: str
|
|
||||||
# 更新:
|
|
||||||
user_id: str
|
|
||||||
# 更新:
|
|
||||||
title: str
|
|
||||||
# 更新:
|
|
||||||
content: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
expect_category: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
expect_version: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
expect_packaging: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
expect_number: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
expect_price_min: Optional[float]
|
|
||||||
# 更新:
|
|
||||||
expect_price_max: Optional[float]
|
|
||||||
# 更新:
|
|
||||||
status: str
|
|
||||||
# 更新:
|
|
||||||
is_matched: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
matched_user_id: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
matched_contact: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
view_count: int
|
|
||||||
# 更新:
|
|
||||||
contact_count: int
|
|
||||||
# 更新:
|
|
||||||
created_at: Optional[datetime]
|
|
||||||
# 更新:
|
|
||||||
updated_at: Optional[datetime]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
class Config:
|
|
||||||
# 更新:
|
|
||||||
from_attributes = True
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ API ============
|
|
||||||
# 更新:
|
|
||||||
@router.get("/list", response_model=list[SeekInfoResponse])
|
|
||||||
# 更新:
|
|
||||||
def get_seek_list(
|
|
||||||
# 更新:
|
|
||||||
status: str = Query("active"),
|
|
||||||
# 更新:
|
|
||||||
page: int = Query(1, ge=1),
|
|
||||||
# 更新:
|
|
||||||
page_size: int = Query(20, ge=1, le=1000),
|
|
||||||
# 更新:
|
|
||||||
user_only: bool = Query(False),
|
|
||||||
# 更新:
|
|
||||||
current_user: Optional = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""获取寻配号列表"""
|
|
||||||
# 更新:
|
|
||||||
query = db.query(SeekInfo).filter(SeekInfo.status == status)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 我的寻配号:只查看自己的
|
|
||||||
# 更新:
|
|
||||||
if user_only and current_user:
|
|
||||||
# 更新:
|
|
||||||
query = query.filter(SeekInfo.user_id == current_user.f99_90_id)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 排序
|
|
||||||
# 更新:
|
|
||||||
query = query.order_by(SeekInfo.created_at.desc())
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 分页
|
|
||||||
# 更新:
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
# 更新:
|
|
||||||
items = query.offset(offset).limit(page_size).all()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return items
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/stats")
|
|
||||||
# 更新:
|
|
||||||
def get_seek_stats(
|
|
||||||
# 更新:
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""获取寻配号统计"""
|
|
||||||
# 更新:
|
|
||||||
total = db.query(SeekInfo).filter(SeekInfo.status == "active").count()
|
|
||||||
# 更新:
|
|
||||||
matched = db.query(SeekInfo).filter(SeekInfo.is_matched == "true").count()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {
|
|
||||||
# 更新:
|
|
||||||
"total": total,
|
|
||||||
# 更新:
|
|
||||||
"matched": matched,
|
|
||||||
# 更新:
|
|
||||||
"unmatched": total - matched
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.post("", response_model=SeekInfoResponse)
|
|
||||||
# 更新:
|
|
||||||
def create_seek(
|
|
||||||
# 更新:
|
|
||||||
data: SeekInfoCreate,
|
|
||||||
# 更新:
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""创建寻配号"""
|
|
||||||
# 更新:
|
|
||||||
if not current_user:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=401, detail="请先登录")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
seek = SeekInfo(
|
|
||||||
# 更新:
|
|
||||||
user_id=current_user.f99_90_id,
|
|
||||||
# 更新:
|
|
||||||
title=data.title,
|
|
||||||
# 更新:
|
|
||||||
content=data.content,
|
|
||||||
# 更新:
|
|
||||||
expect_category=data.expect_category,
|
|
||||||
# 更新:
|
|
||||||
expect_version=data.expect_version,
|
|
||||||
# 更新:
|
|
||||||
expect_packaging=data.expect_packaging,
|
|
||||||
# 更新:
|
|
||||||
expect_number=data.expect_number,
|
|
||||||
# 更新:
|
|
||||||
expect_price_min=data.expect_price_min,
|
|
||||||
# 更新:
|
|
||||||
expect_price_max=data.expect_price_max,
|
|
||||||
# 更新:
|
|
||||||
status="active"
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
db.add(seek)
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
db.refresh(seek)
|
|
||||||
# 更新:
|
|
||||||
return seek
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/{seek_id}", response_model=SeekInfoResponse)
|
|
||||||
# 更新:
|
|
||||||
def get_seek(
|
|
||||||
# 更新:
|
|
||||||
seek_id: str,
|
|
||||||
# 更新:
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""获取寻配号详情"""
|
|
||||||
# 更新:
|
|
||||||
seek = db.query(SeekInfo).filter(SeekInfo.id == seek_id).first()
|
|
||||||
# 更新:
|
|
||||||
if not seek:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=404, detail="寻配号不存在")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 增加浏览数
|
|
||||||
# 更新:
|
|
||||||
seek.view_count += 1
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return seek
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.put("/{seek_id}", response_model=SeekInfoResponse)
|
|
||||||
# 更新:
|
|
||||||
def update_seek(
|
|
||||||
# 更新:
|
|
||||||
seek_id: str,
|
|
||||||
# 更新:
|
|
||||||
data: SeekInfoUpdate,
|
|
||||||
# 更新:
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""更新寻配号"""
|
|
||||||
# 更新:
|
|
||||||
if not current_user:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=401, detail="请先登录")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
seek = db.query(SeekInfo).filter(
|
|
||||||
# 更新:
|
|
||||||
SeekInfo.id == seek_id,
|
|
||||||
# 更新:
|
|
||||||
SeekInfo.user_id == current_user.f99_90_id
|
|
||||||
# 更新:
|
|
||||||
).first()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if not seek:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=404, detail="寻配号不存在")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
for key, value in data.model_dump(exclude_unset=True).items():
|
|
||||||
# 更新:
|
|
||||||
setattr(seek, key, value)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
db.refresh(seek)
|
|
||||||
# 更新:
|
|
||||||
return seek
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.delete("/{seek_id}")
|
|
||||||
# 更新:
|
|
||||||
def delete_seek(
|
|
||||||
# 更新:
|
|
||||||
seek_id: str,
|
|
||||||
# 更新:
|
|
||||||
current_user = Depends(get_current_user),
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""删除寻配号"""
|
|
||||||
# 更新:
|
|
||||||
if not current_user:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=401, detail="请先登录")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
seek = db.query(SeekInfo).filter(
|
|
||||||
# 更新:
|
|
||||||
SeekInfo.id == seek_id,
|
|
||||||
# 更新:
|
|
||||||
SeekInfo.user_id == current_user.f99_90_id
|
|
||||||
# 更新:
|
|
||||||
).first()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if not seek:
|
|
||||||
# 更新:
|
|
||||||
raise HTTPException(status_code=404, detail="寻配号不存在")
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
seek.status = "deleted"
|
|
||||||
# 更新:
|
|
||||||
db.commit()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {"message": "删除成功"}
|
|
||||||
# 更新:
|
|
||||||
|
|
@ -1,7 +1,4 @@
|
||||||
# users.py - 用户管理路由
|
# 用户管理路由
|
||||||
# Version: 1.2.98 (2026-04-19)
|
|
||||||
# 更新:新增 dealCount 字段,从 Information 表统计用户发布的行情数量
|
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
@ -14,14 +11,12 @@ router = APIRouter(prefix="/api", tags=["用户"])
|
||||||
|
|
||||||
# ============ 当前用户接口 ============
|
# ============ 当前用户接口 ============
|
||||||
|
|
||||||
@router.get("/users/me") # 无 response_model,避免 Pydantic 序列化问题
|
@router.get("/users/me", response_model=UserResponse)
|
||||||
def get_current_user_info(
|
def get_current_user_info(
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""获取当前登录用户信息"""
|
"""获取当前登录用户信息"""
|
||||||
return {
|
return {
|
||||||
"id": current_user.f99_90_id,
|
|
||||||
"username": current_user.f01_01_name,
|
|
||||||
"f99_90_id": current_user.f99_90_id,
|
"f99_90_id": current_user.f99_90_id,
|
||||||
"f01_01_name": current_user.f01_01_name,
|
"f01_01_name": current_user.f01_01_name,
|
||||||
"email": current_user.email,
|
"email": current_user.email,
|
||||||
|
|
@ -30,27 +25,11 @@ def get_current_user_info(
|
||||||
"address": current_user.address,
|
"address": current_user.address,
|
||||||
"bio": current_user.bio,
|
"bio": current_user.bio,
|
||||||
"role": current_user.role,
|
"role": current_user.role,
|
||||||
"level": current_user.f99_94_level,
|
|
||||||
"aiCount": current_user.f99_95_ai_count or 0,
|
|
||||||
"searchCount": current_user.f99_96_search_count or 0,
|
|
||||||
"collectionCount": current_user.f99_97_collection_count or 0,
|
|
||||||
"phoneVerified": current_user.f01_06_phone_verified or False,
|
|
||||||
"loginCount": current_user.f99_98_login_count or 0,
|
|
||||||
"lastLogin": current_user.f99_99_last_login.isoformat() if current_user.f99_99_last_login else None,
|
|
||||||
"gender": current_user.f01_07_gender,
|
|
||||||
"birthday": current_user.f01_08_birthday.isoformat() if current_user.f01_08_birthday else None,
|
|
||||||
"region": current_user.f01_09_region,
|
|
||||||
"realnameVerified": current_user.f01_10_realname_verified or False,
|
|
||||||
"points": current_user.f99_100_points or 0,
|
|
||||||
"balance": float(current_user.f01_11_balance) if current_user.f01_11_balance else 0,
|
|
||||||
"totalAmount": float(current_user.f01_12_total_amount) if current_user.f01_12_total_amount else 0,
|
|
||||||
"inviteCode": current_user.f01_13_invite_code,
|
|
||||||
"user_code": current_user.user_code,
|
|
||||||
"f99_92_created_at": current_user.f99_92_created_at.isoformat() if current_user.f99_92_created_at else None,
|
"f99_92_created_at": current_user.f99_92_created_at.isoformat() if current_user.f99_92_created_at else None,
|
||||||
"f99_93_updated_at": current_user.f99_93_updated_at.isoformat() if current_user.f99_93_updated_at else None
|
"f99_93_updated_at": current_user.f99_93_updated_at.isoformat() if current_user.f99_93_updated_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
@router.put("/users/me") # 无 response_model,避免 Pydantic 序列化问题
|
@router.put("/users/me", response_model=UserResponse)
|
||||||
def update_current_user(
|
def update_current_user(
|
||||||
user_update: UserUpdate,
|
user_update: UserUpdate,
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
|
|
@ -100,46 +79,24 @@ def get_users(
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取用户列表(仅管理员)"""
|
"""获取用户列表(仅管理员)"""
|
||||||
if current_user.role not in ["admin", "editor"]:
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="无权访问")
|
raise HTTPException(status_code=403, detail="无权访问")
|
||||||
|
|
||||||
total = db.query(User).count()
|
total = db.query(User).count()
|
||||||
from sqlalchemy import case
|
users = db.query(User).offset((page-1)*limit).limit(limit).all()
|
||||||
users = db.query(User).order_by(
|
|
||||||
case(
|
|
||||||
(User.user_code == None, 1),
|
|
||||||
else_=0
|
|
||||||
),
|
|
||||||
User.user_code.asc()
|
|
||||||
).offset((page-1)*limit).limit(limit).all()
|
|
||||||
|
|
||||||
user_list = []
|
user_list = []
|
||||||
for u in users:
|
for u in users:
|
||||||
# 统计每个用户的藏品数量
|
# 统计每个用户的藏品数量
|
||||||
count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count()
|
count = db.query(Collection).filter(Collection.f99_91_user_id == u.f99_90_id).count()
|
||||||
# 统计每个用户发布的行情数量(info_type='deal')
|
|
||||||
deal_count = db.query(Information).filter(
|
|
||||||
Information.user_id == u.f99_90_id,
|
|
||||||
Information.info_type == "deal"
|
|
||||||
).count()
|
|
||||||
user_list.append({
|
user_list.append({
|
||||||
"id": u.f99_90_id,
|
"id": u.f99_90_id,
|
||||||
"username": u.f01_01_name,
|
"username": u.f01_01_name,
|
||||||
"email": u.email,
|
"email": u.email,
|
||||||
"phone": u.phone,
|
"phone": u.phone,
|
||||||
"role": u.role,
|
"role": u.role,
|
||||||
"user_code": u.user_code,
|
|
||||||
"created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None,
|
"created_at": u.f99_92_created_at.isoformat() if u.f99_92_created_at else None,
|
||||||
"collectionCount": count,
|
"collection_count": count
|
||||||
"dealCount": deal_count,
|
|
||||||
"level": u.f99_94_level,
|
|
||||||
"aiCount": u.f99_95_ai_count,
|
|
||||||
"searchCount": u.f99_96_search_count,
|
|
||||||
"loginCount": u.f99_98_login_count,
|
|
||||||
"points": u.f99_100_points,
|
|
||||||
"balance": float(u.f01_11_balance) if u.f01_11_balance else 0,
|
|
||||||
"totalAmount": float(u.f01_12_total_amount) if u.f01_12_total_amount else 0,
|
|
||||||
"phoneVerified": u.f01_06_phone_verified,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return user_list
|
return user_list
|
||||||
|
|
@ -152,7 +109,7 @@ def get_user(
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取单个用户信息"""
|
"""获取单个用户信息"""
|
||||||
if current_user.role not in ["admin", "editor"]:
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="无权访问")
|
raise HTTPException(status_code=403, detail="无权访问")
|
||||||
|
|
||||||
user = db.query(User).filter(User.id == user_id).first()
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
|
@ -177,7 +134,7 @@ def get_user_collections(
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取指定用户的藏品列表"""
|
"""获取指定用户的藏品列表"""
|
||||||
if current_user.role not in ["admin", "editor"]:
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="无权访问")
|
raise HTTPException(status_code=403, detail="无权访问")
|
||||||
|
|
||||||
collections = db.query(Collection).filter(
|
collections = db.query(Collection).filter(
|
||||||
|
|
@ -194,7 +151,7 @@ def get_user_collection_count(
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""获取指定用户的藏品数量"""
|
"""获取指定用户的藏品数量"""
|
||||||
if current_user.role not in ["admin", "editor"]:
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
||||||
|
|
||||||
count = db.query(Collection).filter(Collection.user_id == user_id).count()
|
count = db.query(Collection).filter(Collection.user_id == user_id).count()
|
||||||
|
|
@ -208,18 +165,11 @@ def update_user(
|
||||||
email: Optional[str] = Body(None),
|
email: Optional[str] = Body(None),
|
||||||
role: Optional[str] = Body(None),
|
role: Optional[str] = Body(None),
|
||||||
password: Optional[str] = Body(None),
|
password: Optional[str] = Body(None),
|
||||||
user_code: Optional[str] = Body(None),
|
|
||||||
level: Optional[str] = Body(None),
|
|
||||||
points: Optional[int] = Body(None),
|
|
||||||
balance: Optional[float] = Body(None),
|
|
||||||
totalAmount: Optional[float] = Body(None),
|
|
||||||
aiCount: Optional[int] = Body(None),
|
|
||||||
searchCount: Optional[int] = Body(None),
|
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""更新用户信息(仅管理员)"""
|
"""更新用户信息(仅管理员)"""
|
||||||
if current_user.role not in ["admin", "editor"]:
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
||||||
|
|
||||||
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
user = db.query(User).filter(User.f99_90_id == user_id).first()
|
||||||
|
|
@ -234,45 +184,6 @@ def update_user(
|
||||||
if role is not None:
|
if role is not None:
|
||||||
user.role = role
|
user.role = role
|
||||||
|
|
||||||
# 更新会员等级
|
|
||||||
if level is not None:
|
|
||||||
user.f99_94_level = level
|
|
||||||
|
|
||||||
# 更新积分
|
|
||||||
if points is not None:
|
|
||||||
user.f99_100_points = points
|
|
||||||
|
|
||||||
# 更新余额
|
|
||||||
if balance is not None:
|
|
||||||
user.f01_11_balance = balance
|
|
||||||
|
|
||||||
# 更新累计金额
|
|
||||||
if totalAmount is not None:
|
|
||||||
user.f01_12_total_amount = totalAmount
|
|
||||||
|
|
||||||
# 更新AI识别次数
|
|
||||||
if aiCount is not None:
|
|
||||||
user.f99_95_ai_count = aiCount
|
|
||||||
|
|
||||||
# 更新寻号次数
|
|
||||||
if searchCount is not None:
|
|
||||||
user.f99_96_search_count = searchCount
|
|
||||||
|
|
||||||
# 更新用户编码
|
|
||||||
if user_code is not None:
|
|
||||||
# 只有非空字符串才检查唯一性
|
|
||||||
user_code_str = user_code.strip() if user_code else ''
|
|
||||||
if user_code_str:
|
|
||||||
existing = db.query(User).filter(
|
|
||||||
User.user_code == user_code_str,
|
|
||||||
User.f99_90_id != user_id
|
|
||||||
).first()
|
|
||||||
if existing:
|
|
||||||
raise HTTPException(status_code=400, detail="E00052: 该用户编码已被其他用户使用")
|
|
||||||
user.user_code = user_code_str
|
|
||||||
else:
|
|
||||||
user.user_code = None
|
|
||||||
|
|
||||||
# 更新密码
|
# 更新密码
|
||||||
if password and password.strip():
|
if password and password.strip():
|
||||||
from app.core.auth import get_password_hash
|
from app.core.auth import get_password_hash
|
||||||
|
|
@ -297,7 +208,7 @@ def delete_user(
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""删除用户(仅管理员)"""
|
"""删除用户(仅管理员)"""
|
||||||
if current_user.role not in ["admin", "editor"]:
|
if current_user.role != "admin":
|
||||||
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
raise HTTPException(status_code=403, detail="E00050: 仅管理员可访问")
|
||||||
|
|
||||||
# 不能删除自己
|
# 不能删除自己
|
||||||
|
|
|
||||||
|
|
@ -1,760 +0,0 @@
|
||||||
# yichens - 一尘数据路由
|
|
||||||
# Version: 1.2.70
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
|
||||||
# 更新:
|
|
||||||
# Version: 1.2.x
|
|
||||||
# 更新:
|
|
||||||
from sqlalchemy import func, text
|
|
||||||
# 更新:
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
# 更新:
|
|
||||||
from pydantic import BaseModel
|
|
||||||
# 更新:
|
|
||||||
from typing import Optional, List
|
|
||||||
# 更新:
|
|
||||||
from datetime import datetime, date
|
|
||||||
# 更新:
|
|
||||||
from app.core.coolbot_db import get_coolbot_db
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
router = APIRouter(prefix="/api/yichens", tags=["一尘看板"])
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ 数据模型 ============
|
|
||||||
# 更新:
|
|
||||||
class YichensPostStats(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
total_posts: int
|
|
||||||
# 更新:
|
|
||||||
total_deals: int # 出售
|
|
||||||
# 更新:
|
|
||||||
total_wants: int # 求购
|
|
||||||
# 更新:
|
|
||||||
total_replies: int
|
|
||||||
# 更新:
|
|
||||||
total_views: int
|
|
||||||
# 更新:
|
|
||||||
avg_price: Optional[float]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
class CategoryStat(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
category: str
|
|
||||||
# 更新:
|
|
||||||
count: int
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
class PostItem(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
post_id: str
|
|
||||||
# 更新:
|
|
||||||
title: str
|
|
||||||
# 更新:
|
|
||||||
category: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
post_type: str
|
|
||||||
# 更新:
|
|
||||||
price: Optional[float]
|
|
||||||
# 更新:
|
|
||||||
author_username: str
|
|
||||||
# 更新:
|
|
||||||
post_time: str
|
|
||||||
# 更新:
|
|
||||||
reply_count: int
|
|
||||||
# 更新:
|
|
||||||
view_count: int
|
|
||||||
# 更新:
|
|
||||||
url: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
content: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
class UserStat(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
total_users: int
|
|
||||||
# 更新:
|
|
||||||
new_users_today: int
|
|
||||||
# 更新:
|
|
||||||
sellers: int
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
class UserItem(BaseModel):
|
|
||||||
# 更新:
|
|
||||||
user_id: str
|
|
||||||
# 更新:
|
|
||||||
username: str
|
|
||||||
# 更新:
|
|
||||||
avatar_url: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
content: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
credit_level: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
credit_score: Optional[int]
|
|
||||||
# 更新:
|
|
||||||
post_count: int
|
|
||||||
# 更新:
|
|
||||||
is_seller: bool
|
|
||||||
# 更新:
|
|
||||||
registration_date: Optional[str]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# ============ 统计接口 ============
|
|
||||||
# 更新:
|
|
||||||
@router.get("/stats/posts", response_model=YichensPostStats)
|
|
||||||
# 更新:
|
|
||||||
def get_post_stats(days: int = Query(30, ge=1, le=365), db: Session = Depends(get_coolbot_db)):
|
|
||||||
# 更新:
|
|
||||||
"""获取帖子统计"""
|
|
||||||
# 更新:
|
|
||||||
result = db.execute(text("""
|
|
||||||
# 更新:
|
|
||||||
SELECT
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) as total_posts,
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) FILTER (WHERE post_type = 'deal') as total_deals,
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) FILTER (WHERE post_type = 'want') as total_wants,
|
|
||||||
# 更新:
|
|
||||||
COALESCE(SUM(reply_count), 0) as total_replies,
|
|
||||||
# 更新:
|
|
||||||
COALESCE(SUM(view_count), 0) as total_views,
|
|
||||||
# 更新:
|
|
||||||
AVG(price) as avg_price
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE post_time >= NOW() - INTERVAL '1 day' * :days
|
|
||||||
# 更新:
|
|
||||||
"""), {"days": days}).fetchone()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return YichensPostStats(
|
|
||||||
# 更新:
|
|
||||||
total_posts=result[0] or 0,
|
|
||||||
# 更新:
|
|
||||||
total_deals=result[1] or 0,
|
|
||||||
# 更新:
|
|
||||||
total_wants=result[2] or 0,
|
|
||||||
# 更新:
|
|
||||||
total_replies=result[3] or 0,
|
|
||||||
# 更新:
|
|
||||||
total_views=result[4] or 0,
|
|
||||||
# 更新:
|
|
||||||
avg_price=float(result[5]) if result[5] else None
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/stats/categories", response_model=List[CategoryStat])
|
|
||||||
# 更新:
|
|
||||||
def get_category_stats(days: int = Query(30, ge=1, le=365), db: Session = Depends(get_coolbot_db)):
|
|
||||||
# 更新:
|
|
||||||
"""按分类统计帖子数量"""
|
|
||||||
# 更新:
|
|
||||||
results = db.execute(text("""
|
|
||||||
# 更新:
|
|
||||||
SELECT category, COUNT(*) as count
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE post_time >= NOW() - INTERVAL '1 day' * :days
|
|
||||||
# 更新:
|
|
||||||
GROUP BY category
|
|
||||||
# 更新:
|
|
||||||
ORDER BY count DESC
|
|
||||||
# 更新:
|
|
||||||
"""), {"days": days}).fetchall()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return [CategoryStat(category=r[0] or "未知", count=r[1]) for r in results]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/stats/users", response_model=UserStat)
|
|
||||||
# 更新:
|
|
||||||
def get_user_stats(db: Session = Depends(get_coolbot_db)):
|
|
||||||
# 更新:
|
|
||||||
"""获取用户统计"""
|
|
||||||
# 更新:
|
|
||||||
result = db.execute(text("""
|
|
||||||
# 更新:
|
|
||||||
SELECT
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) as total_users,
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) FILTER (WHERE created_at::date = CURRENT_DATE) as new_users_today,
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) FILTER (WHERE is_seller = true) as sellers
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_users
|
|
||||||
# 更新:
|
|
||||||
""")).fetchone()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return UserStat(
|
|
||||||
# 更新:
|
|
||||||
total_users=result[0] or 0,
|
|
||||||
# 更新:
|
|
||||||
new_users_today=result[1] or 0,
|
|
||||||
# 更新:
|
|
||||||
sellers=result[2] or 0
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/posts")
|
|
||||||
# 更新:
|
|
||||||
def get_posts(
|
|
||||||
# 更新:
|
|
||||||
limit: int = Query(20, ge=1, le=500),
|
|
||||||
# 更新:
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
# 更新:
|
|
||||||
category: Optional[str] = None,
|
|
||||||
# 更新:
|
|
||||||
post_type: Optional[str] = None,
|
|
||||||
# 更新:
|
|
||||||
keyword: Optional[str] = None,
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_coolbot_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""获取帖子列表 - 支持全局搜索,返回总数和分页信息"""
|
|
||||||
# 更新:
|
|
||||||
# 构建WHERE条件
|
|
||||||
# 更新:
|
|
||||||
where_clauses = ["1=1"]
|
|
||||||
# 更新:
|
|
||||||
params = {"limit": limit, "offset": offset}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if category:
|
|
||||||
# 更新:
|
|
||||||
where_clauses.append("category = :category")
|
|
||||||
# 更新:
|
|
||||||
params["category"] = category
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if post_type:
|
|
||||||
# 更新:
|
|
||||||
where_clauses.append("post_type = :post_type")
|
|
||||||
# 更新:
|
|
||||||
params["post_type"] = post_type
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 全局搜索
|
|
||||||
# 更新:
|
|
||||||
if keyword:
|
|
||||||
# 更新:
|
|
||||||
where_clauses.append("(title ILIKE :keyword OR content ILIKE :keyword OR category ILIKE :keyword OR author_username ILIKE :keyword)")
|
|
||||||
# 更新:
|
|
||||||
params["keyword"] = f"%{keyword}%"
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
where_sql = " AND ".join(where_clauses)
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 查询总数
|
|
||||||
# 更新:
|
|
||||||
count_query = f"SELECT COUNT(*) FROM yichens_posts WHERE {where_sql}"
|
|
||||||
# 更新:
|
|
||||||
total_count = db.execute(text(count_query), {k: v for k, v in params.items() if k not in ['limit', 'offset']}).scalar() or 0
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 查询数据 - 有post_time时按post_time排序,没有时按crawled_at排序
|
|
||||||
# 更新:
|
|
||||||
data_query = f"""
|
|
||||||
# 更新:
|
|
||||||
SELECT post_id, title, content, category, post_type, price,
|
|
||||||
# 更新:
|
|
||||||
author_username, post_time, reply_count, view_count, url
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE {where_sql}
|
|
||||||
# 更新:
|
|
||||||
ORDER BY COALESCE(post_time, crawled_at) DESC LIMIT :limit OFFSET :offset
|
|
||||||
# 更新:
|
|
||||||
"""
|
|
||||||
# 更新:
|
|
||||||
results = db.execute(text(data_query), params).fetchall()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
posts = [PostItem(
|
|
||||||
# 更新:
|
|
||||||
post_id=r[0],
|
|
||||||
# 更新:
|
|
||||||
title=r[1] or "",
|
|
||||||
# 更新:
|
|
||||||
content=r[2] or "",
|
|
||||||
# 更新:
|
|
||||||
category=r[3],
|
|
||||||
# 更新:
|
|
||||||
post_type=r[4] or "",
|
|
||||||
# 更新:
|
|
||||||
price=float(r[5]) if r[5] else None,
|
|
||||||
# 更新:
|
|
||||||
author_username=r[6] or "",
|
|
||||||
# 更新:
|
|
||||||
post_time=str(r[7]) if r[7] else "",
|
|
||||||
# 更新:
|
|
||||||
reply_count=r[8] or 0,
|
|
||||||
# 更新:
|
|
||||||
view_count=r[9] or 0,
|
|
||||||
# 更新:
|
|
||||||
url=r[10]
|
|
||||||
# 更新:
|
|
||||||
) for r in results]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {
|
|
||||||
# 更新:
|
|
||||||
"posts": posts,
|
|
||||||
# 更新:
|
|
||||||
"total": total_count,
|
|
||||||
# 更新:
|
|
||||||
"page": offset // limit + 1,
|
|
||||||
# 更新:
|
|
||||||
"page_size": limit
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/users", response_model=List[UserItem])
|
|
||||||
# 更新:
|
|
||||||
def get_users(
|
|
||||||
# 更新:
|
|
||||||
limit: int = Query(20, ge=1, le=500),
|
|
||||||
# 更新:
|
|
||||||
offset: int = Query(0, ge=0),
|
|
||||||
# 更新:
|
|
||||||
is_seller: Optional[bool] = None,
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_coolbot_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""获取用户列表"""
|
|
||||||
# 更新:
|
|
||||||
query = """
|
|
||||||
# 更新:
|
|
||||||
SELECT user_id, username, avatar_url, credit_level, credit_score,
|
|
||||||
# 更新:
|
|
||||||
post_count, is_seller, registration_date
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_users
|
|
||||||
# 更新:
|
|
||||||
WHERE 1=1
|
|
||||||
# 更新:
|
|
||||||
"""
|
|
||||||
# 更新:
|
|
||||||
params = {"limit": limit, "offset": offset}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
if is_seller is not None:
|
|
||||||
# 更新:
|
|
||||||
query += " AND is_seller = :is_seller"
|
|
||||||
# 更新:
|
|
||||||
params["is_seller"] = is_seller
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
query += " ORDER BY post_count DESC LIMIT :limit OFFSET :offset"
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
results = db.execute(text(query), params).fetchall()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return [UserItem(
|
|
||||||
# 更新:
|
|
||||||
user_id=r[0],
|
|
||||||
# 更新:
|
|
||||||
username=r[1] or "",
|
|
||||||
# 更新:
|
|
||||||
avatar_url=r[2],
|
|
||||||
# 更新:
|
|
||||||
credit_level=r[3],
|
|
||||||
# 更新:
|
|
||||||
credit_score=r[4],
|
|
||||||
# 更新:
|
|
||||||
post_count=r[5] or 0,
|
|
||||||
# 更新:
|
|
||||||
is_seller=r[6] or False,
|
|
||||||
# 更新:
|
|
||||||
registration_date=str(r[7]) if r[7] else None
|
|
||||||
# 更新:
|
|
||||||
) for r in results]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/stats/today")
|
|
||||||
# 更新:
|
|
||||||
async def get_today_stats(db: Session = Depends(get_coolbot_db)):
|
|
||||||
# 更新:
|
|
||||||
"""获取今日新增帖子统计"""
|
|
||||||
# 更新:
|
|
||||||
query = """
|
|
||||||
# 更新:
|
|
||||||
SELECT
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) as total,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'normal' THEN 1 ELSE 0 END) as others,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN category LIKE '%龙%' THEN 1 ELSE 0 END) as dragons,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN category LIKE '%马%' THEN 1 ELSE 0 END) as horses,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN category LIKE '%蛇%' THEN 1 ELSE 0 END) as snakes,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN title LIKE '%天马%' OR content LIKE '%天马%' THEN 1 ELSE 0 END) as tianma
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE post_time >= CURRENT_DATE
|
|
||||||
# 更新:
|
|
||||||
"""
|
|
||||||
# 更新:
|
|
||||||
result = db.execute(text(query)).fetchone()
|
|
||||||
# 更新:
|
|
||||||
return {
|
|
||||||
# 更新:
|
|
||||||
"total": result[0] or 0,
|
|
||||||
# 更新:
|
|
||||||
"deals": result[1] or 0,
|
|
||||||
# 更新:
|
|
||||||
"wants": result[2] or 0,
|
|
||||||
# 更新:
|
|
||||||
"others": result[3] or 0,
|
|
||||||
# 更新:
|
|
||||||
"dragons": result[4] or 0,
|
|
||||||
# 更新:
|
|
||||||
"horses": result[5] or 0,
|
|
||||||
# 更新:
|
|
||||||
"snakes": result[6] or 0,
|
|
||||||
# 更新:
|
|
||||||
"tianma": result[7] or 0
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/stats/hour")
|
|
||||||
# 更新:
|
|
||||||
async def get_hour_stats(db: Session = Depends(get_coolbot_db)):
|
|
||||||
# 更新:
|
|
||||||
"""获取近一个小时新增帖子统计"""
|
|
||||||
# 更新:
|
|
||||||
query = """
|
|
||||||
# 更新:
|
|
||||||
SELECT
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) as total,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'normal' THEN 1 ELSE 0 END) as others,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN category LIKE '%龙%' THEN 1 ELSE 0 END) as dragons,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN category LIKE '%马%' THEN 1 ELSE 0 END) as horses,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN category LIKE '%蛇%' THEN 1 ELSE 0 END) as snakes
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE post_time >= NOW() - INTERVAL '1 hour'
|
|
||||||
# 更新:
|
|
||||||
"""
|
|
||||||
# 更新:
|
|
||||||
result = db.execute(text(query)).fetchone()
|
|
||||||
# 更新:
|
|
||||||
return {
|
|
||||||
# 更新:
|
|
||||||
"total": result[0] or 0,
|
|
||||||
# 更新:
|
|
||||||
"deals": result[1] or 0,
|
|
||||||
# 更新:
|
|
||||||
"wants": result[2] or 0,
|
|
||||||
# 更新:
|
|
||||||
"others": result[3] or 0,
|
|
||||||
# 更新:
|
|
||||||
"dragons": result[3] or 0,
|
|
||||||
# 更新:
|
|
||||||
"horses": result[4] or 0,
|
|
||||||
# 更新:
|
|
||||||
"snakes": result[5] or 0
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/stats/today-category")
|
|
||||||
# 更新:
|
|
||||||
async def get_today_category_stats(db: Session = Depends(get_coolbot_db)):
|
|
||||||
# 更新:
|
|
||||||
"""获取今日帖子分类统计"""
|
|
||||||
# 更新:
|
|
||||||
query = """
|
|
||||||
# 更新:
|
|
||||||
SELECT category, COUNT(*) as count
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE post_time >= CURRENT_DATE
|
|
||||||
# 更新:
|
|
||||||
GROUP BY category
|
|
||||||
# 更新:
|
|
||||||
ORDER BY count DESC
|
|
||||||
# 更新:
|
|
||||||
"""
|
|
||||||
# 更新:
|
|
||||||
results = db.execute(text(query)).fetchall()
|
|
||||||
# 更新:
|
|
||||||
return [{"category": r[0] or "未分类", "count": r[1]} for r in results]
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
@router.get("/stats/dragons-today")
|
|
||||||
# 更新:
|
|
||||||
def get_dragons_stats_today(
|
|
||||||
# 更新:
|
|
||||||
db: Session = Depends(get_coolbot_db)
|
|
||||||
# 更新:
|
|
||||||
):
|
|
||||||
# 更新:
|
|
||||||
"""获取今日龙钞详细统计数据(按号码分类)- 就高不就低"""
|
|
||||||
# 更新:
|
|
||||||
from sqlalchemy import text
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 1. 带4:包含"带4"、"带四"、"通货"
|
|
||||||
# 更新:
|
|
||||||
dai4 = db.execute(text("""
|
|
||||||
# 更新:
|
|
||||||
SELECT
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) as total,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE post_time >= CURRENT_DATE
|
|
||||||
# 更新:
|
|
||||||
AND category LIKE '%龙%'
|
|
||||||
# 更新:
|
|
||||||
AND (
|
|
||||||
# 更新:
|
|
||||||
content LIKE '%带4%' OR title LIKE '%带4%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%带四%' OR title LIKE '%带四%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%通货%' OR title LIKE '%通货%'
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
""")).fetchone()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 2. 无4:包含"无4"、"无四",排除"无47"、"无247"、"无四七"、"永恒"
|
|
||||||
# 更新:
|
|
||||||
wu4 = db.execute(text("""
|
|
||||||
# 更新:
|
|
||||||
SELECT
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) as total,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE post_time >= CURRENT_DATE
|
|
||||||
# 更新:
|
|
||||||
AND category LIKE '%龙%'
|
|
||||||
# 更新:
|
|
||||||
AND (
|
|
||||||
# 更新:
|
|
||||||
content LIKE '%无4%' OR title LIKE '%无4%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%无四%' OR title LIKE '%无四%'
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
AND content NOT LIKE '%无47%' AND title NOT LIKE '%无47%'
|
|
||||||
# 更新:
|
|
||||||
AND content NOT LIKE '%无247%' AND title NOT LIKE '%无247%'
|
|
||||||
# 更新:
|
|
||||||
AND content NOT LIKE '%永恒%' AND title NOT LIKE '%永恒%'
|
|
||||||
# 更新:
|
|
||||||
AND content NOT LIKE '%无四七%' AND title NOT LIKE '%无四七%'
|
|
||||||
# 更新:
|
|
||||||
""")).fetchone()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 3. 无47:包含"无47"、"永恒"、"无四七",排除"无247"
|
|
||||||
# 更新:
|
|
||||||
wu47 = db.execute(text("""
|
|
||||||
# 更新:
|
|
||||||
SELECT
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) as total,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE post_time >= CURRENT_DATE
|
|
||||||
# 更新:
|
|
||||||
AND category LIKE '%龙%'
|
|
||||||
# 更新:
|
|
||||||
AND (
|
|
||||||
# 更新:
|
|
||||||
content LIKE '%无47%' OR title LIKE '%无47%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%永恒%' OR title LIKE '%永恒%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%无四七%' OR title LIKE '%无四七%'
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
AND content NOT LIKE '%无247%' AND title NOT LIKE '%无247%'
|
|
||||||
# 更新:
|
|
||||||
""")).fetchone()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 4. 无247:包含"无247"、"天马"、"金山",排除"无347"
|
|
||||||
# 更新:
|
|
||||||
wu247 = db.execute(text("""
|
|
||||||
# 更新:
|
|
||||||
SELECT
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) as total,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE post_time >= CURRENT_DATE
|
|
||||||
# 更新:
|
|
||||||
AND category LIKE '%龙%'
|
|
||||||
# 更新:
|
|
||||||
AND (
|
|
||||||
# 更新:
|
|
||||||
content LIKE '%无247%' OR title LIKE '%无247%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%天马%' OR title LIKE '%天马%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%金山%' OR title LIKE '%金山%'
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
AND content NOT LIKE '%无347%' AND title NOT LIKE '%无347%'
|
|
||||||
# 更新:
|
|
||||||
""")).fetchone()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
# 5. 无347:包含"无347"、"钻石"、"金马"、"魅力"、"朦胧"
|
|
||||||
# 更新:
|
|
||||||
wu347 = db.execute(text("""
|
|
||||||
# 更新:
|
|
||||||
SELECT
|
|
||||||
# 更新:
|
|
||||||
COUNT(*) as total,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'deal' THEN 1 ELSE 0 END) as deals,
|
|
||||||
# 更新:
|
|
||||||
SUM(CASE WHEN post_type = 'want' THEN 1 ELSE 0 END) as wants
|
|
||||||
# 更新:
|
|
||||||
FROM yichens_posts
|
|
||||||
# 更新:
|
|
||||||
WHERE post_time >= CURRENT_DATE
|
|
||||||
# 更新:
|
|
||||||
AND category LIKE '%龙%'
|
|
||||||
# 更新:
|
|
||||||
AND (
|
|
||||||
# 更新:
|
|
||||||
content LIKE '%无347%' OR title LIKE '%无347%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%钻石%' OR title LIKE '%钻石%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%金马%' OR title LIKE '%金马%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%魅力%' OR title LIKE '%魅力%'
|
|
||||||
# 更新:
|
|
||||||
OR content LIKE '%朦胧%' OR title LIKE '%朦胧%'
|
|
||||||
# 更新:
|
|
||||||
)
|
|
||||||
# 更新:
|
|
||||||
""")).fetchone()
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
return {
|
|
||||||
# 更新:
|
|
||||||
"dai4": {"total": dai4[0] or 0, "deals": dai4[1] or 0, "wants": dai4[2] or 0},
|
|
||||||
# 更新:
|
|
||||||
"wu4": {"total": wu4[0] or 0, "deals": wu4[1] or 0, "wants": wu4[2] or 0},
|
|
||||||
# 更新:
|
|
||||||
"wu47": {"total": wu47[0] or 0, "deals": wu47[1] or 0, "wants": wu47[2] or 0},
|
|
||||||
# 更新:
|
|
||||||
"wu247": {"total": wu247[0] or 0, "deals": wu247[1] or 0, "wants": wu247[2] or 0},
|
|
||||||
# 更新:
|
|
||||||
"wu347": {"total": wu347[0] or 0, "deals": wu347[1] or 0, "wants": wu347[2] or 0}
|
|
||||||
# 更新:
|
|
||||||
}
|
|
||||||
# 更新:
|
|
||||||
|
|
||||||
# 更新:
|
|
||||||
|
|
@ -19,7 +19,6 @@ class UserBase(BaseModel):
|
||||||
|
|
||||||
class UserCreate(UserBase):
|
class UserCreate(UserBase):
|
||||||
password: str = Field(..., min_length=6)
|
password: str = Field(..., min_length=6)
|
||||||
invite_code: Optional[str] = Field(None, alias="inviteCode") # 填写的邀请码(选填)
|
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
|
|
@ -35,27 +34,9 @@ class UserUpdate(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
class UserResponse(UserBase):
|
class UserResponse(UserBase):
|
||||||
# 新增字段
|
|
||||||
f99_94_level: Optional[str] = Field(None, alias="level")
|
|
||||||
f99_95_ai_count: Optional[int] = Field(0, alias="aiCount")
|
|
||||||
f99_96_search_count: Optional[int] = Field(0, alias="searchCount")
|
|
||||||
f99_97_collection_count: Optional[int] = Field(0, alias="collectionCount")
|
|
||||||
f01_06_phone_verified: Optional[bool] = Field(False, alias="phoneVerified")
|
|
||||||
f99_98_login_count: Optional[int] = Field(0, alias="loginCount")
|
|
||||||
f99_99_last_login: Optional[datetime] = Field(None, alias="lastLogin")
|
|
||||||
f01_07_gender: Optional[str] = Field(None, alias="gender")
|
|
||||||
f01_08_birthday: Optional[datetime] = Field(None, alias="birthday")
|
|
||||||
f01_09_region: Optional[str] = Field(None, alias="region")
|
|
||||||
f01_10_realname_verified: Optional[bool] = Field(False, alias="realnameVerified")
|
|
||||||
f99_100_points: Optional[int] = Field(0, alias="points")
|
|
||||||
f01_11_balance: Optional[float] = Field(0, alias="balance")
|
|
||||||
f01_12_total_amount: Optional[float] = Field(0, alias="totalAmount")
|
|
||||||
f01_13_invite_code: Optional[str] = Field(None, alias="inviteCode")
|
|
||||||
f99_101_invited_count: Optional[int] = Field(0, alias="invitedCount")
|
|
||||||
f99_90_id: str = Field(..., alias="id")
|
f99_90_id: str = Field(..., alias="id")
|
||||||
f01_01_name: str = Field(..., alias="username")
|
f01_01_name: str = Field(..., alias="username")
|
||||||
role: str
|
role: str
|
||||||
user_code: Optional[str] = None
|
|
||||||
f99_92_created_at: Optional[datetime] = Field(None, alias="created_at")
|
f99_92_created_at: Optional[datetime] = Field(None, alias="created_at")
|
||||||
f99_93_updated_at: Optional[datetime] = Field(None, alias="updated_at")
|
f99_93_updated_at: Optional[datetime] = Field(None, alias="updated_at")
|
||||||
|
|
||||||
|
|
@ -77,7 +58,6 @@ class CollectionBase(BaseModel):
|
||||||
f02_11_version: Optional[str] = Field(None, alias="version")
|
f02_11_version: Optional[str] = Field(None, alias="version")
|
||||||
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
|
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
|
||||||
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
|
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
|
||||||
f02_14_number_category: Optional[str] = Field(None, alias="numberCategory")
|
|
||||||
|
|
||||||
# f03 评级信息
|
# f03 评级信息
|
||||||
f03_20_is_graded: Optional[bool] = Field(False, alias="isGraded")
|
f03_20_is_graded: Optional[bool] = Field(False, alias="isGraded")
|
||||||
|
|
@ -124,7 +104,6 @@ class CollectionUpdate(BaseModel):
|
||||||
f02_11_version: Optional[str] = Field(None, alias="version")
|
f02_11_version: Optional[str] = Field(None, alias="version")
|
||||||
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
|
f02_12_packaging: Optional[str] = Field(None, alias="packaging")
|
||||||
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
|
f02_13_rarity: Optional[str] = Field(None, alias="rarity")
|
||||||
f02_14_number_category: Optional[str] = Field(None, alias="numberCategory")
|
|
||||||
|
|
||||||
# f03 评级信息
|
# f03 评级信息
|
||||||
f03_20_is_graded: Optional[bool] = Field(None, alias="isGraded")
|
f03_20_is_graded: Optional[bool] = Field(None, alias="isGraded")
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,10 @@ from typing import Optional
|
||||||
|
|
||||||
# 阿里云短信配置
|
# 阿里云短信配置
|
||||||
SMS_CONFIG = {
|
SMS_CONFIG = {
|
||||||
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5tQAx5niD7JQVqGE5acE"),
|
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5t6HUnpFBLEK9194kPVG"),
|
||||||
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "QsQFAEKBkaNynIoKyvdIi3BUyWVZu1"),
|
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "LEr4Q8yRxb8D5b24cKfCwlt4MMoke1"),
|
||||||
"sign_name": os.getenv("SMS_SIGN_NAME", "苏州算力"),
|
"sign_name": "阿里云",
|
||||||
"template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"),
|
"template_code": "100001",
|
||||||
}
|
}
|
||||||
|
|
||||||
# 验证码缓存(生产环境建议用Redis)
|
# 验证码缓存(生产环境建议用Redis)
|
||||||
|
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
# 号码分类工具
|
|
||||||
# 按MEMORY.md最新规则 (2026-04-02)
|
|
||||||
# 优先级:数字越小越有价值
|
|
||||||
|
|
||||||
CATEGORIES = [
|
|
||||||
# 1. 圆圆号:无.123457,只能用0689
|
|
||||||
{"name": "圆圆号", "cannot_use": ".123457", "must_have": ""},
|
|
||||||
# 2. 倒置号:无23457,可用01689必须有1
|
|
||||||
{"name": "倒置号", "cannot_use": "23457", "must_have": "1"},
|
|
||||||
# 3. 金马王:无12347,可用05689必须有5
|
|
||||||
{"name": "金马王", "cannot_use": "12347", "must_have": "5"},
|
|
||||||
# 4. 金马号:无2347,可用015689必须有1和5
|
|
||||||
{"name": "金马号", "cannot_use": "2347", "must_have": "15"},
|
|
||||||
# 5. 金山王:无12457,可用03689必须有3
|
|
||||||
{"name": "金山王", "cannot_use": "12457", "must_have": "3"},
|
|
||||||
# 6. 天马王:无1247,可用035689必须有3和5
|
|
||||||
{"name": "天马王", "cannot_use": "1247", "must_have": "35"},
|
|
||||||
# 7. 金山号:无2457,可用013689必须有1和3
|
|
||||||
{"name": "金山号", "cannot_use": "2457", "must_have": "13"},
|
|
||||||
# 8. 天马号:无247,可用0135689必须有1、3和5
|
|
||||||
{"name": "天马号", "cannot_use": "247", "must_have": "135"},
|
|
||||||
# 9. 朦胧王:无13457
|
|
||||||
{"name": "朦胧王", "cannot_use": "13457", "must_have": ""},
|
|
||||||
# 10. 朦胧号:无3457
|
|
||||||
{"name": "朦胧号", "cannot_use": "3457", "must_have": ""},
|
|
||||||
# 11. 如意号:无1347
|
|
||||||
{"name": "如意号", "cannot_use": "1347", "must_have": ""},
|
|
||||||
# 12. 钻石号:无347
|
|
||||||
{"name": "钻石号", "cannot_use": "347", "must_have": ""},
|
|
||||||
# 13. 永恒号:无47
|
|
||||||
{"name": "永恒号", "cannot_use": "47", "must_have": ""},
|
|
||||||
# 14. 无4号:不包含4
|
|
||||||
{"name": "无4号", "cannot_use": "4", "must_have": ""},
|
|
||||||
# 15. 通货:含4
|
|
||||||
{"name": "通货", "cannot_use": "", "must_have": "4"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def extract_digits(serial: str) -> dict:
|
|
||||||
"""提取冠字号中的数字部分"""
|
|
||||||
if not serial:
|
|
||||||
return {"digits": "", "type": "single"}
|
|
||||||
|
|
||||||
nums = serial.replace("J", "").replace(",", "").replace(".", "").strip()
|
|
||||||
nums = "".join(c for c in nums if c.isdigit())
|
|
||||||
|
|
||||||
if nums.endswith("01"):
|
|
||||||
return {"digits": nums[:-2], "type": "hundred"}
|
|
||||||
elif nums.endswith("1"):
|
|
||||||
return {"digits": nums[:-1], "type": "ten"}
|
|
||||||
else:
|
|
||||||
return {"digits": nums, "type": "single"}
|
|
||||||
|
|
||||||
|
|
||||||
def matches_category(digits: str, category: dict) -> bool:
|
|
||||||
cannot_use = category.get("cannot_use", "")
|
|
||||||
must_have = category.get("must_have", "")
|
|
||||||
|
|
||||||
# 检查不能用的数字
|
|
||||||
for n in cannot_use:
|
|
||||||
if n in digits:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 检查必须有的数字
|
|
||||||
if must_have:
|
|
||||||
for n in must_have:
|
|
||||||
if n not in digits:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def get_number_category(serial: str) -> str:
|
|
||||||
"""号码分类函数"""
|
|
||||||
if not serial:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# 提取数字
|
|
||||||
info = extract_digits(serial)
|
|
||||||
digits = info["digits"]
|
|
||||||
|
|
||||||
if not digits:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# 根据类型取对应位数
|
|
||||||
digits_type = info["type"]
|
|
||||||
if digits_type == "hundred":
|
|
||||||
# 标百看后6位
|
|
||||||
check_digits = digits[-6:] if len(digits) >= 6 else digits
|
|
||||||
elif digits_type == "ten":
|
|
||||||
# 标十看后7位
|
|
||||||
check_digits = digits[-7:] if len(digits) >= 7 else digits
|
|
||||||
else:
|
|
||||||
# 散钞看全部
|
|
||||||
check_digits = digits
|
|
||||||
|
|
||||||
# 按优先级匹配分类
|
|
||||||
for category in CATEGORIES:
|
|
||||||
if matches_category(check_digits, category):
|
|
||||||
return category["name"]
|
|
||||||
|
|
||||||
return "通货"
|
|
||||||
|
|
||||||
|
|
||||||
def get_number_category_color(category: str) -> str:
|
|
||||||
"""获取分类颜色"""
|
|
||||||
colors = {
|
|
||||||
"圆圆号": "#ef4444", # 红
|
|
||||||
"倒置号": "#f97316", # 橙
|
|
||||||
"金马王": "#eab308", # 黄
|
|
||||||
"金马号": "#84cc16", # 绿
|
|
||||||
"金山王": "#22c55e", # 深绿
|
|
||||||
"天马王": "#14b8a6", # 青
|
|
||||||
"金山号": "#06b6d4", # 蓝
|
|
||||||
"天马号": "#0ea5e9", # 浅蓝
|
|
||||||
"朦胧王": "#6366f1", # 靛蓝
|
|
||||||
"朦胧号": "#8b5cf6", # 紫
|
|
||||||
"如意号": "#a855f7", # 深紫
|
|
||||||
"钻石号": "#d946ef", # 品红
|
|
||||||
"永恒号": "#ec4899", # 粉红
|
|
||||||
"无4号": "#64748b", # 灰
|
|
||||||
"通货": "#9ca3af", # 浅灰
|
|
||||||
}
|
|
||||||
return colors.get(category, "#9ca3af")
|
|
||||||
|
|
||||||
|
|
||||||
def get_category_priority(category: str) -> int:
|
|
||||||
"""获取分类优先级(数字越小越高级)"""
|
|
||||||
for i, cat in enumerate(CATEGORIES, 1):
|
|
||||||
if cat["name"] == category:
|
|
||||||
return i
|
|
||||||
return 999
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
# 版本更新记录
|
|
||||||
|
|
||||||
## v1.2.98 (2026-04-19)
|
|
||||||
|
|
||||||
### 前端更新
|
|
||||||
- **Admin.jsx**
|
|
||||||
- 功能:修复管理员用户列表行情数显示问题
|
|
||||||
- 改进:新增 dealCount 字段显示用户发布的成交行情数量
|
|
||||||
- 代码:将"配号"改为"📈 行情: X条"
|
|
||||||
|
|
||||||
### 后端更新
|
|
||||||
- **users.py**
|
|
||||||
- 功能:修复管理员用户列表行情数API
|
|
||||||
- 改进:新增 dealCount 字段,从 Information 表统计用户发布的 deal 数量
|
|
||||||
- 代码:新增 Information 模型导入,统计查询
|
|
||||||
|
|
||||||
### Vite配置更新
|
|
||||||
- **vite.config.js**
|
|
||||||
- 功能:修复前端构建版本号不更新问题
|
|
||||||
- 改进:将版本更新改为 closeBundle hook 执行
|
|
||||||
- 代码:重构 updateHtmlTitle 函数
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.2.97 (2026-04-XX)
|
|
||||||
|
|
||||||
### 更新内容
|
|
||||||
- (待记录)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v1.2.96 (2026-04-XX)
|
|
||||||
|
|
||||||
### 更新内容
|
|
||||||
- (待记录)
|
|
||||||
|
|
@ -1 +1,14 @@
|
||||||
1.2.98
|
# 甲辰藏品管理系统 - 版本配置
|
||||||
|
# Version Configuration for Zodiac Collection Management System
|
||||||
|
|
||||||
|
# 当前版本号 (语义化版本:主版本。次版本.修订版)
|
||||||
|
VERSION=1.1.21
|
||||||
|
|
||||||
|
# 版本代号 (可选)
|
||||||
|
VERSION_CODENAME="新生"
|
||||||
|
|
||||||
|
# 发布日期
|
||||||
|
RELEASE_DATE=2026-03-18
|
||||||
|
|
||||||
|
# 版本说明
|
||||||
|
VERSION_NOTES="性能优化 - Nginx反向代理、图片OSS直连、自动压缩、上传限制20MB"
|
||||||
|
|
|
||||||
|
|
@ -1,48 +0,0 @@
|
||||||
{
|
|
||||||
"version": "1.2.98",
|
|
||||||
"updated": "2026-04-19",
|
|
||||||
"modules": {
|
|
||||||
"frontend": {
|
|
||||||
"version": "1.2.98",
|
|
||||||
"pages": {
|
|
||||||
"Add": "1.2.90",
|
|
||||||
"Admin": "1.2.98",
|
|
||||||
"Detail": "1.2.90",
|
|
||||||
"Edit": "1.2.90",
|
|
||||||
"Home": "1.2.95",
|
|
||||||
"List": "1.2.93",
|
|
||||||
"Login": "1.2.85",
|
|
||||||
"News": "1.2.80",
|
|
||||||
"News_YichensBoard": "1.2.75",
|
|
||||||
"Settings": "1.2.75",
|
|
||||||
"Stats": "1.2.70",
|
|
||||||
"YichensBoard": "1.2.70"
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"version": "1.2.98"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"backend": {
|
|
||||||
"version": "1.2.98",
|
|
||||||
"routers": {
|
|
||||||
"auth": "1.2.90",
|
|
||||||
"collections": "1.2.95",
|
|
||||||
"deal": "1.2.85",
|
|
||||||
"information": "1.2.92",
|
|
||||||
"news": "1.2.80",
|
|
||||||
"ocr": "1.2.75",
|
|
||||||
"operations": "1.2.70",
|
|
||||||
"seek": "1.2.70",
|
|
||||||
"users": "1.2.98",
|
|
||||||
"yichens": "1.2.70"
|
|
||||||
},
|
|
||||||
"app": {
|
|
||||||
"core": "1.2.0",
|
|
||||||
"models": "1.2.0",
|
|
||||||
"schemas": "1.2.0",
|
|
||||||
"services": "1.2.0",
|
|
||||||
"utils": "1.2.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:15-alpine
|
|
||||||
container_name: jiachenlong-db-test
|
|
||||||
restart: unless-stopped
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: zodiac
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
||||||
interval: 10s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 5
|
|
||||||
|
|
||||||
backend:
|
|
||||||
image: python:3.11-slim
|
|
||||||
container_name: jiachenlong-backend-test
|
|
||||||
restart: unless-stopped
|
|
||||||
working_dir: /app
|
|
||||||
command: >
|
|
||||||
bash -c "pip install fastapi uvicorn sqlalchemy psycopg2-binary pydantic python-jose bcrypt python-multipart pillow dashscope alibabacloud-dysmsapi20170525 -q && uvicorn app.main:app --host 0.0.0.0 --port 3000"
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
volumes:
|
|
||||||
- /root/jiachenlong/backend:/app
|
|
||||||
environment:
|
|
||||||
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/zodiac
|
|
||||||
- SECRET_KEY=test-secret-key-for-sms
|
|
||||||
- ACCESS_TOKEN_EXPIRE_MINUTES=60
|
|
||||||
- OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
|
|
||||||
- OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
|
|
||||||
- OSS_BUCKET=jiachenlong-oss
|
|
||||||
- OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
|
||||||
- SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
|
|
||||||
- SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
|
|
||||||
- SMS_SIGN_NAME=苏州算力
|
|
||||||
- SMS_TEMPLATE_CODE=SMS_501590956
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
# v1.2.4 版本问题修复说明
|
|
||||||
|
|
||||||
**版本**: v1.2.4-stable
|
|
||||||
**发布日期**: 2026-03-20
|
|
||||||
**维护人**: 甲辰生产
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 问题列表及解决方案
|
|
||||||
|
|
||||||
### 问题1: OCR识别后保存藏品失败
|
|
||||||
|
|
||||||
**现象**: OCR识别成功后,点击保存藏品返回404错误
|
|
||||||
|
|
||||||
**原因**:
|
|
||||||
- OCR代码只尝试小写扩展名(jpg/jpeg/png/gif)
|
|
||||||
- 用户上传的图片文件扩展名是大写的.JPG
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
修改 `backend/app/routers/ocr.py` 第299行:
|
|
||||||
```python
|
|
||||||
# 修改前
|
|
||||||
temp_extensions = [jpg, jpeg, png, gif]
|
|
||||||
|
|
||||||
# 修改后
|
|
||||||
temp_extensions = [jpg, jpeg, png, gif, JPG, JPEG, PNG, GIF]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题2: 用户协议页面乱码
|
|
||||||
|
|
||||||
**现象**: 点击用户协议显示乱码,只有一个标题
|
|
||||||
|
|
||||||
**原因**:
|
|
||||||
- 协议文件只有一个空的HTML骨架
|
|
||||||
- 没有实际内容
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
创建完整的用户协议HTML文件 `/var/www/frontend/user_agreement.html`,包含完整的中文协议内容(服务条款、用户责任、数据安全、免责声明等)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题3: 用户协议页面无返回按钮
|
|
||||||
|
|
||||||
**现象**: 打开用户协议后无法返回
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
在用户协议页面添加返回按钮:
|
|
||||||
```html
|
|
||||||
<button class="back-btn" onclick="window.close()">← 返回</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题4: 首页Logo显示不出来
|
|
||||||
|
|
||||||
**现象**: 登录页Logo正常,登录后首页Logo显示404
|
|
||||||
|
|
||||||
**原因**:
|
|
||||||
- 部署时static目录没有复制到正确位置
|
|
||||||
- 浏览器缓存了旧的JS文件
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
1. 确保部署时复制static目录:
|
|
||||||
```bash
|
|
||||||
cp -r /root/jiachenlong/static /var/www/frontend/
|
|
||||||
cp -r /root/jiachenlong/static /var/www/mobile/
|
|
||||||
```
|
|
||||||
|
|
||||||
2. 首页Logo添加版本号防止缓存:
|
|
||||||
```jsx
|
|
||||||
// 修改Home.jsx
|
|
||||||
<img src="/static/images/jiachenlong-logo.png?v=1.2.4" ... />
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 问题5: 版本号显示重复
|
|
||||||
|
|
||||||
**现象**: 标题显示"vv1.2.4"(v重复)
|
|
||||||
|
|
||||||
**原因**:
|
|
||||||
- VERSION文件中版本号为"v1.2.4"(带v前缀)
|
|
||||||
- vite.config.js中又自动添加了"v"前缀
|
|
||||||
|
|
||||||
**解决方案**:
|
|
||||||
修改VERSION文件,去掉v前缀:
|
|
||||||
```
|
|
||||||
# 修改前
|
|
||||||
VERSION=v1.2.4
|
|
||||||
|
|
||||||
# 修改后
|
|
||||||
VERSION=1.2.4
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 部署检查清单
|
|
||||||
|
|
||||||
### 前端部署
|
|
||||||
```bash
|
|
||||||
# 1. 确保VERSION文件格式正确(不带v前缀)
|
|
||||||
cat config/VERSION
|
|
||||||
# 输出: VERSION=1.2.4
|
|
||||||
|
|
||||||
# 2. 构建
|
|
||||||
cd frontend
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 3. 部署(两个目录都要部署)
|
|
||||||
rm -rf /var/www/mobile/*
|
|
||||||
rm -rf /var/www/frontend/*
|
|
||||||
cp -r dist/* /var/www/mobile/
|
|
||||||
cp -r dist/* /var/www/frontend/
|
|
||||||
cp -r ../static /var/www/mobile/
|
|
||||||
cp -r ../static /var/www/frontend/
|
|
||||||
|
|
||||||
# 4. 重载Nginx
|
|
||||||
nginx -s reload
|
|
||||||
```
|
|
||||||
|
|
||||||
### 后端部署
|
|
||||||
```bash
|
|
||||||
# 1. 拉取最新代码
|
|
||||||
cd /root/jiachenlong
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# 2. 重启后端服务
|
|
||||||
pkill -f uvicorn
|
|
||||||
cd backend
|
|
||||||
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 &
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 标签信息
|
|
||||||
|
|
||||||
- **当前稳定版本**: v1.2.4-stable
|
|
||||||
- **Gitea标签地址**: http://47.253.189.47:3000/coolbot/jiachenlong/tags
|
|
||||||
- **发布说明**: v1.2.4稳定版本 - 用户协议修复版
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**文档结束**
|
|
||||||
297
docs/标准部署流程.md
|
|
@ -1,297 +0,0 @@
|
||||||
# 甲辰藏品管理系统 - 标准部署流程
|
|
||||||
|
|
||||||
**版本**: v1.0
|
|
||||||
**创建时间**: 2026-03-21
|
|
||||||
**维护人**: 甲辰生产
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 部署前检查清单
|
|
||||||
|
|
||||||
### 1. 获取信息
|
|
||||||
|
|
||||||
| 项目 | 内容 | 获取方式 |
|
|
||||||
|------|------|---------|
|
|
||||||
| 目标服务器IP | 如 8.149.137.26 | MEMORY.md |
|
|
||||||
| SSH密码 | 如 Jiachen123 | 询问酷博特 |
|
|
||||||
| 目标版本 | 如 v1.2.4 | Gitea tags |
|
|
||||||
| 数据库配置 | IP/密码/端口 | MEMORY.md |
|
|
||||||
|
|
||||||
### 2. 环境确认
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 登录目标服务器
|
|
||||||
ssh root@<目标IP>
|
|
||||||
|
|
||||||
# 检查已有配置(不要覆盖!)
|
|
||||||
cat /root/jiachenlong/config/VERSION
|
|
||||||
cat /etc/nginx/conf.d/*.conf
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 标准部署流程
|
|
||||||
|
|
||||||
### 前端部署(所有环境)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 登录服务器
|
|
||||||
ssh root@<前端IP>
|
|
||||||
|
|
||||||
# 2. 拉取代码(重要:不要覆盖已有目录)
|
|
||||||
cd /root
|
|
||||||
rm -rf jiachenlong_bak
|
|
||||||
mv jiachenlong jiachenlong_bak # 备份旧代码
|
|
||||||
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git jiachenlong
|
|
||||||
|
|
||||||
# 3. 检查并修改VERSION文件(重要:用sed保留原内容)
|
|
||||||
# 先查看原内容
|
|
||||||
cat jiachenlong/config/VERSION
|
|
||||||
# 修改VERSION行(保留其他行)
|
|
||||||
sed -i 's/^VERSION=.*/VERSION=1.2.4/' jiachenlong/config/VERSION
|
|
||||||
|
|
||||||
# 4. 构建前端
|
|
||||||
cd jiachenlong/frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 5. 部署(两个目录都要部署!)
|
|
||||||
rm -rf /var/www/mobile/*
|
|
||||||
rm -rf /var/www/frontend/*
|
|
||||||
cp -r dist/* /var/www/mobile/
|
|
||||||
cp -r dist/* /var/www/frontend/
|
|
||||||
cp -r ../static /var/www/mobile/
|
|
||||||
cp -r ../static /var/www/frontend/
|
|
||||||
|
|
||||||
# 6. 部署用户协议(如有)
|
|
||||||
cp user_agreement.html /var/www/mobile/
|
|
||||||
cp user_agreement.html /var/www/frontend/
|
|
||||||
|
|
||||||
# 7. 重载Nginx
|
|
||||||
nginx -s reload
|
|
||||||
```
|
|
||||||
|
|
||||||
### 后端部署(所有环境)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 登录服务器
|
|
||||||
ssh root@<后端IP>
|
|
||||||
|
|
||||||
# 2. 拉取代码
|
|
||||||
cd /root
|
|
||||||
rm -rf jiachenlong_bak
|
|
||||||
mv jiachenlong jiachenlong_bak
|
|
||||||
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git jiachenlong
|
|
||||||
|
|
||||||
# 3. 检查OCR扩展名修复(如无则手动修复)
|
|
||||||
grep -n 'temp_extensions' jiachenlong/backend/app/routers/ocr.py
|
|
||||||
# 如只有小写,修复:
|
|
||||||
sed -i "s/temp_extensions = \['jpg', 'jpeg', 'png', 'gif'\]/temp_extensions = ['jpg', 'jpeg', 'png', 'gif', 'JPG', 'JPEG', 'PNG', 'GIF']/" jiachenlong/backend/app/routers/ocr.py
|
|
||||||
|
|
||||||
# 4. 检查数据库配置
|
|
||||||
cat jiachenlong/backend/.env | grep DATABASE_URL
|
|
||||||
|
|
||||||
# 5. 停止旧服务
|
|
||||||
pkill -f uvicorn
|
|
||||||
|
|
||||||
# 6. 启动新服务
|
|
||||||
cd jiachenlong/backend
|
|
||||||
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/uvicorn.log 2>&1 &
|
|
||||||
|
|
||||||
# 7. 等待启动
|
|
||||||
sleep 5
|
|
||||||
|
|
||||||
# 8. 验证
|
|
||||||
curl -s http://localhost:3000/ | head -c 100
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ 部署后验证清单
|
|
||||||
|
|
||||||
### 必须验证的项目
|
|
||||||
|
|
||||||
| # | 验证项 | 命令 | 期望结果 |
|
|
||||||
|---|--------|------|---------|
|
|
||||||
| 1 | 前端页面 | curl http://<前端IP>/ | 200 + HTML |
|
|
||||||
| 2 | 前端版本 | curl http://<前端IP>/ \| grep title | v1.2.4 |
|
|
||||||
| 3 | 前端端口 | curl http://<前端IP>:3001/ | 200 |
|
|
||||||
| 4 | 后端健康 | curl http://<后端IP>:3000/ | 200 |
|
|
||||||
| 5 | 登录功能 | curl -X POST http://<后端IP>:3000/api/auth/login -d "username=admin&password=admin123" | 返回token |
|
|
||||||
| 6 | Logo图片 | curl -I http://<前端IP>/static/images/jiachenlong-logo.png | 200 |
|
|
||||||
| 7 | 用户协议 | curl http://<前端IP>/user_agreement.html | 200 + 内容 |
|
|
||||||
| 8 | 80端口 | curl -o /dev/null -w "%{http_code}" http://<前端IP>/ | 200 |
|
|
||||||
| 9 | API代理 | curl http://<前端IP>/api/collections | JSON响应 |
|
|
||||||
|
|
||||||
### 与基准环境对比
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 以C环境为基准,对比关键文件
|
|
||||||
# C环境
|
|
||||||
curl -s http://47.103.29.111/ | grep title
|
|
||||||
# B环境
|
|
||||||
curl -s http://8.149.137.26/ | grep title
|
|
||||||
# 期望:版本号一致
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 常见错误及解决方案
|
|
||||||
|
|
||||||
### 1. git clone失败(目录已存在)
|
|
||||||
|
|
||||||
**错误**:
|
|
||||||
```
|
|
||||||
fatal: destination path . already exists and is not an empty directory.
|
|
||||||
```
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
```bash
|
|
||||||
# 方法1:先备份再删除
|
|
||||||
mv jiachenlong jiachenlong_backup
|
|
||||||
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git
|
|
||||||
|
|
||||||
# 方法2:删除后克隆
|
|
||||||
rm -rf jiachenlong
|
|
||||||
git clone http://47.253.189.47:3000/coolbot/jiachenlong.git
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. VERSION文件被覆盖
|
|
||||||
|
|
||||||
**错误**:
|
|
||||||
```
|
|
||||||
VERSION=1.2.4
|
|
||||||
# 原有内容丢失
|
|
||||||
```
|
|
||||||
|
|
||||||
**解决**:使用sed修改而非echo覆盖
|
|
||||||
```bash
|
|
||||||
# 错误方法
|
|
||||||
echo "VERSION=1.2.4" > VERSION # ❌ 会覆盖整个文件
|
|
||||||
|
|
||||||
# 正确方法
|
|
||||||
sed -i s/^VERSION=.*/VERSION=1.2.4/ VERSION # ✅ 只修改VERSION行
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 版本号显示vv1.2.4
|
|
||||||
|
|
||||||
**原因**:VERSION文件带v前缀 + vite.config.js又加v
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
```bash
|
|
||||||
# VERSION文件不要带v
|
|
||||||
VERSION=1.2.4 # ✅
|
|
||||||
# 不是 VERSION=v1.2.4
|
|
||||||
|
|
||||||
# 源index.html如有vv先修复
|
|
||||||
sed -i s/vv/v/g index.html
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. 首页Logo显示404
|
|
||||||
|
|
||||||
**原因**:static目录未部署
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
```bash
|
|
||||||
# 部署时必须复制static目录
|
|
||||||
cp -r ../static /var/www/mobile/
|
|
||||||
cp -r ../static /var/www/frontend/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. 浏览器缓存旧JS
|
|
||||||
|
|
||||||
**原因**:JS文件名hash未变
|
|
||||||
|
|
||||||
**解决**:首页Logo添加版本号
|
|
||||||
```jsx
|
|
||||||
// Home.jsx
|
|
||||||
<img src="/static/images/jiachenlong-logo.png?v=1.2.4" ... />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Nginx 80端口返回403
|
|
||||||
|
|
||||||
**原因**:root目录为空或不存在
|
|
||||||
|
|
||||||
**解决**:
|
|
||||||
```bash
|
|
||||||
# 检查目录
|
|
||||||
ls -la /var/www/frontend/
|
|
||||||
|
|
||||||
# 部署到正确目录
|
|
||||||
cp -r dist/* /var/www/frontend/
|
|
||||||
|
|
||||||
# 重载Nginx
|
|
||||||
nginx -s reload
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 环境配置参考
|
|
||||||
|
|
||||||
### A环境(生产)
|
|
||||||
|
|
||||||
| 服务 | IP | 端口 |
|
|
||||||
|------|-----|------|
|
|
||||||
| 前端 | 8.154.46.3 | 80, 3001 |
|
|
||||||
| 后端 | 42.121.116.25 | 3000 |
|
|
||||||
| 数据库 | 47.98.171.101 | 5432 |
|
|
||||||
|
|
||||||
### B环境(灰度)
|
|
||||||
|
|
||||||
| 服务 | IP | 端口 |
|
|
||||||
|------|-----|------|
|
|
||||||
| 前端 | 8.149.137.26 | 80, 3001 |
|
|
||||||
| 后端 | 47.110.37.129 | 3000 |
|
|
||||||
| 数据库 | 47.96.181.36 | 5432 |
|
|
||||||
|
|
||||||
### C环境(测试)
|
|
||||||
|
|
||||||
| 服务 | IP | 端口 |
|
|
||||||
|------|-----|------|
|
|
||||||
| 前端 | 47.103.29.111 | 80 |
|
|
||||||
| 后端 | 47.103.9.192 | 3000 |
|
|
||||||
| 数据库 | 47.103.9.192 | 5432 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 部署记录模板
|
|
||||||
|
|
||||||
每次部署后填写:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 部署记录
|
|
||||||
|
|
||||||
### 2026-03-21 v1.2.4
|
|
||||||
|
|
||||||
| 环境 | 部署时间 | 操作人 | 结果 |
|
|
||||||
|------|---------|--------|------|
|
|
||||||
| B环境 | 00:27 | 甲辰生产 | ✅ 成功 |
|
|
||||||
|
|
||||||
### 部署命令
|
|
||||||
```bash
|
|
||||||
# 前端
|
|
||||||
ssh root@8.149.137.26
|
|
||||||
cd /root/jiachenlong/frontend
|
|
||||||
npm run build
|
|
||||||
cp -r dist/* /var/www/mobile/
|
|
||||||
cp -r dist/* /var/www/frontend/
|
|
||||||
|
|
||||||
# 后端
|
|
||||||
ssh root@47.110.37.129
|
|
||||||
pkill -f uvicorn
|
|
||||||
cd /root/jiachenlong/backend
|
|
||||||
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 &
|
|
||||||
```
|
|
||||||
|
|
||||||
### 验证结果
|
|
||||||
- 前端版本:v1.2.4 ✅
|
|
||||||
- 后端健康:200 ✅
|
|
||||||
- Logo显示:200 ✅
|
|
||||||
|
|
||||||
### 问题记录
|
|
||||||
无
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**文档结束**
|
|
||||||
186
docs/测试环境部署指南.md
|
|
@ -1,186 +0,0 @@
|
||||||
# 测试环境部署指南
|
|
||||||
|
|
||||||
## 测试环境架构
|
|
||||||
|
|
||||||
| 服务器 | IP | 服务 |
|
|
||||||
|--------|-----|------|
|
|
||||||
| 测试机1 | 47.103.29.111 | 前端 (Nginx) |
|
|
||||||
| 测试机2 | 47.103.9.192 | 后端 (FastAPI) + PostgreSQL |
|
|
||||||
|
|
||||||
## 部署步骤
|
|
||||||
|
|
||||||
### 1. 测试机1 - 前端部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 拉取代码
|
|
||||||
cd /root/jiachenlong
|
|
||||||
git fetch --all
|
|
||||||
git checkout v1.1.21
|
|
||||||
|
|
||||||
# 安装依赖并构建
|
|
||||||
cd frontend
|
|
||||||
npm install
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 复制静态文件到可访问目录
|
|
||||||
mkdir -p /var/www/html
|
|
||||||
cp -r dist/* /var/www/html/
|
|
||||||
cp -r ../static /var/www/html/
|
|
||||||
chmod -R 755 /var/www/html
|
|
||||||
|
|
||||||
# 配置Nginx
|
|
||||||
cat > /etc/nginx/conf.d/jiachenlong-test.conf << 'EOF'
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /var/www/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
client_max_body_size 20M;
|
|
||||||
|
|
||||||
# 静态资源(logo、图片等)
|
|
||||||
location /static {
|
|
||||||
alias /var/www/html/static;
|
|
||||||
expires 30d;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
# SPA路由
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# API代理到后端
|
|
||||||
location /api {
|
|
||||||
proxy_pass http://47.103.9.192:3000/api;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
nginx -t && systemctl enable nginx && systemctl restart nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 测试机2 - 后端部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 拉取代码
|
|
||||||
cd /root/jiachenlong
|
|
||||||
git fetch --all
|
|
||||||
git checkout v1.1.21
|
|
||||||
|
|
||||||
# 安装Python依赖
|
|
||||||
pip3 install fastapi uvicorn sqlalchemy psycopg2-binary pydantic python-jose bcrypt python-multipart pillow dashscope alibabacloud-dysmsapi20170525 oss2 email-validator httpx python-dotenv
|
|
||||||
|
|
||||||
# 创建环境变量文件
|
|
||||||
cat > backend/.env << 'EOF'
|
|
||||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/zodiac
|
|
||||||
SECRET_KEY=test-secret-key-for-sms
|
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
|
||||||
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
|
|
||||||
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
|
|
||||||
OSS_BUCKET=jiachenlong-oss
|
|
||||||
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
|
||||||
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
|
|
||||||
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
|
|
||||||
SMS_SIGN_NAME=苏州算力
|
|
||||||
SMS_TEMPLATE_CODE=SMS_501590956
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 启动后端
|
|
||||||
cd backend
|
|
||||||
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 > /tmp/backend.log 2>&1 &
|
|
||||||
|
|
||||||
# 复制静态文件
|
|
||||||
mkdir -p /var/www/html/static
|
|
||||||
cp -r ../static/* /var/www/html/static/
|
|
||||||
chmod -R 755 /var/www/html/static
|
|
||||||
|
|
||||||
# 配置Nginx
|
|
||||||
cat > /etc/nginx/conf.d/jiachenlong-test.conf << 'EOF'
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
|
|
||||||
root /var/www/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
client_max_body_size 20M;
|
|
||||||
|
|
||||||
location /static {
|
|
||||||
alias /var/www/html/static;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /api {
|
|
||||||
proxy_pass http://127.0.0.1:3000/api;
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
nginx -t && systemctl enable nginx && systemctl restart nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### 问题1: Logo不显示
|
|
||||||
|
|
||||||
**原因:**
|
|
||||||
1. Nginx未配置`/static`路径,SPA路由捕获了请求
|
|
||||||
2. 静态文件在`/root`目录下,nginx无权限读取
|
|
||||||
|
|
||||||
**解决:**
|
|
||||||
1. 添加`location /static`配置
|
|
||||||
2. 将静态文件复制到`/var/www/html/static`
|
|
||||||
|
|
||||||
### 问题2: 短信发送失败 "找不到模板"
|
|
||||||
|
|
||||||
**原因:** sms.py中的默认配置未更新
|
|
||||||
|
|
||||||
**解决:** 修改`backend/app/services/sms.py`:
|
|
||||||
```python
|
|
||||||
SMS_CONFIG = {
|
|
||||||
"access_key_id": os.getenv("SMS_ACCESS_KEY_ID", "LTAI5tQAx5niD7JQVqGE5acE"),
|
|
||||||
"access_key_secret": os.getenv("SMS_ACCESS_KEY_SECRET", "QsQFAEKBkaNynIoKyvdIi3BUyWVZu1"),
|
|
||||||
"sign_name": os.getenv("SMS_SIGN_NAME", "苏州算力"),
|
|
||||||
"template_code": os.getenv("SMS_TEMPLATE_CODE", "SMS_501590956"),
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 问题3: 数据库连接失败 "Ident authentication failed"
|
|
||||||
|
|
||||||
**原因:** PostgreSQL默认使用ident认证
|
|
||||||
|
|
||||||
**解决:**
|
|
||||||
```bash
|
|
||||||
sed -i 's/ident/trust/g' /var/lib/pgsql/data/pg_hba.conf
|
|
||||||
systemctl restart postgresql
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验证命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 测试机1验证
|
|
||||||
curl -s -o /dev/null -w "%{http_code}" http://47.103.29.111/
|
|
||||||
curl -s -o /dev/null -w "%{http_code}" http://47.103.29.111/static/images/jiachenlong-logo.png
|
|
||||||
|
|
||||||
# 测试机2验证
|
|
||||||
curl -s http://47.103.9.192:3000/
|
|
||||||
curl -s -o /dev/null -w "%{http_code}" http://47.103.9.192/static/images/jiachenlong-logo.png
|
|
||||||
```
|
|
||||||
|
|
||||||
## 短信配置
|
|
||||||
|
|
||||||
| 项目 | 值 |
|
|
||||||
|------|-----|
|
|
||||||
| 模板Code | SMS_501590956 |
|
|
||||||
| 签名 | 苏州算力 |
|
|
||||||
| AccessKey ID | LTAI5tQAx5niD7JQVqGE5acE |
|
|
||||||
| AccessKey Secret | QsQFAEKBkaNynIoKyvdIi3BUyWVZu1 |
|
|
||||||
|
|
@ -66,7 +66,6 @@ tailwindcss@3
|
||||||
| 版本 | 日期 | 更新人 | 更新内容 |
|
| 版本 | 日期 | 更新人 | 更新内容 |
|
||||||
|------|------|--------|---------|
|
|------|------|--------|---------|
|
||||||
| v1.2 | 2026-03-15 | 菜鸟小 D | 添加详细版本要求和关键注意事项 |
|
| v1.2 | 2026-03-15 | 菜鸟小 D | 添加详细版本要求和关键注意事项 |
|
||||||
| v1.2.4 | 2026-03-20 | 甲辰生产 | 修复OCR扩展名大小写问题、修复用户协议乱码、添加返回按钮 |
|
|
||||||
| v1.1 | 2026-03-15 | 菜鸟小 D | 根据实地检查更新实际配置 |
|
| v1.1 | 2026-03-15 | 菜鸟小 D | 根据实地检查更新实际配置 |
|
||||||
| v1.0 | 2026-03-15 | 菜鸟小 D | 初始版本 |
|
| v1.0 | 2026-03-15 | 菜鸟小 D | 初始版本 |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,72 +0,0 @@
|
||||||
# 部署检查清单
|
|
||||||
|
|
||||||
## 部署后必须检查
|
|
||||||
|
|
||||||
### 1. 环境变量检查
|
|
||||||
```bash
|
|
||||||
cat backend/.env
|
|
||||||
```
|
|
||||||
必须包含:
|
|
||||||
- ✅ DASHSCOPE_API_KEY(不是sk-xxx)
|
|
||||||
- ✅ SMS_ACCESS_KEY_ID
|
|
||||||
- ✅ SMS_SIGN_NAME
|
|
||||||
- ✅ SMS_TEMPLATE_CODE
|
|
||||||
|
|
||||||
### 2. 代码版本检查
|
|
||||||
```bash
|
|
||||||
git log --oneline -1
|
|
||||||
```
|
|
||||||
确认是目标版本
|
|
||||||
|
|
||||||
### 3. OCR扩展名检查
|
|
||||||
```bash
|
|
||||||
grep temp_extensions backend/app/routers/ocr.py
|
|
||||||
```
|
|
||||||
确认包含大小写扩展名 JPG JPEG PNG GIF
|
|
||||||
|
|
||||||
### 4. Python版本检查
|
|
||||||
```bash
|
|
||||||
python3 --version
|
|
||||||
```
|
|
||||||
必须是 3.11+
|
|
||||||
|
|
||||||
### 5. 服务启动检查
|
|
||||||
```bash
|
|
||||||
curl http://localhost:3000/
|
|
||||||
```
|
|
||||||
返回 200
|
|
||||||
|
|
||||||
### 6. API测试
|
|
||||||
```bash
|
|
||||||
# 登录
|
|
||||||
TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/login \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
-d "username=admin&password=admin123" | python3 -c "import sys,json; print(json.load(sys.stdin).get(access_token,))")
|
|
||||||
|
|
||||||
# 测试验证码
|
|
||||||
curl -X POST http://localhost:3000/api/auth/send-verification-code \
|
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"phone":"13800138000"}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常见问题快速修复
|
|
||||||
|
|
||||||
| 问题 | 修复命令 |
|
|
||||||
|------|---------|
|
|
||||||
| OCR慢 | 检查DASHSCOPE_API_KEY是否正确 |
|
|
||||||
| 验证码失败 | 检查SMS_*配置是否完整 |
|
|
||||||
| 导入错误 | 使用python3.11启动 |
|
|
||||||
| 404错误 | 重启后端服务 |
|
|
||||||
|
|
||||||
### 7. 用户协议页面检查
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
确认返回HTML内容
|
|
||||||
|
|
||||||
### 8. 前端静态文件检查
|
|
||||||
|
|
||||||
确认用户协议文件存在
|
|
||||||
8
env.conf
|
|
@ -1,8 +0,0 @@
|
||||||
DATABASE_URL=postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong
|
|
||||||
SECRET_KEY=production-secret-key-b-env
|
|
||||||
OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
|
|
||||||
OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
|
|
||||||
SMS_ACCESS_KEY_ID=LTAI5tQAx5niD7JQVqGE5acE
|
|
||||||
SMS_ACCESS_KEY_SECRET=QsQFAEKBkaNynIoKyvdIi3BUyWVZu1
|
|
||||||
SMS_SIGN_NAME=苏州算力
|
|
||||||
SMS_TEMPLATE_CODE=SMS_501590956
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
VERSION=1.2.97
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
1.2.98
|
|
||||||
|
|
@ -4,10 +4,10 @@
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<title>甲辰收藏 v=1.2.97</title>
|
<title>甲辰收藏 v1.1.19</title>
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="/static/icons/favicon.png" />
|
<link rel="icon" type="image/x-icon" href="/static/icons/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png" />
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "jiachenlong-frontend",
|
"name": "jiachenlong-frontend",
|
||||||
"version": "1.2.82",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "jiachenlong-frontend",
|
"name": "jiachenlong-frontend",
|
||||||
"version": "1.2.82",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"vite": "^6.4.2"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
|
|
@ -2047,9 +2047,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.4.2",
|
"version": "6.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||||
"integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "jiachenlong-frontend",
|
"name": "jiachenlong-frontend",
|
||||||
"version": "1.2.82",
|
"version": "1.0.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "甲辰藏品管理系统 - 移动端前端",
|
"description": "甲辰藏品管理系统 - 移动端前端",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -9,13 +9,13 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.7.9",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^7.1.0"
|
"react-router-dom": "^7.1.0",
|
||||||
|
"axios": "^1.7.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"vite": "^6.4.2"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
v=1.2.80
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const src = path.join(__dirname, 'static', 'images');
|
|
||||||
const dst = path.join(__dirname, 'dist', 'static', 'images');
|
|
||||||
|
|
||||||
if (!fs.existsSync(dst)) {
|
|
||||||
fs.mkdirSync(dst, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(src)) {
|
|
||||||
fs.readdirSync(src).forEach(f => {
|
|
||||||
const srcFile = path.join(src, f);
|
|
||||||
const dstFile = path.join(dst, f);
|
|
||||||
fs.copyFileSync(srcFile, dstFile);
|
|
||||||
console.log('Copied:', f);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Logo复制完成');
|
|
||||||
|
Before Width: | Height: | Size: 133 KiB |
|
|
@ -1,26 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 80">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#FFE4B5"/>
|
|
||||||
<stop offset="25%" stop-color="#FFD700"/>
|
|
||||||
<stop offset="50%" stop-color="#FFA500"/>
|
|
||||||
<stop offset="75%" stop-color="#DAA520"/>
|
|
||||||
<stop offset="100%" stop-color="#B8860B"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="glow">
|
|
||||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
|
||||||
<feFlood flood-color="#FFD700" flood-opacity="0.6"/>
|
|
||||||
<feComposite in2="blur" operator="in"/>
|
|
||||||
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
||||||
</filter>
|
|
||||||
<filter id="shadow">
|
|
||||||
<feDropShadow dx="2" dy="3" stdDeviation="2" flood-color="#000" flood-opacity="0.5"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Main title -->
|
|
||||||
<text x="0" y="45" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="52" font-weight="bold" fill="url(#goldGrad)" filter="url(#shadow)">甲辰收藏</text>
|
|
||||||
|
|
||||||
<!-- Subtitle -->
|
|
||||||
<text x="0" y="72" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="20" fill="#DAA520" letter-spacing="4">生肖纪念钞管理系统</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 133 KiB |
|
|
@ -1,115 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
||||||
<title>用户协议 - 甲辰收藏</title>
|
|
||||||
<style>
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Helvetica Neue', sans-serif; padding: 20px; max-width: 800px; margin: 0 auto; line-height: 1.8; background: #f5f5f5; }
|
|
||||||
h1 { text-align: center; color: #1a1a1a; font-size: 24px; margin-bottom: 30px; }
|
|
||||||
h2 { color: #333; font-size: 18px; margin-top: 30px; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 8px; }
|
|
||||||
h3 { color: #555; font-size: 15px; margin-top: 20px; margin-bottom: 10px; }
|
|
||||||
.content { color: #444; font-size: 14px; background: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.05); }
|
|
||||||
.content p { margin: 10px 0; }
|
|
||||||
.highlight { font-weight: bold; }
|
|
||||||
.version { text-align: center; color: #999; font-size: 12px; margin-bottom: 20px; }
|
|
||||||
ul { padding-left: 20px; }
|
|
||||||
li { margin: 5px 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="content">
|
|
||||||
<h1>甲辰收藏平台用户协议</h1>
|
|
||||||
<div class="version">版本生效日期:自注册之日起</div>
|
|
||||||
|
|
||||||
<p><span class="highlight">特别提示:</span>本协议涉及您的重大权利义务,加粗条款请您重点阅读。您完成注册、登录或继续使用本平台服务,即视为已充分阅读、理解并同意本协议全部内容。本协议适用《中华人民共和国民法典》《网络安全法》《个人信息保护法》《电子商务法》等法律法规。</p>
|
|
||||||
|
|
||||||
<h2>一、协议主体与适用范围</h2>
|
|
||||||
<ol>
|
|
||||||
<li>本协议由您与甲辰收藏平台运营主体(以下简称"平台")签订,约束您使用平台藏品管理,信息展示,数据存储,社区互动等全部服务。</li>
|
|
||||||
<li>平台发布的隐私政策、藏品发布规则、交易规范等均为本协议组成部分,与本协议具有同等法律效力。</li>
|
|
||||||
<li>您承诺为具备完全民事行为能力的自然人/法人;未成年人使用需监护人同意并陪同。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>二、个人信息收集与使用</h2>
|
|
||||||
<h3>(一)收集原则</h3>
|
|
||||||
<p>平台遵循合法、正当、必要、诚信、最小必要原则收集信息,仅为实现服务功能所必需,不强制收集非必要信息。</p>
|
|
||||||
|
|
||||||
<h3>(二)收集范围与目的</h3>
|
|
||||||
<ol>
|
|
||||||
<li><strong>注册与身份信息:</strong>用户名、手机号、邮箱(用于账号创建,安全验证、客服联系)。</li>
|
|
||||||
<li><strong>藏品与行为信息:</strong>藏品上传内容、收藏记录、浏览操作、发布评论、交易数据(用于藏品管理,服务优化、风控核验)。</li>
|
|
||||||
<li><strong>设备与网络信息:</strong>设备型号、系统版本、IP 地址、日志信息(用于安全防护、故障排查,防作弊)。</li>
|
|
||||||
<li><strong>位置信息:</strong>仅在您主动开启定位权限时收集,用于同城展示等可选功能,关闭不影响基础服务。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h3>(三)信息使用规则</h3>
|
|
||||||
<ol>
|
|
||||||
<li>平台仅在您授权范围内使用信息,不超出约定目的、范围与期限处理。</li>
|
|
||||||
<li>向第三方共享信息时,将单独告知并取得您明确同意,法律法规要求除外。</li>
|
|
||||||
<li>您有权查阅、更正、删除个人信息,申请注销账号,平台在核验身份后依法处理。</li>
|
|
||||||
<li>平台采取加密、去标识化、权限管控等措施保护信息安全,制定安全事件应急预案。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>三、公开信息使用与免责条款</h2>
|
|
||||||
<ol>
|
|
||||||
<li><strong>公开信息定义:</strong>您通过平台主动发布、设置为公开可见的藏品资料、图文、评论、动态等内容,均属于您自行公开的信息。</li>
|
|
||||||
<li><strong>授权使用:</strong>您同意平台可在合理范围内使用您的公开信息,用于藏品展示、平台运营、合规审核、服务推广等,不侵犯您合法权益。</li>
|
|
||||||
<li><strong>法定免责依据:</strong>根据《个人信息保护法》第二十七条,平台处理您自行合法公开的信息,在无明确拒绝且不对您权益造成重大影响的情形下,无需另行取得单独同意。</li>
|
|
||||||
<li><strong>第三方行为免责:</strong>
|
|
||||||
<ul>
|
|
||||||
<li>公开信息可被其他用户浏览、复制、存储、转发,平台无法完全控制第三方使用行为。</li>
|
|
||||||
<li>因第三方擅自使用、转载、篡改您公开信息引发的纠纷、损失,平台不承担责任。</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li><strong>内容责任自负:</strong>
|
|
||||||
<ul>
|
|
||||||
<li>您对公开信息的真实性、合法性、原创性承担全部责任,不得侵犯第三方知识产权、肖像权、名誉权等。</li>
|
|
||||||
<li>如因您发布违法、侵权内容导致平台受损,您应承担全部赔偿责任。</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li><strong>平台管理边界:</strong>平台仅依据法律法规与平台规则进行内容审核,对用户自主发布的公开信息不做修改、篡改,不承担真实性担保责任。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>四、用户权利与义务</h2>
|
|
||||||
<ol>
|
|
||||||
<li>您有权使用平台基础服务,对账号与密码安全负责,不得转借、出售账号。</li>
|
|
||||||
<li>不得发布违法违规、侵权、虚假、低俗等违反法律法规与公序良俗的内容。</li>
|
|
||||||
<li>不得利用平台从事洗钱、诈骗、非法交易等违法活动。</li>
|
|
||||||
<li>您对自行上传的藏品素材、文字等享有知识产权,授权平台在服务范围内非独占使用。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>五、知识产权条款</h2>
|
|
||||||
<ol>
|
|
||||||
<li>平台所有文字、图标、界面设计、软件代码等知识产权归平台所有,受法律保护。</li>
|
|
||||||
<li>您上传的原创内容知识产权归您所有;您授权平台为提供服务之目的,使用、存储、展示、传播该内容。</li>
|
|
||||||
<li>未经权利人书面许可,任何主体不得复制、改编、传播、商用平台内容或用户原创内容。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>六、服务变更、中断与终止</h2>
|
|
||||||
<ol>
|
|
||||||
<li>平台因维护、升级、政策调整需变更或暂停服务的,将提前公示;因不可抗力、监管要求、第三方故障导致服务中断的,平台不承担违约责任。</li>
|
|
||||||
<li>平台有权对违规账号采取警示、限流、删帖、封禁等处理措施。</li>
|
|
||||||
<li>您可随时停止使用服务;账号注销后,平台按法律法规留存相关数据,逾期依法删除。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>七、免责声明(法律允许范围内)</h2>
|
|
||||||
<ol>
|
|
||||||
<li>平台按"现状"提供服务,对服务及时性、安全性、稳定性不作绝对担保。</li>
|
|
||||||
<li>平台不对藏品真伪、价值、权属作明示或暗示保证,藏品鉴定与价值判断由您自行负责。</li>
|
|
||||||
<li>法律允许的最大范围内,平台不对间接损失、利润损失、数据丢失等承担赔偿责任;因平台故意或重大过失导致的损失除外。</li>
|
|
||||||
<li>因您自身操作不当、账号保管不善、第三方侵权等导致的损失,由您自行承担。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>八、协议修改与争议解决</h2>
|
|
||||||
<ol>
|
|
||||||
<li>平台修改协议将提前7日公示,您继续使用视为接受修订;如不同意,可停止使用并注销账号。</li>
|
|
||||||
<li>因本协议产生争议,双方协商解决;协商不成,提交平台运营主体所在地有管辖权的人民法院诉讼解决。</li>
|
|
||||||
<li>本协议适用中华人民共和国大陆地区法律(不含港澳台法律)。</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<h2>九、联系与通知</h2>
|
|
||||||
<p>平台联系方式:aicoolbot@163.com;您可通过客服渠道咨询协议、隐私、投诉等相关事宜。</p>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -4,7 +4,6 @@ import Settings from './pages/Settings'
|
||||||
import List from './pages/List'
|
import List from './pages/List'
|
||||||
import Add from './pages/Add'
|
import Add from './pages/Add'
|
||||||
import Stats from './pages/Stats'
|
import Stats from './pages/Stats'
|
||||||
import News from './pages/News'
|
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Detail from './pages/Detail'
|
import Detail from './pages/Detail'
|
||||||
import Edit from './pages/Edit'
|
import Edit from './pages/Edit'
|
||||||
|
|
@ -29,7 +28,6 @@ export default function App() {
|
||||||
const getComponent = () => {
|
const getComponent = () => {
|
||||||
const basePath = path.split('?')[0]
|
const basePath = path.split('?')[0]
|
||||||
if (basePath === '/') return <Home />
|
if (basePath === '/') return <Home />
|
||||||
if (basePath === '/news') return <News />
|
|
||||||
if (basePath === '/stats') return <Stats />
|
if (basePath === '/stats') return <Stats />
|
||||||
if (basePath === '/list') return <List />
|
if (basePath === '/list') return <List />
|
||||||
if (basePath === '/add') return <Add />
|
if (basePath === '/add') return <Add />
|
||||||
|
|
@ -50,7 +48,6 @@ export default function App() {
|
||||||
console.error('Parse user error:', e)
|
console.error('Parse user error:', e)
|
||||||
}
|
}
|
||||||
const isAdmin = user && user.role === 'admin'
|
const isAdmin = user && user.role === 'admin'
|
||||||
const isEditor = user && user.role === 'editor'
|
|
||||||
|
|
||||||
// 登录页面独立渲染,不显示底部导航
|
// 登录页面独立渲染,不显示底部导航
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
|
@ -85,10 +82,9 @@ export default function App() {
|
||||||
}}>
|
}}>
|
||||||
{[
|
{[
|
||||||
{ path: '/', icon: '🏠', label: '首页' },
|
{ path: '/', icon: '🏠', label: '首页' },
|
||||||
{ path: '/news', icon: '📰', label: '行情' },
|
|
||||||
{ path: '/stats', icon: '📊', label: '统计' },
|
{ path: '/stats', icon: '📊', label: '统计' },
|
||||||
{ path: '/list', icon: '📚', label: '我的' },
|
{ path: '/list', icon: '📚', label: '藏品' },
|
||||||
{ path: '/add', icon: '🎯', label: '录入' },
|
{ path: '/add', icon: '🎯', label: '添加' },
|
||||||
...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : [])
|
...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : [])
|
||||||
].map(tab => (
|
].map(tab => (
|
||||||
<div
|
<div
|
||||||
|
|
@ -112,4 +108,3 @@ export default function App() {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Fri Apr 10 03:44:24 PM CST 2026
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// 修改版本号只需修改 ../../VERSION 文件中的 VERSION=xxx
|
// 修改版本号只需修改 ../../VERSION 文件中的 VERSION=xxx
|
||||||
|
|
||||||
// 从环境变量读取(vite.config.js 注入)
|
// 从环境变量读取(vite.config.js 注入)
|
||||||
export const APP_VERSION = import.meta.env.APP_VERSION || '1.2.82'
|
export const APP_VERSION = import.meta.env.APP_VERSION || '1.0.0'
|
||||||
|
|
||||||
// 版本信息
|
// 版本信息
|
||||||
export const VERSION_INFO = {
|
export const VERSION_INFO = {
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,6 @@
|
||||||
/**
|
// 添加藏品页面 - 支持 AI 识别/手工录入/批量录入
|
||||||
* Add - 添加藏品页面
|
|
||||||
* Version: 1.2.90x
|
|
||||||
* 更新:
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useRef } from 'react'
|
import React, { useState, useRef } from 'react'
|
||||||
import { APP_VERSION } from '../config/version'
|
import { APP_VERSION } from '../config/version'
|
||||||
const API_BASE = localStorage.getItem('API_BASE') || ''
|
|
||||||
const numberCategoryOptions = [{value:'无2347',label:'无2347'},{value:'无347',label:'无347'},{value:'无247',label:'无247'},{value:'无47',label:'无47'},{value:'带7号',label:'带7号'},{value:'带4号',label:'带4号'},{value:'其他',label:'其他'}];
|
|
||||||
|
|
||||||
// 字段转换函数
|
// 字段转换函数
|
||||||
const convertField = (obj) => {
|
const convertField = (obj) => {
|
||||||
|
|
@ -19,7 +12,7 @@ const convertField = (obj) => {
|
||||||
packaging: 'f02_12_packaging', rarity: 'f02_13_rarity',
|
packaging: 'f02_12_packaging', rarity: 'f02_13_rarity',
|
||||||
isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company',
|
isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company',
|
||||||
gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star',
|
gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star',
|
||||||
specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature', numberCategory: 'f02_14_number_category',
|
specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature',
|
||||||
issuer: 'f04_32_issuer', issueYear: 'f04_33_issue_year',
|
issuer: 'f04_32_issuer', issueYear: 'f04_33_issue_year',
|
||||||
material: 'f04_34_material', denomination: 'f04_35_denomination',
|
material: 'f04_34_material', denomination: 'f04_35_denomination',
|
||||||
issueQuantity: 'f04_36_issue_quantity',
|
issueQuantity: 'f04_36_issue_quantity',
|
||||||
|
|
@ -68,66 +61,21 @@ const Input = ({ form, handleChange, label, field, type = 'text', options = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultForm = () => ({
|
const getDefaultForm = () => ({
|
||||||
name: '藏品', code: '', category: '自持', rarity: '通货', prefixSerial: '',
|
name: '龙钞', code: '', category: '自持', rarity: '通货', prefixSerial: '',
|
||||||
version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
|
version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
|
||||||
material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
|
material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
|
||||||
gradingCompany: '爱藏', gradingScore: '67+', threeStar: false, specialMark: '',
|
gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '',
|
||||||
serialFeature: '', numberCategory: '', issuer: '中国人民银行', issueYear: '2024',
|
serialFeature: '', issuer: '中国人民银行', issueYear: '2024',
|
||||||
costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: ''
|
costPrice: '', targetPrice: '', goalPrice: '', repairFee: '', gradingFee: '', remark: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
export default function Add() {
|
export default function Add() {
|
||||||
// 行情录入表单
|
|
||||||
const [dealForm, setDealForm] = useState({
|
|
||||||
serial: '',
|
|
||||||
category: '',
|
|
||||||
packaging: '标十',
|
|
||||||
price: '',
|
|
||||||
platform: '抖音',
|
|
||||||
seller: '',
|
|
||||||
buyer: '',
|
|
||||||
date: new Date().toISOString().split('T')[0],
|
|
||||||
isGraded: false,
|
|
||||||
gradingCompany: '爱藏',
|
|
||||||
gradingScore: '67+'
|
|
||||||
})
|
|
||||||
const [batchDefaultPackaging, setBatchDefaultPackaging] = useState('')
|
|
||||||
const [batchDefaultDate, setBatchDefaultDate] = useState('')
|
|
||||||
const [batchDefaultPlatform, setBatchDefaultPlatform] = useState('抖音')
|
|
||||||
const [dealMode, setDealMode] = useState('single') // single-单条录入, batch-批量录入
|
|
||||||
const [batchText, setBatchText] = useState('')
|
|
||||||
const [batchResult, setBatchResult] = useState([])
|
|
||||||
const [parsing, setParsing] = useState(false)
|
|
||||||
const [savingDeal, setSavingDeal] = useState(false)
|
|
||||||
|
|
||||||
// 号码分类函数
|
|
||||||
const autoCategory = (serial) => {
|
|
||||||
const digits = serial.replace('J', '').replace(/[^0-9]/g, '').slice(0, 9)
|
|
||||||
if (!digits) return ''
|
|
||||||
const d = digits
|
|
||||||
if (!d.includes('1') && !d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '圆圆号'
|
|
||||||
if (!d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '倒置号'
|
|
||||||
if (!d.includes('1') && !d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '金马王'
|
|
||||||
if (!d.includes('2') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '金马号'
|
|
||||||
if (!d.includes('1') && !d.includes('2') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '金山王'
|
|
||||||
if (!d.includes('1') && !d.includes('2') && !d.includes('4') && !d.includes('7')) return '天马王'
|
|
||||||
if (!d.includes('2') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '金山号'
|
|
||||||
if (!d.includes('2') && !d.includes('4') && !d.includes('7')) return '天马号'
|
|
||||||
if (!d.includes('1') && !d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '朦胧王'
|
|
||||||
if (!d.includes('3') && !d.includes('4') && !d.includes('5') && !d.includes('7')) return '朦胧号'
|
|
||||||
if (!d.includes('1') && !d.includes('3') && !d.includes('4') && !d.includes('7')) return '如意号'
|
|
||||||
if (!d.includes('3') && !d.includes('4') && !d.includes('7')) return '钻石号'
|
|
||||||
if (!d.includes('4') && !d.includes('7')) return '永恒号'
|
|
||||||
if (!d.includes('4')) return '带7号'
|
|
||||||
return '带4号'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 根据 URL 参数确定默认标签
|
// 根据 URL 参数确定默认标签
|
||||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||||
const modeParam = params.get('mode')
|
const modeParam = params.get('mode')
|
||||||
const defaultTab = modeParam === 'manual' ? 'manual' : 'ai'
|
const defaultTab = modeParam === 'manual' ? 'manual' : 'ai'
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, deal
|
const [activeTab, setActiveTab] = useState(defaultTab) // ai, manual, batch
|
||||||
const [form, setForm] = useState(getDefaultForm())
|
const [form, setForm] = useState(getDefaultForm())
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
@ -198,23 +146,6 @@ export default function Add() {
|
||||||
if (key === 'goalPrice' && value) {
|
if (key === 'goalPrice' && value) {
|
||||||
setForm(prev => ({ ...prev, status: 'sold' }))
|
setForm(prev => ({ ...prev, status: 'sold' }))
|
||||||
}
|
}
|
||||||
// 冠字号变化时自动分类
|
|
||||||
if (key === 'prefixSerial') {
|
|
||||||
const serial = value
|
|
||||||
let cat = ''
|
|
||||||
const match = serial.match(/J(\d{9})/)
|
|
||||||
const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9)
|
|
||||||
if (digits) {
|
|
||||||
if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347'
|
|
||||||
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
|
|
||||||
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
|
|
||||||
else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47'
|
|
||||||
else if (digits.includes('7') && !digits.includes('4')) cat = '带7号'
|
|
||||||
else if (digits.includes('4')) cat = '带4号'
|
|
||||||
else cat = '其他'
|
|
||||||
}
|
|
||||||
setForm(prev => ({ ...prev, numberCategory: cat }))
|
|
||||||
}
|
|
||||||
// 版别变化时同步发行年份
|
// 版别变化时同步发行年份
|
||||||
if (key === 'version') {
|
if (key === 'version') {
|
||||||
const yearMatch = value.match(/(20\d{2})/)
|
const yearMatch = value.match(/(20\d{2})/)
|
||||||
|
|
@ -313,26 +244,7 @@ export default function Add() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
// 保存前自动根据冠字号设置号码分类
|
const formData = convertField(form)
|
||||||
const formWithCategory = { ...form }
|
|
||||||
if (formWithCategory.prefixSerial && !formWithCategory.numberCategory) {
|
|
||||||
const serial = formWithCategory.prefixSerial
|
|
||||||
const match = serial.match(/J(\d{9})/)
|
|
||||||
const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9)
|
|
||||||
if (digits) {
|
|
||||||
let cat = ''
|
|
||||||
if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347'
|
|
||||||
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
|
|
||||||
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
|
|
||||||
else if (!digits.includes('4') && !digits.includes('7') && digits.includes('2') && digits.includes('3')) cat = '无47'
|
|
||||||
else if (digits.includes('7') && !digits.includes('4')) cat = '带7号'
|
|
||||||
else if (digits.includes('4')) cat = '带4号'
|
|
||||||
else cat = '其他'
|
|
||||||
formWithCategory.numberCategory = cat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = convertField(formWithCategory)
|
|
||||||
try {
|
try {
|
||||||
// 1. 保存藏品信息(先检查是否重复)
|
// 1. 保存藏品信息(先检查是否重复)
|
||||||
const res = await fetch('/api/collections', {
|
const res = await fetch('/api/collections', {
|
||||||
|
|
@ -347,11 +259,6 @@ export default function Add() {
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
let collectionId = result.f99_90_id || result.id
|
let collectionId = result.f99_90_id || result.id
|
||||||
|
|
||||||
// 检查是否有错误(重复编号等)
|
|
||||||
if (result.error) {
|
|
||||||
throw new Error(result.error.message || '保存失败')
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否有重复警告
|
// 检查是否有重复警告
|
||||||
if (result.warning && result.warning.code === 'DUPLICATE_SERIAL') {
|
if (result.warning && result.warning.code === 'DUPLICATE_SERIAL') {
|
||||||
const { warning } = result
|
const { warning } = result
|
||||||
|
|
@ -456,15 +363,15 @@ export default function Add() {
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<button onClick={() => setActiveTab('ai')}
|
<button onClick={() => setActiveTab('ai')}
|
||||||
style={{ flex: 1, padding: '10px', background: activeTab === 'ai' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'ai' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
|
style={{ flex: 1, padding: '10px', background: activeTab === 'ai' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'ai' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
|
||||||
AI 识别
|
🤖 AI 识别
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setActiveTab('manual')}
|
<button onClick={() => setActiveTab('manual')}
|
||||||
style={{ flex: 1, padding: '10px', background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'manual' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
|
style={{ flex: 1, padding: '10px', background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'manual' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
|
||||||
手工录入
|
✍️ 手工录入
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => setActiveTab('deal')}
|
<button onClick={() => setActiveTab('batch')} disabled
|
||||||
style={{ flex: 1, padding: '10px', background: activeTab === 'deal' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'deal' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>
|
style={{ flex: 1, padding: '10px', background: 'rgba(255,255,255,0.02)', color: '#64748b', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'not-allowed' }}>
|
||||||
行情录入
|
📦 批量录入
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -486,7 +393,7 @@ export default function Add() {
|
||||||
style={{ padding: '12px 24px', background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: '1px solid #ef4444', borderRadius: '8px', fontSize: '14px', cursor: 'pointer' }}>🗑️ 重新选择</button>
|
style={{ padding: '12px 24px', background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: '1px solid #ef4444', borderRadius: '8px', fontSize: '14px', cursor: 'pointer' }}>🗑️ 重新选择</button>
|
||||||
<button onClick={handleRecognize} disabled={recognizing}
|
<button onClick={handleRecognize} disabled={recognizing}
|
||||||
style={{ padding: '12px 24px', background: recognizing ? '#64748b' : '#fbbf24', color: recognizing ? '#94a3b8' : '#1e293b', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: recognizing ? 'not-allowed' : 'pointer' }}>
|
style={{ padding: '12px 24px', background: recognizing ? '#64748b' : '#fbbf24', color: recognizing ? '#94a3b8' : '#1e293b', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: recognizing ? 'not-allowed' : 'pointer' }}>
|
||||||
{recognizing ? '🔍 识别中...' : '开始识别'}
|
{recognizing ? '🔍 识别中...' : '🤖 开始识别'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -629,7 +536,6 @@ export default function Add() {
|
||||||
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
|
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
|
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
|
||||||
<Input form={form} handleChange={handleChange} label="编号(可选)" field="code" />
|
|
||||||
<Input form={form} handleChange={handleChange} label="持仓类型" field="category" options={categoryOptions} />
|
<Input form={form} handleChange={handleChange} label="持仓类型" field="category" options={categoryOptions} />
|
||||||
<Input form={form} handleChange={handleChange} label="珍惜度" field="rarity" options={rarityOptions} />
|
<Input form={form} handleChange={handleChange} label="珍惜度" field="rarity" options={rarityOptions} />
|
||||||
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
|
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
|
||||||
|
|
@ -667,7 +573,6 @@ export default function Add() {
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
|
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
|
||||||
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
|
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
|
||||||
<Input form={form} handleChange={handleChange} label="号码分类" field="numberCategory" options={numberCategoryOptions} />
|
|
||||||
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
|
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
|
||||||
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
|
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
|
||||||
<Input form={form} handleChange={handleChange} label="材质" field="material" />
|
<Input form={form} handleChange={handleChange} label="材质" field="material" />
|
||||||
|
|
@ -708,380 +613,16 @@ export default function Add() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 批量录入模式 */}
|
||||||
|
{activeTab === 'batch' && (
|
||||||
|
<div style={{ padding: '48px 16px', textAlign: 'center', color: '#94a3b8' }}>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚧</div>
|
||||||
|
<div style={{ fontSize: '16px', fontWeight: 'bold', marginBottom: '8px' }}>批量录入开发中</div>
|
||||||
|
<div style={{ fontSize: '13px' }}>敬请期待后续版本</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 版本号 */}
|
{/* 版本号 */}
|
||||||
|
|
||||||
{/* 行情录入模式 */}
|
|
||||||
{activeTab === 'deal' && (
|
|
||||||
<div style={{ padding: '16px' }}>
|
|
||||||
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '20px', marginBottom: '16px' }}>
|
|
||||||
<div style={{ color: '#fbbf24', fontSize: '16px', fontWeight: 'bold', marginBottom: '16px' }}>成交行情录入</div>
|
|
||||||
|
|
||||||
{/* 单条/批量切换 */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
|
||||||
<button onClick={() => setDealMode('single')}
|
|
||||||
style={{ flex: 1, padding: '10px', background: dealMode === 'single' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: dealMode === 'single' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
|
|
||||||
单条录入
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setDealMode('batch')}
|
|
||||||
style={{ flex: 1, padding: '10px', background: dealMode === 'batch' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: dealMode === 'batch' ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
|
|
||||||
批量录入
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 单条录入 */}
|
|
||||||
{dealMode === 'single' && (
|
|
||||||
<div>
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>冠字号 *</div>
|
|
||||||
<input value={dealForm.serial} onChange={(e) => setDealForm({...dealForm, serial: e.target.value.toUpperCase(), category: autoCategory(e.target.value)})}
|
|
||||||
placeholder="J0xxxxxxxx"
|
|
||||||
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dealForm.category && (
|
|
||||||
<div style={{ background: 'rgba(251,191,36,0.2)', padding: '10px', borderRadius: '8px', textAlign: 'center', marginBottom: '12px' }}>
|
|
||||||
<div style={{ fontSize: '12px', color: '#94a3b8' }}>号码分类</div>
|
|
||||||
<div style={{ fontSize: '14px', color: '#fbbf24', fontWeight: 'bold' }}>{dealForm.category}</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>包装类型</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
{['单张', '标十', '标百'].map(p => (
|
|
||||||
<button key={p} onClick={() => setDealForm({...dealForm, packaging: p})}
|
|
||||||
style={{ flex: 1, padding: '10px', background: dealForm.packaging === p ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: dealForm.packaging === p ? '#1e293b' : '#fff', border: 'none', borderRadius: '8px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
|
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>成交价格 *</div>
|
|
||||||
<input type="number" value={dealForm.price} onChange={(e) => setDealForm({...dealForm, price: e.target.value})}
|
|
||||||
placeholder="请输入成交价格"
|
|
||||||
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>成交平台 *</div>
|
|
||||||
<select value={dealForm.platform} onChange={(e) => setDealForm({...dealForm, platform: e.target.value})}
|
|
||||||
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }}>
|
|
||||||
<option value="淘宝">淘宝</option>
|
|
||||||
<option value="咸鱼">咸鱼</option>
|
|
||||||
<option value="抖音">抖音</option>
|
|
||||||
<option value="快手">快手</option>
|
|
||||||
<option value="微拍堂">微拍堂</option>
|
|
||||||
<option value="拼多多">拼多多</option>
|
|
||||||
<option value="一尘">一尘</option>
|
|
||||||
<option value="爱藏">爱藏</option>
|
|
||||||
<option value="其他">其他</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '12px' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>出售者</div>
|
|
||||||
<input value={dealForm.seller} onChange={(e) => setDealForm({...dealForm, seller: e.target.value})}
|
|
||||||
placeholder="请输入出售者"
|
|
||||||
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>购买者</div>
|
|
||||||
<input value={dealForm.buyer} onChange={(e) => setDealForm({...dealForm, buyer: e.target.value})}
|
|
||||||
placeholder="请输入购买者"
|
|
||||||
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>成交日期 *</div>
|
|
||||||
<input type="date" value={dealForm.date} onChange={(e) => setDealForm({...dealForm, date: e.target.value})}
|
|
||||||
style={{ width: '100%', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '15px' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 评级字段 */}
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>评级(可选)</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<select value={dealForm.gradingCompany || ''} onChange={(e) => setDealForm({...dealForm, gradingCompany: e.target.value})}
|
|
||||||
style={{ flex: 1, padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '14px' }}>
|
|
||||||
<option value="">评级机构</option>
|
|
||||||
<option value="PCGS">PCGS</option>
|
|
||||||
<option value="PMG">PMG</option>
|
|
||||||
<option value="ACG">ACG</option>
|
|
||||||
<option value="爱藏">爱藏</option>
|
|
||||||
</select>
|
|
||||||
<input value={dealForm.gradingScore || ''} onChange={(e) => setDealForm({...dealForm, gradingScore: e.target.value})}
|
|
||||||
placeholder="评级分数"
|
|
||||||
style={{ flex: 1, padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '14px' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={async () => {
|
|
||||||
if (!dealForm.serial || !dealForm.price || !dealForm.platform || !dealForm.date) {
|
|
||||||
alert('请填写所有必填项')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setSavingDeal(true)
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
// 冠字号矫正:用户输入的数字位数不同,生成规则不同
|
|
||||||
// 7位 → J0 + 0 + 数字 (如 J00123456)
|
|
||||||
// 8位 → J0 + 数字 (如 J01234567)
|
|
||||||
// 9位 → J0 + 取后8位数字 (如 J02345678)
|
|
||||||
const rawSerial = dealForm.serial.replace('J', '').replace(/\D/g, '')
|
|
||||||
let normalizedSerial = ''
|
|
||||||
if (rawSerial.length === 7) {
|
|
||||||
normalizedSerial = 'J0' + '0' + rawSerial
|
|
||||||
} else if (rawSerial.length === 8) {
|
|
||||||
normalizedSerial = 'J0' + rawSerial
|
|
||||||
} else if (rawSerial.length >= 9) {
|
|
||||||
normalizedSerial = 'J0' + rawSerial.slice(1, 9) // 取后8位
|
|
||||||
} else {
|
|
||||||
normalizedSerial = 'J0' + rawSerial
|
|
||||||
}
|
|
||||||
|
|
||||||
const tailNumber = rawSerial.slice(-2)
|
|
||||||
const sizeType = (dealForm.packaging === '标十' && ['01','11','21','31','41','51','61','71','81','91'].includes(tailNumber)) ? '小号' :
|
|
||||||
(dealForm.packaging === '标百' && ['001','011','021','031','041','051','061','071','081','091'].includes(rawSerial.slice(-3))) ? '小号' : '大号'
|
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE}/api/deal`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: `${normalizedSerial}-¥${dealForm.price}`,
|
|
||||||
content: `分类: ${dealForm.category || '未分类'}\n尾号: ${tailNumber || '-'}\n大小号: ${sizeType || '-'}\n包装: ${dealForm.packaging}\n平台: ${dealForm.platform}\n价格: ¥${dealForm.price}\n出售者: ${dealForm.seller || '-'}\n购买者: ${dealForm.buyer || '-'}\n日期: ${dealForm.date}\n评级: ${dealForm.gradingCompany ? dealForm.gradingCompany + ' ' + dealForm.gradingScore : '未评级'}`,
|
|
||||||
deal_price: parseFloat(dealForm.price),
|
|
||||||
deal_date: dealForm.date,
|
|
||||||
packaging: dealForm.packaging,
|
|
||||||
category: dealForm.category,
|
|
||||||
tail_number: tailNumber,
|
|
||||||
size_type: sizeType,
|
|
||||||
platform: dealForm.platform,
|
|
||||||
seller: dealForm.seller || '',
|
|
||||||
buyer: dealForm.buyer || ''
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if (response.ok) {
|
|
||||||
alert('行情录入成功!')
|
|
||||||
setDealForm({ serial: '', category: '', packaging: '标十', price: '', platform: '抖音', seller: '', buyer: '', date: new Date().toISOString().split('T')[0] })
|
|
||||||
} else {
|
|
||||||
const data = await response.json()
|
|
||||||
alert('录入失败: ' + (data.detail || '未知错误'))
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('提交失败: ' + e.message)
|
|
||||||
} finally {
|
|
||||||
setSavingDeal(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={savingDeal}
|
|
||||||
style={{ width: '100%', padding: '14px', background: savingDeal ? '#64748b' : '#fbbf24', color: savingDeal ? '#94a3b8' : '#1e293b', border: 'none', borderRadius: '10px', fontSize: '16px', fontWeight: '600', cursor: savingDeal ? 'not-allowed' : 'pointer' }}>
|
|
||||||
{savingDeal ? '提交中...' : '提交行情'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 批量录入 */}
|
|
||||||
{dealMode === 'batch' && (
|
|
||||||
<div>
|
|
||||||
{/* 日期和平台 */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginBottom: '12px' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '10px', color: '#64748b', marginBottom: '4px' }}>默认日期(可选)</div>
|
|
||||||
<input type="date" value={batchDefaultDate || ''} onChange={(e) => setBatchDefaultDate(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '12px' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '10px', color: '#64748b', marginBottom: '4px' }}>成交平台(可选)</div>
|
|
||||||
<select value={batchDefaultPlatform || ''} onChange={(e) => setBatchDefaultPlatform(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '12px' }}>
|
|
||||||
<option value="">请选择</option>
|
|
||||||
<option value="淘宝">淘宝</option>
|
|
||||||
<option value="咸鱼">咸鱼</option>
|
|
||||||
<option value="抖音">抖音</option>
|
|
||||||
<option value="快手">快手</option>
|
|
||||||
<option value="微拍堂">微拍堂</option>
|
|
||||||
<option value="拼多多">拼多多</option>
|
|
||||||
<option value="一尘">一尘</option>
|
|
||||||
<option value="爱藏">爱藏</option>
|
|
||||||
<option value="其他">其他</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 包装类型选择 */}
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<div style={{ fontSize: '10px', color: '#64748b', marginBottom: '6px' }}>批量默认包装类型(可选)</div>
|
|
||||||
<div style={{ display: 'flex', gap: '6px' }}>
|
|
||||||
<button onClick={() => setBatchDefaultPackaging(batchDefaultPackaging === '单张' ? '' : '单张')}
|
|
||||||
style={{ flex: 1, padding: '8px', background: batchDefaultPackaging === '单张' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: batchDefaultPackaging === '单张' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '12px', cursor: 'pointer' }}>
|
|
||||||
单张
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setBatchDefaultPackaging(batchDefaultPackaging === '标十' ? '' : '标十')}
|
|
||||||
style={{ flex: 1, padding: '8px', background: batchDefaultPackaging === '标十' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: batchDefaultPackaging === '标十' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '12px', cursor: 'pointer' }}>
|
|
||||||
标十
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setBatchDefaultPackaging(batchDefaultPackaging === '标百' ? '' : '标百')}
|
|
||||||
style={{ flex: 1, padding: '8px', background: batchDefaultPackaging === '标百' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: batchDefaultPackaging === '标百' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '12px', cursor: 'pointer' }}>
|
|
||||||
标百
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<textarea value={batchText} onChange={(e) => setBatchText(e.target.value)}
|
|
||||||
placeholder="粘贴批量行情文本..." style={{ width: '100%', minHeight: '120px', padding: '12px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff', fontSize: '14px' }} />
|
|
||||||
<button onClick={async () => {
|
|
||||||
if (!batchText.trim()) { alert('请先粘贴行情文本'); return }
|
|
||||||
setParsing(true)
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/api/information/batch-parse-local`, {
|
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ text: batchText, defaultPackaging: batchDefaultPackaging, defaultDate: batchDefaultDate, defaultPlatform: batchDefaultPlatform })
|
|
||||||
})
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.success) { setBatchResult(data.data || []); alert(`解析成功${data.data.length}条`) }
|
|
||||||
else { alert('解析失败: ' + (data.error || '未知错误')) }
|
|
||||||
} catch (e) { alert('请求失败: ' + e.message) }
|
|
||||||
finally { setParsing(false) }
|
|
||||||
}}
|
|
||||||
disabled={parsing} style={{ width: '100%', marginTop: '8px', padding: '10px', background: parsing ? '#64748b' : '#22c55e', color: '#fff', border: 'none', borderRadius: '8px' }}>
|
|
||||||
{parsing ? '解析中...' : '解析文本'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{batchResult.length > 0 && (
|
|
||||||
<div style={{ marginTop: '12px' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '12px' }}>解析结果 ({batchResult.length}条)</div>
|
|
||||||
<button onClick={() => { setBatchResult([]); setBatchText('') }} style={{ padding: '4px 8px', background: 'rgba(239,68,68,0.2)', color: '#ef4444', border: 'none', borderRadius: '4px', fontSize: '11px', cursor: 'pointer' }}>清空</button>
|
|
||||||
</div>
|
|
||||||
<div style={{ maxHeight: '350px', overflowY: 'auto' }}>
|
|
||||||
{batchResult.map((item, idx) => (
|
|
||||||
<div key={idx} style={{ padding: '8px', background: 'rgba(255,255,255,0.05)', marginBottom: '6px', borderRadius: '6px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
|
||||||
{/* 第一行:冠字号 | 价格 | 分类 | 包装 */}
|
|
||||||
<div style={{ display: 'flex', gap: '6px', marginBottom: '4px' }}>
|
|
||||||
<div style={{ flex: '0 0 35%' }}>
|
|
||||||
<input value={batchResult[idx].serial} onChange={(e) => {
|
|
||||||
const newResult = [...batchResult]
|
|
||||||
newResult[idx].serial = e.target.value
|
|
||||||
setBatchResult(newResult)
|
|
||||||
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fbbf24', fontSize: '12px' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: '0 0 25%' }}>
|
|
||||||
<input type="number" value={batchResult[idx].price} onChange={(e) => {
|
|
||||||
const newResult = [...batchResult]
|
|
||||||
newResult[idx].price = parseFloat(e.target.value) || 0
|
|
||||||
setBatchResult(newResult)
|
|
||||||
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#22c55e', fontSize: '12px' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: '0 0 20%' }}>
|
|
||||||
<select value={batchResult[idx].category} onChange={(e) => {
|
|
||||||
const newResult = [...batchResult]
|
|
||||||
newResult[idx].category = e.target.value
|
|
||||||
setBatchResult(newResult)
|
|
||||||
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '10px' }}>
|
|
||||||
{['圆圆号','倒置号','金马王','金马号','金山王','天马王','金山号','天马号','朦胧王','朦胧号','如意号','钻石号','永恒号','带7号','带4号'].map(c => <option key={c} value={c}>{c}</option>)}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: '0 0 20%' }}>
|
|
||||||
<select value={batchResult[idx].packaging || '单张'} onChange={(e) => {
|
|
||||||
const newResult = [...batchResult]
|
|
||||||
newResult[idx].packaging = e.target.value
|
|
||||||
setBatchResult(newResult)
|
|
||||||
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '10px' }}>
|
|
||||||
<option value="单张">单张</option>
|
|
||||||
<option value="标十">标十</option>
|
|
||||||
<option value="标百">标百</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 第二行:评级机构 | 分数 | 出售者 | 删除 */}
|
|
||||||
<div style={{ display: 'flex', gap: '6px' }}>
|
|
||||||
<div style={{ flex: '0 0 22%' }}>
|
|
||||||
<select value={batchResult[idx].grading_company || ''} onChange={(e) => {
|
|
||||||
const newResult = [...batchResult]
|
|
||||||
newResult[idx].grading_company = e.target.value
|
|
||||||
newResult[idx].is_graded = !!e.target.value
|
|
||||||
setBatchResult(newResult)
|
|
||||||
}} style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '10px' }}>
|
|
||||||
<option value="">未评级</option>
|
|
||||||
<option value="PCGS">PCGS</option>
|
|
||||||
<option value="PMG">PMG</option>
|
|
||||||
<option value="ACG">ACG</option>
|
|
||||||
<option value="爱藏">爱藏</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: '0 0 18%' }}>
|
|
||||||
<input value={batchResult[idx].grade || ''} onChange={(e) => {
|
|
||||||
const newResult = [...batchResult]
|
|
||||||
newResult[idx].grade = e.target.value
|
|
||||||
setBatchResult(newResult)
|
|
||||||
}} placeholder="分数" style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#06b6d4', fontSize: '11px' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: '0 0 30%' }}>
|
|
||||||
<input value={batchResult[idx].seller || ''} onChange={(e) => {
|
|
||||||
const newResult = [...batchResult]
|
|
||||||
newResult[idx].seller = e.target.value
|
|
||||||
setBatchResult(newResult)
|
|
||||||
}} placeholder="出售者" style={{ width: '100%', padding: '4px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '4px', color: '#fff', fontSize: '11px' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: '0 0 30%', textAlign: 'right' }}>
|
|
||||||
<button onClick={() => {
|
|
||||||
const newResult = batchResult.filter((_, i) => i !== idx)
|
|
||||||
setBatchResult(newResult)
|
|
||||||
}} style={{ padding: '4px 8px', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '4px', fontSize: '10px', cursor: 'pointer' }}>删除</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button onClick={async () => {
|
|
||||||
setSavingDeal(true)
|
|
||||||
let count = 0; const token = localStorage.getItem('token')
|
|
||||||
for (const item of batchResult) {
|
|
||||||
try {
|
|
||||||
// 计算尾号和大小号
|
|
||||||
const digits = (item.serial || '').replace('J', '').replace(/[^0-9]/g, '')
|
|
||||||
let tail_number = '', size_type = ''
|
|
||||||
if ((item.packaging === '标十' || item.packaging === '标百') && digits.length >= 2) {
|
|
||||||
tail_number = item.packaging === '标十' ? digits.slice(-2) : digits.slice(-3)
|
|
||||||
size_type = (item.packaging === '标十' && ['01','11','21','31','41','51'].includes(tail_number)) || (item.packaging === '标百' && ['101','201','301','401','501'].includes(tail_number)) ? '小号' : '大号'
|
|
||||||
}
|
|
||||||
await fetch(`${API_BASE}/api/deal`, {
|
|
||||||
method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: `${item.serial}-¥${item.price}`,
|
|
||||||
content: `分类: ${item.category || '-'}\n尾号: ${tail_number || '-'}\n大小号: ${size_type || '-'}\n包装: ${item.packaging || '单张'}\n平台: ${item.platform || '-'}\n价格: ¥${item.price}\n出售者: ${item.seller || '-'}\n购买者: ${item.buyer || '-'}\n日期: ${item.deal_date || new Date().toISOString().split('T')[0]}`,
|
|
||||||
deal_price: parseFloat(item.price),
|
|
||||||
deal_date: item.deal_date || new Date().toISOString().split('T')[0],
|
|
||||||
packaging: item.packaging || '单张',
|
|
||||||
category: item.category || '',
|
|
||||||
tail_number: tail_number,
|
|
||||||
size_type: size_type,
|
|
||||||
platform: item.platform || '-',
|
|
||||||
seller: item.seller || '',
|
|
||||||
buyer: item.buyer || ''
|
|
||||||
})
|
|
||||||
})
|
|
||||||
count++
|
|
||||||
} catch(e) { console.error(e) }
|
|
||||||
}
|
|
||||||
alert(`完成${count}条`); setBatchResult([]); setBatchText(''); setSavingDeal(false)
|
|
||||||
}} disabled={savingDeal} style={{ width: '100%', marginTop: '8px', padding: '10px', background: savingDeal?'#64748b':'#fbbf24', color: savingDeal?'#94a3b8':'#1e293b', border:'none', borderRadius:'8px' }}>
|
|
||||||
{savingDeal ? '提交中...' : `批量录入${batchResult.length}条`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ position: 'fixed', bottom: '16px', right: '16px', color: 'rgba(255,255,255,0.2)', fontSize: '11px', zIndex: 100 }}>v{APP_VERSION}</div>
|
<div style={{ position: 'fixed', bottom: '16px', right: '16px', color: 'rgba(255,255,255,0.2)', fontSize: '11px', zIndex: 100 }}>v{APP_VERSION}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* Admin - 管理员用户管理页面
|
|
||||||
* Version: 1.2.98 (2026-04-19)
|
|
||||||
* 更新:新增 dealCount 字段显示用户发布的行情数量
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { APP_VERSION } from '../config/version'
|
import { APP_VERSION } from '../config/version'
|
||||||
|
|
||||||
|
|
@ -132,18 +126,7 @@ export default function Admin() {
|
||||||
const updateData = {
|
const updateData = {
|
||||||
username: editingUser.username,
|
username: editingUser.username,
|
||||||
email: editingUser.email,
|
email: editingUser.email,
|
||||||
phone: editingUser.phone,
|
role: editingUser.role
|
||||||
phoneVerified: editingUser.phoneVerified,
|
|
||||||
role: editingUser.role,
|
|
||||||
user_code: editingUser.user_code,
|
|
||||||
level: editingUser.level,
|
|
||||||
loginCount: editingUser.loginCount,
|
|
||||||
aiCount: editingUser.aiCount,
|
|
||||||
searchCount: editingUser.searchCount,
|
|
||||||
points: editingUser.points,
|
|
||||||
balance: editingUser.balance,
|
|
||||||
totalAmount: editingUser.totalAmount,
|
|
||||||
phoneVerified: editingUser.phoneVerified
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果有新密码,添加到更新数据
|
// 如果有新密码,添加到更新数据
|
||||||
|
|
@ -203,56 +186,26 @@ export default function Admin() {
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<div key={user.id} style={{ background: 'rgba(30, 41, 59, 0.8)', borderRadius: '12px', padding: '16px' }}>
|
<div key={user.id} style={{ background: 'rgba(30, 41, 59, 0.8)', borderRadius: '12px', padding: '16px' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<div style={{ flex: 1 }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<div style={{ color: '#fbbf24', fontSize: '16px', fontWeight: 'bold' }}>{user.username}</div>
|
<div style={{ color: '#fbbf24', fontSize: '16px', fontWeight: 'bold' }}>{user.username}</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '2px 8px', borderRadius: '4px',
|
padding: '2px 8px', borderRadius: '4px',
|
||||||
background: user.role === 'admin' ? 'rgba(16, 185, 129, 0.2)' : (user.role === 'editor' ? 'rgba(245, 158, 11, 0.2)' : 'rgba(148, 163, 184, 0.2)'),
|
background: user.role === 'admin' ? 'rgba(16, 185, 129, 0.2)' : 'rgba(148, 163, 184, 0.2)',
|
||||||
color: user.role === 'admin' ? '#10b981' : (user.role === 'editor' ? '#f59e0b' : '#94a3b8'),
|
color: user.role === 'admin' ? '#10b981' : '#94a3b8',
|
||||||
fontSize: '12px'
|
fontSize: '12px'
|
||||||
}}>
|
}}>
|
||||||
{user.role === 'admin' ? '👑 管理员' : (user.role === 'editor' ? '📝 信息员' : '👤 用户')}
|
{user.role === 'admin' ? '👑 管理员' : '👤 用户'}
|
||||||
</div>
|
|
||||||
<div style={{ background: 'rgba(59, 130, 246, 0.2)', padding: '2px 8px', borderRadius: '4px', color: '#60a5fa', fontSize: '12px' }}>
|
|
||||||
编码: {user.user_code || '-'}
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'rgba(168, 85, 247, 0.2)', padding: '2px 8px', borderRadius: '4px', color: '#a855f7', fontSize: '12px' }}>
|
|
||||||
等级: {user.level || '青铜'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#64748b', fontSize: '13px', marginTop: '8px' }}>
|
<div style={{ color: '#64748b', fontSize: '13px', marginTop: '4px' }}>
|
||||||
📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'} | 📱已认证: {user.phoneVerified ? '✓' : '✗'}
|
📧 {user.email || '未设置'} | 📱 {user.phone || '未设置'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div style={{ color: '#94a3b8', fontSize: '12px' }}>藏品数</div>
|
<div style={{ color: '#94a3b8', fontSize: '12px' }}>藏品数</div>
|
||||||
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: 'bold', cursor: 'pointer' }} onClick={() => window.location.hash = '#/list?userId=' + user.id} title='点击查看'>{user.collectionCount || 0}</div>
|
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: 'bold' }}>{user.collection_count || 0}</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 更多字段 */}
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
|
||||||
<div style={{ background: 'rgba(16, 185, 129, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#10b981' }}>
|
|
||||||
🔑 登录: {user.loginCount || 0}次
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'rgba(59, 130, 246, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#3b82f6' }}>
|
|
||||||
🤖 AI识别: {user.aiCount || 0}次
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'rgba(20, 184, 166, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#14b8a6' }}>
|
|
||||||
📈 行情: {user.dealCount || 0}条
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'rgba(245, 158, 11, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#f59e0b' }}>
|
|
||||||
🔍 寻号: {user.searchCount || 0}次
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'rgba(168, 85, 247, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#a855f7' }}>
|
|
||||||
💎 积分: {user.points || 0}
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'rgba(236, 72, 153, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#ec4899' }}>
|
|
||||||
💰 余额: {user.balance || 0}
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'rgba(34, 197, 94, 0.1)', padding: '4px 10px', borderRadius: '6px', fontSize: '12px', color: '#22c55e' }}>
|
|
||||||
📊 累计: {user.totalAmount || 0}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
|
@ -261,7 +214,7 @@ export default function Admin() {
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingUser({ ...user, email: user.email || '', phone: user.phone || '', phoneVerified: user.phoneVerified !== false, newPassword: '', confirmPassword: '' })}
|
onClick={() => setEditingUser({ ...user })}
|
||||||
style={{
|
style={{
|
||||||
padding: '6px 12px',
|
padding: '6px 12px',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
|
|
@ -347,7 +300,6 @@ export default function Admin() {
|
||||||
>
|
>
|
||||||
<option value="user">普通用户</option>
|
<option value="user">普通用户</option>
|
||||||
<option value="admin">管理员</option>
|
<option value="admin">管理员</option>
|
||||||
<option value="editor">信息员</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -399,28 +351,6 @@ export default function Admin() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>手机号</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editingUser.phone || ''}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, phone: e.target.value })}
|
|
||||||
style={{ width: '100%', padding: '10px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '8px', color: '#fff' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'flex', alignItems: 'center', color: '#94a3b8', fontSize: '13px', marginBottom: '6px', cursor: 'pointer' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editingUser.phoneVerified === true}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, phoneVerified: e.target.checked })}
|
|
||||||
style={{ marginRight: '8px' }}
|
|
||||||
/>
|
|
||||||
手机号已验证
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label>
|
<label style={{ display: 'block', color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>角色</label>
|
||||||
<select
|
<select
|
||||||
|
|
@ -430,7 +360,6 @@ export default function Admin() {
|
||||||
>
|
>
|
||||||
<option value="user">普通用户</option>
|
<option value="user">普通用户</option>
|
||||||
<option value="admin">管理员</option>
|
<option value="admin">管理员</option>
|
||||||
<option value="editor">信息员</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -462,113 +391,6 @@ export default function Admin() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 第三部分:扩展信息 */}
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
|
||||||
<h3 style={{ color: '#a855f7', fontSize: '15px', fontWeight: 'bold', marginBottom: '16px', paddingBottom: '8px', borderBottom: '1px solid rgba(168, 85, 247, 0.3)' }}>📊 扩展信息</h3>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>用户编码</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={editingUser.user_code || ''}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, user_code: e.target.value })}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>会员等级</label>
|
|
||||||
<select
|
|
||||||
value={editingUser.level || '青铜'}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, level: e.target.value })}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
|
|
||||||
>
|
|
||||||
<option value="青铜">青铜</option>
|
|
||||||
<option value="白银">白银</option>
|
|
||||||
<option value="黄金">黄金</option>
|
|
||||||
<option value="铂金">铂金</option>
|
|
||||||
<option value="钻石号">钻石号</option>
|
|
||||||
<option value="永恒号">永恒号</option>
|
|
||||||
<option value="带7号">带7号</option>
|
|
||||||
<option value="带4号">带4号</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>登录次数</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editingUser.loginCount || 0}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, loginCount: parseInt(e.target.value) || 0 })}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>AI识别次数</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editingUser.aiCount || 0}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, aiCount: parseInt(e.target.value) || 0 })}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>配号次数</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editingUser.searchCount || 0}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, searchCount: parseInt(e.target.value) || 0 })}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>寻号次数</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editingUser.searchCount || 0}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, searchCount: parseInt(e.target.value) || 0 })}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>积分</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={editingUser.points || 0}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, points: parseInt(e.target.value) || 0 })}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>余额</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={editingUser.balance || 0}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, balance: parseFloat(e.target.value) || 0 })}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>累计金额</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={editingUser.totalAmount || 0}
|
|
||||||
onChange={(e) => setEditingUser({ ...editingUser, totalAmount: parseFloat(e.target.value) || 0 })}
|
|
||||||
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setEditingUser(null)}
|
onClick={() => setEditingUser(null)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,513 @@
|
||||||
|
import React, { useState, useRef } from 'react'
|
||||||
|
|
||||||
|
// 字段名转换函数:驼峰转下划线
|
||||||
|
const convertField = (obj) => {
|
||||||
|
const map = {
|
||||||
|
prefixSerial: 'prefix_serial',
|
||||||
|
isGraded: 'is_graded',
|
||||||
|
gradingCompany: 'grading_company',
|
||||||
|
gradingScore: 'grading_score',
|
||||||
|
threeStar: 'three_star',
|
||||||
|
specialMark: 'special_mark',
|
||||||
|
serialFeature: 'serial_feature',
|
||||||
|
issueYear: 'issue_year',
|
||||||
|
issueQuantity: 'issue_quantity',
|
||||||
|
costPrice: 'cost_price',
|
||||||
|
targetPrice: 'target_price',
|
||||||
|
goalPrice: 'goal_price',
|
||||||
|
repairFee: 'repair_fee',
|
||||||
|
gradingFee: 'grading_fee'
|
||||||
|
}
|
||||||
|
const result = {}
|
||||||
|
for (const key in obj) {
|
||||||
|
result[map[key] || key] = obj[key]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BatchMode() {
|
||||||
|
const [images, setImages] = useState([])
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
const [result, setResult] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [savedCount, setSavedCount] = useState(0)
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'in_collection', label: '收藏中' },
|
||||||
|
{ value: 'selling', label: '在售' },
|
||||||
|
{ value: 'sold', label: '已售' },
|
||||||
|
{ value: 'grading', label: '送评' },
|
||||||
|
{ value: 'transit', label: '在途' },
|
||||||
|
{ value: 'other', label: '其他' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const packagingOptions = [
|
||||||
|
{ value: '单张', label: '单张' },
|
||||||
|
{ value: '标十', label: '标十' },
|
||||||
|
{ value: '标百', label: '标百' },
|
||||||
|
{ value: '裸钞', label: '裸钞' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const categoryOptions = [
|
||||||
|
{ value: '自持', label: '自持' },
|
||||||
|
{ value: '寄存', label: '寄存' },
|
||||||
|
{ value: '寄售', label: '寄售' },
|
||||||
|
{ value: '共有', label: '共有' },
|
||||||
|
{ value: '寻号', label: '寻号' },
|
||||||
|
{ value: '其他', label: '其他' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const rarityOptions = [
|
||||||
|
{ value: '通货', label: '通货' },
|
||||||
|
{ value: '特色', label: '特色' },
|
||||||
|
{ value: '少见', label: '少见' },
|
||||||
|
{ value: '稀有', label: '稀有' },
|
||||||
|
{ value: '珍品', label: '珍品' },
|
||||||
|
{ value: '孤品', label: '孤品' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleChooseImages = (e) => {
|
||||||
|
const files = Array.from(e.target.files || [])
|
||||||
|
if (files.length > 0) {
|
||||||
|
setImages(files.slice(0, 10))
|
||||||
|
setCurrentIndex(0)
|
||||||
|
setResult(null)
|
||||||
|
setSavedCount(0)
|
||||||
|
// Auto OCR first image
|
||||||
|
processOCR(files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const processOCR = async (file) => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('image', file)
|
||||||
|
|
||||||
|
const res = await fetch('/api/ocr/recognize', { method: 'POST', body: formData })
|
||||||
|
const data = await res.json()
|
||||||
|
const text = data.result || ''
|
||||||
|
|
||||||
|
const parsed = {
|
||||||
|
name: '生肖纪念钞',
|
||||||
|
ownership_type: '自持',
|
||||||
|
rarity: '通货',
|
||||||
|
status: 'in_collection',
|
||||||
|
version: '',
|
||||||
|
prefixSerial: '',
|
||||||
|
packaging: '单张',
|
||||||
|
isGraded: false,
|
||||||
|
gradingCompany: '',
|
||||||
|
gradingScore: '',
|
||||||
|
threeStar: false,
|
||||||
|
specialMark: '',
|
||||||
|
serialFeature: '',
|
||||||
|
denomination: '贰拾圆',
|
||||||
|
issuer: '中国人民银行',
|
||||||
|
issueYear: '2024',
|
||||||
|
material: '塑料',
|
||||||
|
issueQuantity: '一亿',
|
||||||
|
costPrice: '',
|
||||||
|
goalPrice: '',
|
||||||
|
targetPrice: '',
|
||||||
|
remark: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const v = text.match(/发行版别[::]\s*([^\n]+)/)
|
||||||
|
if (v) {
|
||||||
|
parsed.version = v[1].trim()
|
||||||
|
const yearMatch = v[1].match(/20\d{2}/)
|
||||||
|
if (yearMatch) parsed.issueYear = yearMatch[0]
|
||||||
|
}
|
||||||
|
// 自动识别发行机构
|
||||||
|
const issuerMatch = text.match(/发行机构[::]\s*([^\n]+)/)
|
||||||
|
if (issuerMatch) parsed.issuer = issuerMatch[1].trim()
|
||||||
|
if (text.includes('三星') || text.includes('3星') || text.includes('★★★')) parsed.threeStar = true
|
||||||
|
const d = text.match(/面额[::]\s*([^\n]+)/)
|
||||||
|
if (d) parsed.denomination = d[1].trim()
|
||||||
|
const p = text.match(/冠字序号[::]\s*([^\n]+)/)
|
||||||
|
if (p) parsed.prefixSerial = p[1].trim()
|
||||||
|
// 识别编号
|
||||||
|
const codeMatch = text.match(/编号[::]\s*([^\n]+)/)
|
||||||
|
if (codeMatch) parsed.code = codeMatch[1].trim()
|
||||||
|
const pk = text.match(/封装类型[::]\s*([^\n]+)/)
|
||||||
|
if (pk) {
|
||||||
|
const pv = pk[1].trim()
|
||||||
|
if (pv.includes('百')) parsed.packaging = '标百'
|
||||||
|
else if (pv.includes('十')) parsed.packaging = '标十'
|
||||||
|
else if (pv.includes('单')) parsed.packaging = '单张'
|
||||||
|
else parsed.packaging = '裸钞'
|
||||||
|
}
|
||||||
|
const g = text.match(/是否评级[::]\s*([^\n]+)/)
|
||||||
|
if (g) parsed.isGraded = g[1].trim() === '是'
|
||||||
|
const gc = text.match(/评级机构[::]\s*([^\n]+)/)
|
||||||
|
if (gc) parsed.gradingCompany = gc[1].trim()
|
||||||
|
const gs = text.match(/评级分数[::]\s*([^\n]+)/)
|
||||||
|
if (gs) parsed.gradingScore = gs[1].trim()
|
||||||
|
const sm = text.match(/特殊标识[::]\s*([^\n]+)/)
|
||||||
|
if (sm) parsed.specialMark = sm[1].trim()
|
||||||
|
const sf = text.match(/号码特征[::]\s*([^\n]+)/)
|
||||||
|
if (sf) parsed.serialFeature = sf[1].trim()
|
||||||
|
|
||||||
|
setResult(parsed)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert('识别失败,请重试')
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateResult = (field, value) => {
|
||||||
|
setResult(prev => ({ ...prev, [field]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (force = false) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (!token) {
|
||||||
|
alert('请先登录')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saveData = convertField({ ...result, _forceSave: force })
|
||||||
|
const res = await fetch('/api/collections', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
|
body: JSON.stringify(saveData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
setSavedCount(prev => prev + 1)
|
||||||
|
alert('保存成功!')
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
const errMsg = data.error || data.message || data.detail || '保存失败'
|
||||||
|
// 检查重复
|
||||||
|
if (errMsg.includes('E002') || errMsg.includes('已存在') || errMsg.includes('禁止重复') || errMsg.includes('重复')) {
|
||||||
|
const confirmed = confirm('发现重复:冠字号 ' + (result.prefixSerial || '') + ' 已存在,是否继续保存?')
|
||||||
|
if (confirmed) {
|
||||||
|
await handleSave(true) // 强制保存
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert(errMsg)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('保存失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (currentIndex < images.length - 1) {
|
||||||
|
setCurrentIndex(currentIndex + 1)
|
||||||
|
setResult(null)
|
||||||
|
processOCR(images[currentIndex + 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePrev = () => {
|
||||||
|
if (currentIndex > 0) {
|
||||||
|
setCurrentIndex(currentIndex - 1)
|
||||||
|
setResult(null)
|
||||||
|
processOCR(images[currentIndex - 1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReOCR = () => {
|
||||||
|
processOCR(images[currentIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReUpload = () => {
|
||||||
|
setImages([])
|
||||||
|
setCurrentIndex(0)
|
||||||
|
setResult(null)
|
||||||
|
setSavedCount(0)
|
||||||
|
fileInputRef.current?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// No images selected
|
||||||
|
if (images.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
minHeight: '50vh',
|
||||||
|
background: 'rgba(255,255,255,0.03)',
|
||||||
|
border: '2px dashed rgba(255,255,255,0.15)',
|
||||||
|
borderRadius: '16px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '60px', opacity: 0.5 }}>📦</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: '16px', marginTop: '16px' }}>点击选择多张图片</div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '13px', marginTop: '8px' }}>最多10张</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleChooseImages}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSaved = savedCount > currentIndex
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '16px', paddingBottom: '120px' }}>
|
||||||
|
{/* Sticky Header */}
|
||||||
|
<div style={{ position: 'sticky', top: 0, background: '#0f172a', padding: '12px 0', marginBottom: '12px', zIndex: 10, borderBottom: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
<div style={{ color: '#fff', fontSize: '14px', marginBottom: '8px' }}>
|
||||||
|
已保存: {savedCount} / {images.length} | 当前第 {currentIndex + 1} 张
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
|
||||||
|
{images.map((_, i) => (
|
||||||
|
<div key={i} style={{
|
||||||
|
width: '24px', height: '24px', borderRadius: '4px',
|
||||||
|
background: i < savedCount ? '#22c55e' : (i === currentIndex ? '#fbbf24' : '#333'),
|
||||||
|
color: '#fff', fontSize: '10px', display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{currentIndex === images.length - 1 && savedCount > 0 && (
|
||||||
|
<button onClick={handleReUpload} style={{ marginTop: '12px', padding: '8px 16px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '6px', fontSize: '13px', cursor: 'pointer' }}>
|
||||||
|
重新上传
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Image */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<img
|
||||||
|
src={URL.createObjectURL(images[currentIndex])}
|
||||||
|
alt="preview"
|
||||||
|
style={{ width: '100%', maxHeight: '200px', objectFit: 'contain', borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buttons - moved here */}
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
|
||||||
|
<button
|
||||||
|
onClick={handlePrev}
|
||||||
|
disabled={currentIndex === 0}
|
||||||
|
style={{ flex: 1, padding: '12px', background: currentIndex === 0 ? '#333' : 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', cursor: currentIndex === 0 ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
上一张
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{currentIndex < images.length - 1 && (
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
style={{ flex: 1, padding: '12px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
下一张
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleReOCR}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: '1px solid rgba(255,255,255,0.2)', borderRadius: '8px', cursor: loading ? 'not-allowed' : 'pointer' }}
|
||||||
|
>
|
||||||
|
重新识别
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!result || loading}
|
||||||
|
style={{ flex: 1, padding: '12px', background: result && !loading ? '#22c55e' : '#333', color: '#fff', border: 'none', borderRadius: '8px', fontWeight: 'bold', cursor: result && !loading ? 'pointer' : 'not-allowed' }}
|
||||||
|
>
|
||||||
|
保存结果
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px', color: '#fbbf24' }}>🤖 AI识别中...</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && !loading && (
|
||||||
|
<>
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>名称</div>
|
||||||
|
<input type="text" value={result.name || ''} onChange={(e) => updateResult('name', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>编号</div>
|
||||||
|
<input type="text" value={result.code || ''} onChange={(e) => updateResult('code', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>持仓类型</div>
|
||||||
|
<select value={result.ownership_type || '自持'} onChange={(e) => updateResult('ownership_type', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
|
||||||
|
{categoryOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>珍惜度</div>
|
||||||
|
<select value={result.rarity || '通货'} onChange={(e) => updateResult('rarity', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
|
||||||
|
{rarityOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>冠字序号</div>
|
||||||
|
<input type="text" value={result.prefixSerial || ''} onChange={(e) => updateResult('prefixSerial', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px', fontFamily: 'monospace' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>版别</div>
|
||||||
|
<input type="text" value={result.version || ''} onChange={(e) => updateResult('version', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>状态</div>
|
||||||
|
<select value={result.status || 'in_collection'} onChange={(e) => updateResult('status', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
|
||||||
|
{statusOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>封装</div>
|
||||||
|
<select value={result.packaging || '单张'} onChange={(e) => updateResult('packaging', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
|
||||||
|
{packagingOptions.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 评级信息 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', marginBottom: '10px', padding: '8px', background: result.isGraded ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255,255,255,0.03)', borderRadius: '6px', cursor: 'pointer' }}
|
||||||
|
onClick={() => updateResult('isGraded', !result.isGraded)}>
|
||||||
|
<div style={{ width: '22px', height: '22px', borderRadius: '4px', border: '2px solid', borderColor: result.isGraded ? '#22c55e' : '#64748b', backgroundColor: result.isGraded ? '#22c55e' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{result.isGraded && <span style={{color:'#fff',fontSize:'14px'}}>✓</span>}
|
||||||
|
</div>
|
||||||
|
<span style={{color:'#fff',fontSize:'13px'}}>已评级</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{result.isGraded && (
|
||||||
|
<>
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>评级公司</div>
|
||||||
|
<input type="text" value={result.gradingCompany || ''} onChange={(e) => updateResult('gradingCompany', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>评级分数</div>
|
||||||
|
<input type="text" value={result.gradingScore || ''} onChange={(e) => updateResult('gradingScore', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px', padding: '8px', background: result.threeStar ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255,255,255,0.03)', borderRadius: '6px', cursor: 'pointer' }}
|
||||||
|
onClick={() => updateResult('threeStar', !result.threeStar)}>
|
||||||
|
<div style={{ width: '22px', height: '22px', borderRadius: '4px', border: '2px solid', borderColor: result.threeStar ? '#22c55e' : '#64748b', backgroundColor: result.threeStar ? '#22c55e' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
{result.threeStar && <span style={{color:'#fff',fontSize:'14px'}}>✓</span>}
|
||||||
|
</div>
|
||||||
|
<span style={{color:'#fff',fontSize:'13px'}}>三星</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 特殊信息 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>特殊信息</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>发行机构</div>
|
||||||
|
<input type="text" value={result.issuer || ''} onChange={(e) => updateResult('issuer', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>年份</div>
|
||||||
|
<input type="text" value={result.issueYear || ''} onChange={(e) => updateResult('issueYear', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>特殊标识</div>
|
||||||
|
<input type="text" value={result.specialMark || ''} onChange={(e) => updateResult('specialMark', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>号码特征</div>
|
||||||
|
<input type="text" value={result.serialFeature || ''} onChange={(e) => updateResult('serialFeature', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>面额</div>
|
||||||
|
<input type="text" value={result.denomination || ''} onChange={(e) => updateResult('denomination', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 价格信息 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '16px' }}>
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>成本价</div>
|
||||||
|
<input type="number" value={result.costPrice || ''} onChange={(e) => updateResult('costPrice', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '10px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>出售价</div>
|
||||||
|
<input type="number" value={result.targetPrice || ''} onChange={(e) => updateResult('targetPrice', e.target.value)}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '13px' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 备注 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '14px', marginBottom: '16px' }}>
|
||||||
|
<div style={{ marginBottom: '8px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>备注</div>
|
||||||
|
<textarea value={result.remark || ''} onChange={(e) => updateResult('remark', e.target.value)} rows={2}
|
||||||
|
style={{ width: '100%', padding: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px', resize: 'none' }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{savedCount > 0 && currentIndex === images.length - 1 && (
|
||||||
|
<button onClick={handleReUpload} style={{ width: '100%', padding: '14px', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '15px', marginTop: '16px', cursor: 'pointer' }}>
|
||||||
|
重新上传
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
onChange={handleChooseImages}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* Detail - 藏品详情页面
|
|
||||||
* Version: 1.2.90x
|
|
||||||
* 更新:
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
export default function Detail() {
|
export default function Detail() {
|
||||||
|
|
@ -236,7 +230,6 @@ export default function Detail() {
|
||||||
<Section title="特殊信息">
|
<Section title="特殊信息">
|
||||||
<Field label="特殊标识" value={collection.specialMark} />
|
<Field label="特殊标识" value={collection.specialMark} />
|
||||||
<Field label="号码特征" value={collection.serialFeature} />
|
<Field label="号码特征" value={collection.serialFeature} />
|
||||||
<Field label="号码分类" value={collection.numberCategory} />
|
|
||||||
<Field label="发行方" value={collection.issuer} />
|
<Field label="发行方" value={collection.issuer} />
|
||||||
<Field label="发行年份" value={collection.issueYear} />
|
<Field label="发行年份" value={collection.issueYear} />
|
||||||
<Field label="材质" value={collection.material} />
|
<Field label="材质" value={collection.material} />
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* Edit - 编辑藏品页面
|
|
||||||
* Version: 1.2.90x
|
|
||||||
* 更新:
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react'
|
import React, { useState, useRef, useEffect } from 'react'
|
||||||
|
|
||||||
// Input 组件(复用 Add.jsx 的定义)
|
// Input 组件(复用 Add.jsx 的定义)
|
||||||
|
|
@ -52,7 +46,6 @@ const categoryOptions = [
|
||||||
{ value: '其他', label: '其他' }
|
{ value: '其他', label: '其他' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const numberCategoryOptions = [{value:'无2347',label:'无2347'},{value:'无347',label:'无347'},{value:'无247',label:'无247'},{value:'无47',label:'无47'},{value:'带7号',label:'带7号'},{value:'带4号',label:'带4号'},{value:'其他',label:'其他'}];
|
|
||||||
const rarityOptions = [
|
const rarityOptions = [
|
||||||
{ value: '通货', label: '通货' },
|
{ value: '通货', label: '通货' },
|
||||||
{ value: '特色', label: '特色' },
|
{ value: '特色', label: '特色' },
|
||||||
|
|
@ -75,7 +68,7 @@ export default function Edit() {
|
||||||
const editId = params.get('id')
|
const editId = params.get('id')
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
name: '', code: '', category: '自持', rarity: '通货', numberCategory: '', prefixSerial: '',
|
name: '', code: '', category: '自持', rarity: '通货', prefixSerial: '',
|
||||||
version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
|
version: '2024 龙', denomination: '', status: 'in_collection', packaging: '单张',
|
||||||
material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
|
material: '塑料钞', issueQuantity: '1 亿', purpose: '收藏', isGraded: false,
|
||||||
gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '',
|
gradingCompany: '', gradingScore: '', threeStar: false, specialMark: '',
|
||||||
|
|
@ -103,7 +96,6 @@ export default function Edit() {
|
||||||
code: data.code || '',
|
code: data.code || '',
|
||||||
category: data.category || '自持',
|
category: data.category || '自持',
|
||||||
rarity: data.rarity || '通货',
|
rarity: data.rarity || '通货',
|
||||||
numberCategory: data.numberCategory || '',
|
|
||||||
prefixSerial: data.prefixSerial || '',
|
prefixSerial: data.prefixSerial || '',
|
||||||
version: data.version || '2024 龙',
|
version: data.version || '2024 龙',
|
||||||
denomination: data.denomination || '',
|
denomination: data.denomination || '',
|
||||||
|
|
@ -154,23 +146,6 @@ export default function Edit() {
|
||||||
const handleChange = (key, value) => {
|
const handleChange = (key, value) => {
|
||||||
setForm({ ...form, [key]: value })
|
setForm({ ...form, [key]: value })
|
||||||
if (key === 'targetPrice' && value) setForm(prev => ({ ...prev, status: 'sold' }))
|
if (key === 'targetPrice' && value) setForm(prev => ({ ...prev, status: 'sold' }))
|
||||||
// 冠字号变化时自动分类
|
|
||||||
if (key === 'prefixSerial') {
|
|
||||||
const serial = value
|
|
||||||
let cat = ''
|
|
||||||
const match = serial.match(/J(\d{9})/)
|
|
||||||
const digits = match ? match[1] : serial.replace(/\D/g, '').slice(0, 9)
|
|
||||||
if (digits) {
|
|
||||||
if (!digits.includes('2') && !digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无2347'
|
|
||||||
else if (!digits.includes('3') && !digits.includes('4') && !digits.includes('7')) cat = '无347'
|
|
||||||
else if (digits.includes('4')) cat = '带4号'
|
|
||||||
else if (digits.includes('7')) cat = '带7号'
|
|
||||||
else if (!digits.includes('4') && !digits.includes('7')) cat = '无47'
|
|
||||||
else if (!digits.includes('2') && !digits.includes('4') && !digits.includes('7')) cat = '无247'
|
|
||||||
else cat = '其他'
|
|
||||||
}
|
|
||||||
setForm(prev => ({ ...prev, numberCategory: cat }))
|
|
||||||
}
|
|
||||||
// 版别变化时同步发行年份
|
// 版别变化时同步发行年份
|
||||||
if (key === 'version') {
|
if (key === 'version') {
|
||||||
const yearMatch = value.match(/(20\d{2})/)
|
const yearMatch = value.match(/(20\d{2})/)
|
||||||
|
|
@ -263,7 +238,6 @@ export default function Edit() {
|
||||||
status: 'f01_04_status', remark: 'f01_05_remark',
|
status: 'f01_04_status', remark: 'f01_05_remark',
|
||||||
prefixSerial: 'f02_10_prefix_serial', version: 'f02_11_version',
|
prefixSerial: 'f02_10_prefix_serial', version: 'f02_11_version',
|
||||||
packaging: 'f02_12_packaging', rarity: 'f02_13_rarity',
|
packaging: 'f02_12_packaging', rarity: 'f02_13_rarity',
|
||||||
numberCategory: 'f02_14_number_category',
|
|
||||||
isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company',
|
isGraded: 'f03_20_is_graded', gradingCompany: 'f03_21_grading_company',
|
||||||
gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star',
|
gradingScore: 'f03_22_grading_score', threeStar: 'f03_23_three_star',
|
||||||
specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature',
|
specialMark: 'f04_30_special_mark', serialFeature: 'f04_31_serial_feature',
|
||||||
|
|
@ -424,7 +398,6 @@ export default function Edit() {
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
|
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
|
||||||
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
|
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
|
||||||
<Input form={form} handleChange={handleChange} label="号码分类" field="numberCategory" options={numberCategoryOptions} />
|
|
||||||
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
|
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
|
||||||
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
|
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
|
||||||
<Input form={form} handleChange={handleChange} label="材质" field="material" />
|
<Input form={form} handleChange={handleChange} label="材质" field="material" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,575 @@
|
||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
|
||||||
|
// 通用 Input 组件
|
||||||
|
const Input = ({ form, handleChange, label, field, type = 'text', options = null }) => {
|
||||||
|
const onChange = (e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
if (type === 'number' && value !== '') {
|
||||||
|
const num = parseFloat(value)
|
||||||
|
handleChange(field, isNaN(num) ? '' : num)
|
||||||
|
} else handleChange(field, value)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minWidth: '45%', marginBottom: '12px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>{label}</div>
|
||||||
|
{options ? (
|
||||||
|
<select value={form[field] || ''} onChange={onChange}
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }}>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{options.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input type={type} value={form[field] ?? ''} onChange={onChange}
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '6px', width: '100%', fontSize: '13px' }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Edit() {
|
||||||
|
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
||||||
|
const id = params.get('id')
|
||||||
|
|
||||||
|
const [collection, setCollection] = useState(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [form, setForm] = useState({})
|
||||||
|
const [showDelete, setShowDelete] = useState(false)
|
||||||
|
const [collectionImages, setCollectionImages] = useState([])
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
const [editingImageIndex, setEditingImageIndex] = useState(-1)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) {
|
||||||
|
fetchDetail()
|
||||||
|
}
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
// 图片上传处理
|
||||||
|
const handleImageUpload = async (e) => {
|
||||||
|
const files = Array.from(e.target.files)
|
||||||
|
if (files.length === 0) return
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const maxImages = 3
|
||||||
|
|
||||||
|
// 检查是否超过限制
|
||||||
|
const currentCount = collection.images ? collection.images.length : 0
|
||||||
|
if (editingImageIndex === -1 && currentCount + files.length > maxImages) {
|
||||||
|
alert(`最多只能上传${maxImages}张图片`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更换图片(点击已有图片)
|
||||||
|
if (editingImageIndex >= 0) {
|
||||||
|
const file = files[0]
|
||||||
|
const oldImage = collection.images[editingImageIndex]
|
||||||
|
|
||||||
|
const imgFormData = new FormData()
|
||||||
|
imgFormData.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 先上传新图片
|
||||||
|
const uploadRes = await fetch(`/api/collections/upload-image?collection_id=${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
|
body: imgFormData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (uploadRes.ok) {
|
||||||
|
// 删除旧图片
|
||||||
|
await fetch(`/api/collections/images/${oldImage.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 重新加载详情
|
||||||
|
await fetchDetail()
|
||||||
|
alert('图片已更换')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('图片更换失败:', err)
|
||||||
|
alert('更换失败,请重试')
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditingImageIndex(-1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新图片
|
||||||
|
for (const file of files) {
|
||||||
|
const imgFormData = new FormData()
|
||||||
|
imgFormData.append('file', file)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/collections/upload-image?collection_id=${id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
|
body: imgFormData
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
// 重新加载藏品详情
|
||||||
|
await fetchDetail()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('图片上传失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteImage = async (imageId) => {
|
||||||
|
if (!confirm('确定删除这张图片吗?')) return
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/collections/images/${imageId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
await fetchDetail()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('图片删除失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchDetail = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/collections/${id}`, {
|
||||||
|
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
const item = (data.code === 200 ? data.data : data)
|
||||||
|
if (item && item.id) {
|
||||||
|
setCollection(item)
|
||||||
|
// 设置特殊信息默认值
|
||||||
|
const yearMatch = item.version ? item.version.match(/(20\d{2})/) : null
|
||||||
|
const defaultForm = {
|
||||||
|
...item,
|
||||||
|
issuer: item.issuer || '中国人民银行',
|
||||||
|
issueYear: item.issueYear || (yearMatch ? yearMatch[1] : '2024'),
|
||||||
|
material: item.material || '塑料钞',
|
||||||
|
issueQuantity: item.issueQuantity || '1 亿'
|
||||||
|
}
|
||||||
|
setForm(defaultForm)
|
||||||
|
// 加载图片
|
||||||
|
if (item.images && Array.isArray(item.images)) {
|
||||||
|
setCollectionImages(item.images)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载详情失败:', e)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (key, value) => {
|
||||||
|
setForm({ ...form, [key]: value })
|
||||||
|
// 填入出售价时自动把状态改为"已售"
|
||||||
|
if (key === 'targetPrice' && value) {
|
||||||
|
setForm(prev => ({ ...prev, status: 'sold' }))
|
||||||
|
}
|
||||||
|
// 版别变化时同步发行年份
|
||||||
|
if (key === 'version') {
|
||||||
|
const yearMatch = value.match(/(20\d{2})/)
|
||||||
|
if (yearMatch) {
|
||||||
|
setForm(prev => ({ ...prev, issueYear: yearMatch[1] }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
|
||||||
|
// 1. 先检查是否重复(同版别、同冠字号)
|
||||||
|
if (form.prefixSerial) {
|
||||||
|
const checkRes = await fetch(`/api/collections?search=${encodeURIComponent(form.prefixSerial)}`, {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token }
|
||||||
|
})
|
||||||
|
const checkData = await checkRes.json()
|
||||||
|
const collections = checkData.data || checkData || []
|
||||||
|
const duplicate = collections.find(c =>
|
||||||
|
c.version === form.version &&
|
||||||
|
c.prefixSerial === form.prefixSerial &&
|
||||||
|
c.id !== id // 排除自己
|
||||||
|
)
|
||||||
|
if (duplicate) {
|
||||||
|
setError('已经录入过同版同号藏品!')
|
||||||
|
setSaving(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换字段名:camelCase 转字段编码
|
||||||
|
const convertField = (obj) => {
|
||||||
|
const map = {
|
||||||
|
// f99 系统字段
|
||||||
|
id: 'f99_90_id',
|
||||||
|
userId: 'f99_91_user_id',
|
||||||
|
createdAt: 'f99_92_created_at',
|
||||||
|
updatedAt: 'f99_93_updated_at',
|
||||||
|
// f01 基本信息
|
||||||
|
name: 'f01_01_name',
|
||||||
|
code: 'f01_02_code',
|
||||||
|
category: 'f01_03_category',
|
||||||
|
status: 'f01_04_status',
|
||||||
|
remark: 'f01_05_remark',
|
||||||
|
// f02 详细字段
|
||||||
|
prefixSerial: 'f02_10_prefix_serial',
|
||||||
|
version: 'f02_11_version',
|
||||||
|
packaging: 'f02_12_packaging',
|
||||||
|
rarity: 'f02_13_rarity',
|
||||||
|
// f03 评级信息
|
||||||
|
isGraded: 'f03_20_is_graded',
|
||||||
|
gradingCompany: 'f03_21_grading_company',
|
||||||
|
gradingScore: 'f03_22_grading_score',
|
||||||
|
threeStar: 'f03_23_three_star',
|
||||||
|
// f04 特殊信息
|
||||||
|
specialMark: 'f04_30_special_mark',
|
||||||
|
serialFeature: 'f04_31_serial_feature',
|
||||||
|
issuer: 'f04_32_issuer',
|
||||||
|
issueYear: 'f04_33_issue_year',
|
||||||
|
material: 'f04_34_material',
|
||||||
|
denomination: 'f04_35_denomination',
|
||||||
|
issueQuantity: 'f04_36_issue_quantity',
|
||||||
|
// f05 价格信息
|
||||||
|
costPrice: 'f05_40_cost_price',
|
||||||
|
targetPrice: 'f05_41_target_price',
|
||||||
|
goalPrice: 'f05_42_goal_price',
|
||||||
|
repairFee: 'f05_43_repair_fee',
|
||||||
|
gradingFee: 'f05_44_grading_fee',
|
||||||
|
// f06 其他信息
|
||||||
|
purpose: 'f06_50_purpose'
|
||||||
|
}
|
||||||
|
const result = {}
|
||||||
|
// 数字字段列表
|
||||||
|
const numberFields = ['costPrice', 'targetPrice', 'goalPrice', 'repairFee', 'gradingFee']
|
||||||
|
for (const key in obj) {
|
||||||
|
let value = obj[key]
|
||||||
|
// 过滤空字符串为 null
|
||||||
|
if (value === '' || value === null) {
|
||||||
|
value = null
|
||||||
|
}
|
||||||
|
// 数字字段转换为浮点数
|
||||||
|
else if (numberFields.includes(key)) {
|
||||||
|
value = parseFloat(value)
|
||||||
|
if (isNaN(value)) value = null
|
||||||
|
}
|
||||||
|
result[map[key] || key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/collections/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify(convertField(form))
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
alert('保存成功!')
|
||||||
|
// 刷新首页统计数据
|
||||||
|
if (window.refreshHome) {
|
||||||
|
window.refreshHome()
|
||||||
|
}
|
||||||
|
window.location.hash = '#/detail?id=' + id
|
||||||
|
} else {
|
||||||
|
const data = await res.json()
|
||||||
|
alert('保存失败: ' + (data.error || '未知错误'))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('保存失败: ' + e.message)
|
||||||
|
}
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
setShowDelete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const doDelete = async () => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/collections/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
alert('删除成功!')
|
||||||
|
if (window.refreshList) window.refreshList()
|
||||||
|
window.location.hash = '#/list'
|
||||||
|
} else {
|
||||||
|
alert('删除失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('删除失败')
|
||||||
|
}
|
||||||
|
setShowDelete(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
window.location.hash = '#/detail?id=' + id
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'in_collection', label: '收藏中' },
|
||||||
|
{ value: 'selling', label: '出售中' },
|
||||||
|
{ value: 'sold', label: '已售' },
|
||||||
|
{ value: 'grading', label: '送评中' },
|
||||||
|
{ value: 'repairing', label: '修复中' },
|
||||||
|
{ value: 'transit', label: '在途中' },
|
||||||
|
{ value: 'other', label: '其他' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const categoryOptions = [
|
||||||
|
{ value: '自持', label: '自持' },
|
||||||
|
{ value: '寄存', label: '寄存' },
|
||||||
|
{ value: '寄售', label: '寄售' },
|
||||||
|
{ value: '共有', label: '共有' },
|
||||||
|
{ value: '寻号', label: '寻号' },
|
||||||
|
{ value: '其他', label: '其他' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const rarityOptions = [
|
||||||
|
{ value: '通货', label: '通货' },
|
||||||
|
{ value: '特色', label: '特色' },
|
||||||
|
{ value: '少见', label: '少见' },
|
||||||
|
{ value: '稀有', label: '稀有' },
|
||||||
|
{ value: '珍品', label: '珍品' },
|
||||||
|
{ value: '孤品', label: '孤品' }
|
||||||
|
]
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div style={{ padding: '40px', textAlign: 'center', color: '#94a3b8' }}>加载中...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#0f172a', minHeight: '100vh', overflowY: 'auto', paddingBottom: '120px' }}>
|
||||||
|
{/* 顶部 */}
|
||||||
|
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', position: 'sticky', top: 0, zIndex: 100 }}>
|
||||||
|
<button onClick={goBack} style={{ background: 'none', border: 'none', fontSize: '20px', cursor: 'pointer', padding: '8px', color: '#fff' }}>←</button>
|
||||||
|
<span style={{ fontSize: '18px', fontWeight: 'bold', marginLeft: '8px', color: '#fff' }}>编辑藏品</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ padding: '16px' }}>
|
||||||
|
{/* 图片预览区域 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
||||||
|
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold' }}>藏品图片</div>
|
||||||
|
{collectionImages.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: 'rgba(34, 197, 94, 0.2)',
|
||||||
|
color: '#22c55e',
|
||||||
|
border: '1px solid #22c55e',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📷 更换图片
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleImageUpload}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{collectionImages.length > 0 ? (
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '12px' }}>
|
||||||
|
{collectionImages.map((img, index) => (
|
||||||
|
<div key={img.id || index} style={{ position: 'relative' }}>
|
||||||
|
<img
|
||||||
|
src={`http://120.26.133.10:3000/${img.path || `uploads/collections/${img.filename}`}`}
|
||||||
|
alt={img.original_name || img.filename || '藏品图片'}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
aspectRatio: '1.5',
|
||||||
|
objectFit: 'contain',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '2px solid #10b981',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.3)',
|
||||||
|
background: 'rgba(0,0,0,0.3)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '8px',
|
||||||
|
left: '8px',
|
||||||
|
background: 'rgba(0,0,0,0.6)',
|
||||||
|
color: '#fff',
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>{index + 1}/{collectionImages.length}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
style={{
|
||||||
|
aspectRatio: '1.5',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
border: '2px dashed #fbbf24',
|
||||||
|
borderRadius: '12px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: '#fbbf24',
|
||||||
|
fontSize: '12px',
|
||||||
|
transition: 'all 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '48px', marginBottom: '8px' }}>📷</div>
|
||||||
|
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>点击上传图片</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#94a3b8' }}>
|
||||||
|
最多可上传 1 张
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
|
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="持仓类型" field="category" options={categoryOptions} />
|
||||||
|
<Input form={form} handleChange={handleChange} label="珍惜度" field="rarity" options={rarityOptions} />
|
||||||
|
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="版别" field="version" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="面值" field="denomination" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="状态" field="status" options={statusOptions} />
|
||||||
|
<Input form={form} handleChange={handleChange} label="包装" field="packaging" options={packagingOptions} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '24px', flexWrap: 'wrap' }}>
|
||||||
|
<div style={{ flex: 1, minWidth: '45%' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>编号</div>
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', color: '#fbbf24', padding: '8px', borderRadius: '6px', fontSize: '13px', fontFamily: 'monospace', fontWeight: 'bold' }}>
|
||||||
|
{form.code || '(保存后自动生成)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, minWidth: '45%' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>创建时间</div>
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', color: '#94a3b8', padding: '8px', borderRadius: '6px', fontSize: '13px' }}>
|
||||||
|
{form.createdAt ? new Date(form.createdAt).toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }).replace(/\//g, '-') : '(保存后自动生成)'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 评级信息 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>评级信息</div>
|
||||||
|
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="isGraded"
|
||||||
|
checked={form.isGraded || false}
|
||||||
|
onChange={(e) => handleChange('isGraded', e.target.checked)}
|
||||||
|
style={{ width: '22px', height: '22px', cursor: 'pointer', backgroundColor: form.isGraded ? '#22c55e' : 'rgba(255,255,255,0.1)', border: '2px solid #22c55e', borderRadius: '4px', appearance: 'none', WebkitAppearance: 'none' }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="isGraded" style={{ color: '#fff', cursor: 'pointer', fontSize: '15px' }}>是否评级</label>
|
||||||
|
</div>
|
||||||
|
<Input form={form} handleChange={handleChange} label="评级公司" field="gradingCompany" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="评级分数" field="gradingScore" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="特殊标识" field="specialMark" />
|
||||||
|
<div style={{ marginBottom: '12px', display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="threeStar"
|
||||||
|
checked={form.threeStar || false}
|
||||||
|
onChange={(e) => handleChange('threeStar', e.target.checked)}
|
||||||
|
style={{ width: '22px', height: '22px', cursor: 'pointer', backgroundColor: form.threeStar ? '#22c55e' : 'rgba(255,255,255,0.1)', border: '2px solid #22c55e', borderRadius: '4px', appearance: 'none', WebkitAppearance: 'none' }}
|
||||||
|
/>
|
||||||
|
<label htmlFor="threeStar" style={{ color: '#fff', cursor: 'pointer', fontSize: '15px' }}>三星</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 特殊信息 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>特殊信息</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '12px' }}>
|
||||||
|
<Input form={form} handleChange={handleChange} label="号码特征" field="serialFeature" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="材质" field="material" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="发行量" field="issueQuantity" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 价格信息 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ color: '#fbbf24', fontSize: '13px', fontWeight: 'bold', marginBottom: '12px' }}>价格信息</div>
|
||||||
|
<Input form={form} handleChange={handleChange} label="成本价" field="costPrice" type="number" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="目标价" field="targetPrice" type="number" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="出售价" field="goalPrice" type="number" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="修复费" field="repairFee" type="number" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="评级费" field="gradingFee" type="number" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="用途" field="purpose" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 备注 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '4px' }}>备注</div>
|
||||||
|
<textarea
|
||||||
|
value={form.remark || ''}
|
||||||
|
onChange={(e) => handleChange('remark', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '8px', borderRadius: '8px', width: '100%', resize: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 按钮 */}
|
||||||
|
<button onClick={saving ? null : handleSave} disabled={saving} style={{ width: '100%', padding: '14px', background: saving ? '#64748b' : '#fbbf24', color: '#1e293b', border: 'none', borderRadius: '10px', fontSize: '16px', fontWeight: '600', marginBottom: '12px', cursor: saving ? 'not-allowed' : 'pointer' }}>
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onClick={handleDelete} style={{ width: '100%', padding: '14px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid #ef4444', borderRadius: '10px', fontSize: '16px', cursor: 'pointer' }}>
|
||||||
|
删除藏品
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 删除确认弹窗 */}
|
||||||
|
{showDelete && (
|
||||||
|
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
|
||||||
|
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', width: '80%', maxWidth: '300px' }}>
|
||||||
|
<div style={{ color: '#fff', fontSize: '16px', fontWeight: 'bold', marginBottom: '16px', textAlign: 'center' }}>确定删除此藏品?</div>
|
||||||
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
|
<button onClick={() => setShowDelete(false)} style={{ flex: 1, padding: '12px', background: 'rgba(255,255,255,0.1)', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px' }}>取消</button>
|
||||||
|
<button onClick={doDelete} style={{ flex: 1, padding: '12px', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '13px' }}>删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* Home - 首页
|
|
||||||
* Version: 1.2.95x
|
|
||||||
* 更新:
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { APP_VERSION } from '../config/version'
|
import { APP_VERSION } from '../config/version'
|
||||||
|
|
||||||
|
|
@ -11,10 +5,6 @@ export default function Home() {
|
||||||
const [user, setUser] = useState(null)
|
const [user, setUser] = useState(null)
|
||||||
const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 })
|
const [stats, setStats] = useState({ totalCount: 0, totalCost: 0, totalRevenue: 0, expectedProfit: 0, totalProfit: 0, gradedCount: 0 })
|
||||||
const [recentCollections, setRecentCollections] = useState([])
|
const [recentCollections, setRecentCollections] = useState([])
|
||||||
const [yichensStats, setYichensStats] = useState({ total: 0, deals: 0, wants: 0, dragons: 0, horses: 0, tianma: 0 })
|
|
||||||
const [seekStats, setSeekStats] = useState({ seekCount: 0, matchedCount: 0 })
|
|
||||||
const [recentPosts, setRecentPosts] = useState([])
|
|
||||||
const [dragonStats, setDragonStats] = useState({})
|
|
||||||
const currentPath = window.location.hash.slice(1) || '/'
|
const currentPath = window.location.hash.slice(1) || '/'
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -64,31 +54,6 @@ export default function Home() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 获取一尘看板数据
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('/api/yichens/stats/dragons-today').then(res => res.json()).then(data => {
|
|
||||||
setDragonStats(data || {})
|
|
||||||
}).catch(() => {})
|
|
||||||
|
|
||||||
fetch('/api/yichens/stats/today').then(res => res.json()).then(data => {
|
|
||||||
setYichensStats(data || {})
|
|
||||||
}).catch(() => {})
|
|
||||||
|
|
||||||
// 获取最新一尘帖子
|
|
||||||
fetch('/api/yichens/stats/dragons-today').then(res => res.json()).then(data => {
|
|
||||||
setDragonStats(data || {})
|
|
||||||
}).catch(() => {})
|
|
||||||
|
|
||||||
fetch('/api/yichens/posts?limit=20&offset=0').then(res => res.json()).then(data => {
|
|
||||||
setRecentPosts(data.posts || data || [])
|
|
||||||
}).catch(() => {})
|
|
||||||
|
|
||||||
// 获取寻配号统计数据
|
|
||||||
fetch('/api/information/seek/stats').then(res => res.json()).then(data => {
|
|
||||||
setSeekStats(data || {})
|
|
||||||
}).catch(() => {})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 检查是否为管理员
|
// 检查是否为管理员
|
||||||
const isAdmin = user && user.role === 'admin'
|
const isAdmin = user && user.role === 'admin'
|
||||||
|
|
||||||
|
|
@ -102,16 +67,16 @@ export default function Home() {
|
||||||
const formatMoney = (val) => {
|
const formatMoney = (val) => {
|
||||||
if (!val || val === 0) return '0'
|
if (!val || val === 0) return '0'
|
||||||
const v = val / 10000
|
const v = val / 10000
|
||||||
return v.toFixed(1)
|
return v.toFixed(4)
|
||||||
}
|
}
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: '藏品数', value: stats.totalCount, color: '#3b82f6' },
|
{ label: '藏品数', value: stats.totalCount, color: '#3b82f6', icon: '📦' },
|
||||||
{ label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e' },
|
{ label: '总成本', value: formatMoney(stats.totalCost) + '万', color: '#22c55e', icon: '💰' },
|
||||||
{ label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4' },
|
{ label: '总收入', value: formatMoney(stats.totalRevenue) + '万', color: '#06b6d4', icon: '📈' },
|
||||||
{ label: '评级数', value: stats.gradedCount, color: '#8b5cf6' },
|
{ label: '评级数', value: stats.gradedCount, color: '#8b5cf6', icon: '⭐' },
|
||||||
{ label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444' },
|
{ label: '预期利润', value: formatMoney(stats.expectedProfit) + '万', color: stats.expectedProfit >= 0 ? '#f59e0b' : '#ef4444', icon: '🎯' },
|
||||||
{ label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e' }
|
{ label: '总利润', value: formatMoney(stats.totalProfit) + '万', color: stats.totalProfit >= 0 ? '#10b981' : '#f43f5e', icon: '💵' }
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -133,7 +98,7 @@ export default function Home() {
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
<img src="/static/images/jiachenlong-logo.png" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} />
|
<img src="/static/images/jiachenlong-logo.png" alt="甲辰收藏" style={{ width: '48px', height: '48px', borderRadius: '50%', objectFit: 'cover' }} />
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '600' }}>{user?.username || '用户'} <span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '11px', fontWeight: 'normal' }}>ID:{user?.user_code || '-'}</span></div>
|
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '600' }}>{user?.username || '用户'}</div>
|
||||||
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div>
|
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '12px' }}>欢迎回来 👋</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
|
@ -166,7 +131,7 @@ export default function Home() {
|
||||||
onMouseOut={e => { e.currentTarget.style.transform = 'translateY(0)' }}
|
onMouseOut={e => { e.currentTarget.style.transform = 'translateY(0)' }}
|
||||||
onClick={() => window.location.hash = '#/stats'}
|
onClick={() => window.location.hash = '#/stats'}
|
||||||
>
|
>
|
||||||
|
<div style={{ fontSize: '24px', marginBottom: '4px' }}>{card.icon}</div>
|
||||||
<div style={{ color: card.color, fontSize: '18px', fontWeight: '700', marginBottom: '4px' }}>{card.value}</div>
|
<div style={{ color: card.color, fontSize: '18px', fontWeight: '700', marginBottom: '4px' }}>{card.value}</div>
|
||||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>{card.label}</div>
|
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>{card.label}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -180,171 +145,41 @@ export default function Home() {
|
||||||
<div onClick={() => window.location.hash = '#/add?mode=ocr'} style={{
|
<div onClick={() => window.location.hash = '#/add?mode=ocr'} style={{
|
||||||
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
background: 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '12px 16px',
|
padding: '16px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: '12px'
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>藏品录入</div>
|
<div style={{ fontSize: '24px' }}>📷</div>
|
||||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>AI识别添加</div>
|
<div>
|
||||||
|
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>AI识别</div>
|
||||||
|
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>拍照识别藏品</div>
|
||||||
</div>
|
</div>
|
||||||
<div onClick={() => window.location.hash = '#/add?mode=deal'} style={{
|
|
||||||
background: 'linear-gradient(135deg, #ec4899 0%, #db2777 100%)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '12px 16px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>行情录入</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>录入成交行情</div>
|
|
||||||
</div>
|
|
||||||
<div onClick={() => window.location.hash = '#/news?type=seek'} style={{
|
|
||||||
background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '12px 16px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>发布寻号</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>发布寻配需求</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div onClick={() => window.location.hash = '#/add?mode=manual'} style={{
|
<div onClick={() => window.location.hash = '#/add?mode=manual'} style={{
|
||||||
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
background: 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '12px 16px',
|
padding: '16px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
gap: '12px'
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
}}>
|
||||||
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '600', whiteSpace: 'nowrap' }}>发布藏品</div>
|
<div style={{ fontSize: '24px' }}>✏️</div>
|
||||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '10px', marginTop: '2px' }}>手动发布</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 寻配号数据 */}
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>🔍 寻配号数据</div>
|
|
||||||
<div onClick={() => window.location.hash = '#/news?type=seek'} style={{
|
|
||||||
background: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)',
|
|
||||||
borderRadius: '12px',
|
|
||||||
padding: '16px',
|
|
||||||
border: '1px solid rgba(245,158,11,0.2)',
|
|
||||||
cursor: 'pointer'
|
|
||||||
}}>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px', textAlign: 'center' }}>
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: '#fbbf24', fontSize: '18px', fontWeight: '700' }}>{seekStats.seekCount || 0}</div>
|
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '600' }}>手动录入</div>
|
||||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>寻号需求</div>
|
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '11px' }}>添加新藏品</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<div style={{ color: '#34d399', fontSize: '18px', fontWeight: '700' }}>{seekStats.userMatchedCount || 0}</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>我的匹配</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ color: '#a78bfa', fontSize: '18px', fontWeight: '700' }}>{seekStats.totalMatchedCount || 0}</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '11px' }}>总共匹配</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 一尘今日数据 */}
|
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>📊 一尘今日连体纪念钞数据</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '8px' }}>
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(59,130,246,0.15) 0%, rgba(59,130,246,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(59,130,246,0.2)', textAlign: 'center' }}>
|
|
||||||
<div style={{ color: '#60a5fa', fontSize: '20px', fontWeight: '700' }}>{yichensStats.total || 0}</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>总帖子</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(16,185,129,0.15) 0%, rgba(16,185,129,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(16,185,129,0.2)', textAlign: 'center' }}>
|
|
||||||
<div style={{ color: '#34d399', fontSize: '20px', fontWeight: '700' }}>{yichensStats.deals || 0}</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>出售</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(245,158,11,0.15) 0%, rgba(245,158,11,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(245,158,11,0.2)', textAlign: 'center' }}>
|
|
||||||
<div style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '700' }}>{yichensStats.wants || 0}</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>求购</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(139,92,246,0.15) 0%, rgba(139,92,246,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(139,92,246,0.2)', textAlign: 'center' }}>
|
|
||||||
<div style={{ color: '#a78bfa', fontSize: '20px', fontWeight: '700' }}>{yichensStats.dragons || 0}</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>龙钞</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(236,72,153,0.15) 0%, rgba(236,72,153,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(236,72,153,0.2)', textAlign: 'center' }}>
|
|
||||||
<div style={{ color: '#f472b6', fontSize: '20px', fontWeight: '700' }}>{yichensStats.horses || 0}</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>马钞</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(20,184,166,0.15) 0%, rgba(20,184,166,0.05) 100%)', borderRadius: '12px', padding: '12px 8px', border: '1px solid rgba(20,184,166,0.2)', textAlign: 'center' }}>
|
|
||||||
<div style={{ color: '#2dd4bf', fontSize: '20px', fontWeight: '700' }}>{yichensStats.tianma || 0}</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.5)', fontSize: '10px' }}>天马</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 今日龙钞帖子数据统计 */}
|
{/* 最近藏品 */}
|
||||||
<div style={{ marginBottom: '20px' }}>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', marginBottom: '12px', paddingLeft: '4px' }}>🐉 今日龙钞帖子数据统计</div>
|
|
||||||
<div style={{ background: 'linear-gradient(180deg, rgba(99,102,241,0.15) 0%, rgba(99,102,241,0.05) 100%)', borderRadius: '12px', padding: '16px', border: '1px solid rgba(99,102,241,0.2)' }}>
|
|
||||||
{/* 表头 */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '8px' }}>
|
|
||||||
<div></div>
|
|
||||||
<div style={{ color: '#22c55e', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>出售</div>
|
|
||||||
<div style={{ color: '#f97316', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>求购</div>
|
|
||||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', fontWeight: '600', textAlign: 'center' }}>合计</div>
|
|
||||||
</div>
|
|
||||||
{/* 数据行 */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
|
|
||||||
<div style={{ color: '#ef4444', fontSize: '14px', fontWeight: '500' }}>带4</div>
|
|
||||||
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.dai4?.deals || 0}</div>
|
|
||||||
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.dai4?.wants || 0}</div>
|
|
||||||
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.dai4?.deals || 0) + (dragonStats.dai4?.wants || 0)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
|
|
||||||
<div style={{ color: '#06b6d4', fontSize: '14px', fontWeight: '500' }}>带7号</div>
|
|
||||||
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu4?.deals || 0}</div>
|
|
||||||
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu4?.wants || 0}</div>
|
|
||||||
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu4?.deals || 0) + (dragonStats.wu4?.wants || 0)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
|
|
||||||
<div style={{ color: '#3b82f6', fontSize: '14px', fontWeight: '500' }}>无47</div>
|
|
||||||
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu47?.deals || 0}</div>
|
|
||||||
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu47?.wants || 0}</div>
|
|
||||||
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu47?.deals || 0) + (dragonStats.wu47?.wants || 0)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px', marginBottom: '6px' }}>
|
|
||||||
<div style={{ color: '#a855f7', fontSize: '14px', fontWeight: '500' }}>无247</div>
|
|
||||||
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu247?.deals || 0}</div>
|
|
||||||
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu247?.wants || 0}</div>
|
|
||||||
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu247?.deals || 0) + (dragonStats.wu247?.wants || 0)}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '60px 1fr 1fr 1fr', gap: '8px' }}>
|
|
||||||
<div style={{ color: '#ec4899', fontSize: '14px', fontWeight: '500' }}>无347</div>
|
|
||||||
<div style={{ color: '#22c55e', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu347?.deals || 0}</div>
|
|
||||||
<div style={{ color: '#f97316', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{dragonStats.wu347?.wants || 0}</div>
|
|
||||||
<div style={{ color: '#fff', fontSize: '18px', fontWeight: '700', textAlign: 'center' }}>{(dragonStats.wu347?.deals || 0) + (dragonStats.wu347?.wants || 0)}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 最新一尘发帖 */}
|
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', paddingLeft: '4px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px', paddingLeft: '4px' }}>
|
||||||
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>📝 最新一尘发帖</div>
|
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px' }}>最近藏品</div>
|
||||||
<div onClick={() => window.location.hash = '#/news'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 →</div>
|
<div onClick={() => window.location.hash = '#/list'} style={{ color: '#3b82f6', fontSize: '12px', cursor: 'pointer' }}>查看全部 →</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{
|
||||||
background: 'rgba(255,255,255,0.03)',
|
background: 'rgba(255,255,255,0.03)',
|
||||||
|
|
@ -352,30 +187,24 @@ export default function Home() {
|
||||||
border: '1px solid rgba(255,255,255,0.05)',
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
overflow: 'hidden'
|
overflow: 'hidden'
|
||||||
}}>
|
}}>
|
||||||
{recentPosts.length === 0 ? (
|
{recentCollections.length === 0 ? (
|
||||||
<div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无帖子</div>
|
<div style={{ padding: '40px', textAlign: 'center', color: 'rgba(255,255,255,0.3)' }}>暂无藏品</div>
|
||||||
) : (
|
) : (
|
||||||
recentPosts.map((item, idx) => (
|
recentCollections.map((item, idx) => (
|
||||||
<div key={item.post_id || idx} onClick={() => window.open(item.url, '_blank')} style={{
|
<div key={item.id || idx} onClick={() => window.location.hash = '#/detail?id=' + item.id} style={{
|
||||||
padding: '12px 16px',
|
padding: '12px 16px',
|
||||||
borderBottom: idx < recentPosts.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
|
borderBottom: idx < recentCollections.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center'
|
alignItems: 'center'
|
||||||
}}>
|
}}>
|
||||||
<div style={{ flex: 1 }}>
|
<div>
|
||||||
<div style={{ color: '#fff', fontSize: '14px' }}>{item.title || '无标题'}</div>
|
<div style={{ color: '#fff', fontSize: '14px' }}>{item.code || '-'} <span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px' }}>{item.prefixSerial || ''}</span></div>
|
||||||
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px', marginTop: '2px' }}>{item.category || '-'} · {item.author_username || '未知'} · {item.post_time?.substring(0, 16) || ''}</div>
|
<div style={{ color: 'rgba(255,255,255,0.4)', fontSize: '12px', marginTop: '2px' }}>{item.version || '-'} · {item.status === 'sold' ? '已售' : item.status === 'in_collection' ? '收藏中' : item.status}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{
|
<div style={{ color: item.costPrice ? '#22c55e' : 'rgba(255,255,255,0.3)', fontSize: '13px' }}>
|
||||||
color: item.post_type === 'deal' ? '#10b981' : item.post_type === 'want' ? '#f59e0b' : '#8b5cf6',
|
{item.costPrice ? '¥' + item.costPrice : '-'}
|
||||||
fontSize: '12px',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
background: item.post_type === 'deal' ? 'rgba(16,185,129,0.2)' : item.post_type === 'want' ? 'rgba(245,158,11,0.2)' : 'rgba(139,92,246,0.2)'
|
|
||||||
}}>
|
|
||||||
{item.post_type === 'deal' ? '出售' : item.post_type === 'want' ? '求购' : '其他'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
@ -399,13 +228,13 @@ export default function Home() {
|
||||||
{(isAdmin ? [
|
{(isAdmin ? [
|
||||||
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 },
|
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 },
|
||||||
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
|
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
|
||||||
{ icon: '➕', label: '录入', hash: '#/ocr', idx: 2 },
|
{ icon: '➕', label: '添加', hash: '#/ocr', idx: 2 },
|
||||||
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 },
|
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 },
|
||||||
{ icon: '⚙️', label: '管理', hash: '#/admin', idx: 4 }
|
{ icon: '⚙️', label: '管理', hash: '#/admin', idx: 4 }
|
||||||
] : [
|
] : [
|
||||||
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 },
|
{ icon: '🏠', label: '首页', hash: '#/', idx: 0 },
|
||||||
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
|
{ icon: '📚', label: '藏品', hash: '#/list', idx: 1 },
|
||||||
{ icon: '➕', label: '录入', hash: '#/ocr', idx: 2 },
|
{ icon: '➕', label: '添加', hash: '#/ocr', idx: 2 },
|
||||||
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 }
|
{ icon: '📊', label: '统计', hash: '#/stats', idx: 3 }
|
||||||
]).map((item) => (
|
]).map((item) => (
|
||||||
<div key={item.idx} onClick={() => window.location.hash = item.hash} style={{
|
<div key={item.idx} onClick={() => window.location.hash = item.hash} style={{
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,17 @@
|
||||||
/**
|
|
||||||
* List - 藏品列表页面
|
|
||||||
* Version: 1.2.93x
|
|
||||||
* 更新:
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { APP_VERSION } from '../config/version'
|
import { APP_VERSION } from '../config/version'
|
||||||
const API_BASE = localStorage.getItem('API_BASE') || ''
|
|
||||||
|
|
||||||
export default function List() {
|
export default function List() {
|
||||||
const [collections, setCollections] = useState([])
|
const [collections, setCollections] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
const [filterType, setFilterType] = useState('')
|
const [filterType, setFilterType] = useState('')
|
||||||
const urlParams = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
|
||||||
const urlUserId = urlParams.get('userId') || ''
|
|
||||||
const [userIdFilter, setUserIdFilter] = useState(urlUserId)
|
|
||||||
const [sortField, setSortField] = useState('createdAt')
|
const [sortField, setSortField] = useState('createdAt')
|
||||||
const [sortOrder, setSortOrder] = useState('desc')
|
const [sortOrder, setSortOrder] = useState('desc')
|
||||||
const [viewMode, setViewMode] = useState('list')
|
const [viewMode, setViewMode] = useState('list')
|
||||||
const [key, setKey] = useState(0)
|
const [key, setKey] = useState(0)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [isAdmin, setIsAdmin] = useState(false)
|
const [isAdmin, setIsAdmin] = useState(false)
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [pagination, setPagination] = useState({ total: 0, pages: 1 })
|
|
||||||
const [activeTab, setActiveTab] = useState('collections') // collections-藏品, deals-行情
|
|
||||||
const [dealSearch, setDealSearch] = useState('')
|
|
||||||
const [dealSortField, setDealSortField] = useState('created_at')
|
|
||||||
const [dealSortOrder, setDealSortOrder] = useState('desc')
|
|
||||||
const [myDeals, setMyDeals] = useState([])
|
|
||||||
const [dealsLoading, setDealsLoading] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 检查是否管理员
|
// 检查是否管理员
|
||||||
|
|
@ -74,70 +56,11 @@ export default function List() {
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 获取当前用户录入的行情数据
|
|
||||||
const fetchMyDeals = async () => {
|
|
||||||
setDealsLoading(true)
|
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
const userStr = localStorage.getItem('user')
|
|
||||||
if (!token || !userStr) {
|
|
||||||
setDealsLoading(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const user = JSON.parse(userStr)
|
|
||||||
const res = await fetch(`${API_BASE}/api/deal/list?user_only=true&page_size=500`, {
|
|
||||||
headers: { 'Authorization': 'Bearer ' + token }
|
|
||||||
})
|
|
||||||
const data = await res.json()
|
|
||||||
const list = data.data || data || []
|
|
||||||
setMyDeals(Array.isArray(list) ? list : [])
|
|
||||||
} catch (e) {
|
|
||||||
console.error('获取行情失败:', e)
|
|
||||||
}
|
|
||||||
setDealsLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换tab时加载数据
|
|
||||||
useEffect(() => {
|
|
||||||
if (activeTab === 'deals') {
|
|
||||||
fetchMyDeals()
|
|
||||||
}
|
|
||||||
}, [activeTab])
|
|
||||||
|
|
||||||
// 行情过滤和排序
|
|
||||||
const filteredDeals = myDeals.filter(deal => {
|
|
||||||
if (!dealSearch) return true
|
|
||||||
const s = dealSearch.toLowerCase().trim()
|
|
||||||
const fields = [deal.title, deal.content, deal.category, deal.packaging, deal.grading_company, deal.grading_score, deal.deal_no, deal.seller, deal.platform, deal.buyer, deal.deal_price?.toString(), deal.deal_date].filter(v => v).map(v => v.toString().toLowerCase())
|
|
||||||
return fields.some(f => f.includes(s))
|
|
||||||
}).sort((a, b) => {
|
|
||||||
let aVal = a[dealSortField] || ''
|
|
||||||
let bVal = b[dealSortField] || ''
|
|
||||||
if (dealSortField === 'created_at') {
|
|
||||||
aVal = new Date(a.created_at).getTime()
|
|
||||||
bVal = new Date(b.created_at).getTime()
|
|
||||||
} else if (dealSortField === 'deal_price') {
|
|
||||||
aVal = a.deal_price || 0
|
|
||||||
bVal = b.deal_price || 0
|
|
||||||
}
|
|
||||||
if (dealSortOrder === 'asc') return aVal > bVal ? 1 : -1
|
|
||||||
return aVal < bVal ? 1 : -1
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchCollections = async () => {
|
const fetchCollections = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
try {
|
try {
|
||||||
// 从URL获取筛选参数
|
const res = await fetch('/api/collections?limit=100', {
|
||||||
const params = new URLSearchParams(window.location.hash.split('?')[1] || '')
|
|
||||||
const urlFilterType = params.get('filter') || ''
|
|
||||||
const urlFilterValue = params.get('value') ? decodeURIComponent(params.get('value')) : ''
|
|
||||||
|
|
||||||
let api = '/api/collections?page=' + page + '&limit=100&sortBy=' + sortField + '&sortOrder=' + sortOrder
|
|
||||||
if (urlFilterType && urlFilterValue) {
|
|
||||||
api += '&' + urlFilterType + '=' + encodeURIComponent(urlFilterValue)
|
|
||||||
}
|
|
||||||
const res = await fetch(api, {
|
|
||||||
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
|
headers: token ? { 'Authorization': 'Bearer ' + token } : {}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -157,18 +80,12 @@ export default function List() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 应用筛选条件
|
// 应用筛选条件
|
||||||
// 用户ID筛选(从URL获取)
|
|
||||||
if (userIdFilter) {
|
|
||||||
list = list.filter(item => item.userId === userIdFilter || item.userId === userIdFilter.replace(/-/g, ''))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterType && filter) {
|
if (filterType && filter) {
|
||||||
const fieldMap = {
|
const fieldMap = {
|
||||||
status: 'status',
|
status: 'status',
|
||||||
category: 'category',
|
category: 'category',
|
||||||
packaging: 'packaging',
|
packaging: 'packaging',
|
||||||
rarity: 'rarity',
|
rarity: 'rarity',
|
||||||
numberCategory: 'numberCategory',
|
|
||||||
version: 'version',
|
version: 'version',
|
||||||
gradingCompany: 'gradingCompany',
|
gradingCompany: 'gradingCompany',
|
||||||
gradingScore: 'gradingScore',
|
gradingScore: 'gradingScore',
|
||||||
|
|
@ -195,14 +112,6 @@ export default function List() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setCollections(list || [])
|
setCollections(list || [])
|
||||||
|
|
||||||
// 获取分页信息
|
|
||||||
if (data.pagination) {
|
|
||||||
setPagination({
|
|
||||||
total: data.pagination.total || 0,
|
|
||||||
pages: data.pagination.pages || 1
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Fetch collections error:', e)
|
console.error('Fetch collections error:', e)
|
||||||
// 网络错误也跳转到登录页
|
// 网络错误也跳转到登录页
|
||||||
|
|
@ -213,14 +122,6 @@ export default function List() {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 监听页码变化,重新获取数据
|
|
||||||
useEffect(() => {
|
|
||||||
if (page > 1) {
|
|
||||||
fetchCollections()
|
|
||||||
}
|
|
||||||
}, [page])
|
|
||||||
|
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
setKey(k => k + 1)
|
setKey(k => k + 1)
|
||||||
}
|
}
|
||||||
|
|
@ -369,11 +270,6 @@ export default function List() {
|
||||||
const rarityOrder = { '通货': 1, '特色': 2, '少见': 3, '稀有': 4, '珍品': 5, '孤品': 6 }
|
const rarityOrder = { '通货': 1, '特色': 2, '少见': 3, '稀有': 4, '珍品': 5, '孤品': 6 }
|
||||||
aVal = rarityOrder[aVal] || 0
|
aVal = rarityOrder[aVal] || 0
|
||||||
bVal = rarityOrder[bVal] || 0
|
bVal = rarityOrder[bVal] || 0
|
||||||
} else if (sortField === 'numberCategory') {
|
|
||||||
// 号码分类按自定义顺序排序
|
|
||||||
const numberCategoryOrder = { '无2347': 1, '无347': 2, '无247': 3, '无47': 4, '带7号': 5, '带4号': 6, '其他': 7 }
|
|
||||||
aVal = numberCategoryOrder[aVal] || 99
|
|
||||||
bVal = numberCategoryOrder[bVal] || 99
|
|
||||||
}
|
}
|
||||||
if (aVal == null) return 1
|
if (aVal == null) return 1
|
||||||
if (bVal == null) return -1
|
if (bVal == null) return -1
|
||||||
|
|
@ -413,30 +309,6 @@ export default function List() {
|
||||||
return colors[category] || '#64748b'
|
return colors[category] || '#64748b'
|
||||||
}
|
}
|
||||||
|
|
||||||
const getNumberCategoryColor = (cat) => {
|
|
||||||
const colors = { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '带7号': '#06b6d4', '带4号': '#22c55e', '其他': '#64748b' };
|
|
||||||
return colors[cat] || '#64748b';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRarityColor = (rarity) => {
|
|
||||||
const colors = { '通货': '#22c55e', '特色': '#06b6d4', '少见': '#3b82f6', '稀有': '#ec4899', '珍品': '#ef4444', '孤品': '#8b5cf6' }
|
|
||||||
return colors[rarity] || '#64748b'
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatPrefixSerial = (serial) => {
|
|
||||||
if (!serial) return '-'
|
|
||||||
// 提取J开头的10位(1位J + 9位数字)
|
|
||||||
const match = serial.match(/J(\d{9})/)
|
|
||||||
if (match) return 'J' + match[1]
|
|
||||||
// 如果没有J开头,取前10位
|
|
||||||
return serial.substring(0, 10)
|
|
||||||
}
|
|
||||||
|
|
||||||
const getPackagingColor = (packaging) => {
|
|
||||||
const colors = { '裸钞': '#22c55e', '单张': '#3b82f6', '标十': '#fbbf24', '标百': '#8b5cf6' }
|
|
||||||
return colors[packaging] || '#64748b'
|
|
||||||
}
|
|
||||||
|
|
||||||
const getStatusColor = (status) => {
|
const getStatusColor = (status) => {
|
||||||
const colors = {
|
const colors = {
|
||||||
'in_collection': '#22c55e',
|
'in_collection': '#22c55e',
|
||||||
|
|
@ -470,46 +342,37 @@ export default function List() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ListItem = ({ item }) => (
|
const ListItem = ({ item }) => (
|
||||||
<div onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '10px', marginBottom: '8px', cursor: 'pointer' }}>
|
<div onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '14px', marginBottom: '10px', cursor: 'pointer' }}>
|
||||||
{/* 第1行:编号 + 冠字号 + 包装(蓝色) | 状态 + 持仓类型 + 珍惜度(紫色) */}
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
<div style={{ flex: 1 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
<span style={{ color: '#fff', fontSize: '11px', fontWeight: 'normal' }}>{item.code || '-'}</span>
|
<div style={{ color: '#fff', fontSize: '15px', fontWeight: '500' }}>{item.code || '-'}</div>
|
||||||
<span style={{ color: '#fbbf24', fontSize: '14px', fontFamily: 'monospace', }}>{formatPrefixSerial(item.prefixSerial)}</span>
|
{isAdmin && item.owner_name && <div style={{ color: '#60a5fa', fontSize: '11px', padding: '2px 6px', background: 'rgba(96, 165, 250, 0.2)', borderRadius: '4px' }}>👤 {item.owner_name}</div>}
|
||||||
{item.packaging && <span style={{ background: getPackagingColor(item.packaging) + '20', color: getPackagingColor(item.packaging), fontSize: '10px', padding: '1px 5px', borderRadius: '3px' }}>{item.packaging}</span>}
|
|
||||||
{item.numberCategory && <span style={{ color: getNumberCategoryColor(item.numberCategory), fontSize: '9px', padding: '1px 4px', background: getNumberCategoryColor(item.numberCategory) + '20', borderRadius: '2px', marginLeft: '4px' }}>{item.numberCategory}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: '3px' }}>
|
<div style={{ color: '#fbbf24', fontSize: '13px', fontFamily: 'monospace', marginTop: '4px' }}>{item.prefixSerial || '-'}</div>
|
||||||
{item.status && <span style={{ color: getStatusColor(item.status), fontSize: '9px', padding: '1px 4px', background: getStatusColor(item.status) + '25', borderRadius: '2px' }}>{getStatusText(item.status)}</span>}
|
</div>
|
||||||
{item.category && <span style={{ color: getCategoryColor(item.category), fontSize: '9px', padding: '1px 4px', background: getCategoryColor(item.category) + '25', borderRadius: '2px' }}>{getCategoryText(item.category)}</span>}
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
{item.category && <div style={{ color: getCategoryColor(item.category), fontSize: '12px', padding: '3px 10px', background: getCategoryColor(item.category) + '20', borderRadius: '4px', display: 'inline-block', marginBottom: '4px' }}>{getCategoryText(item.category)}</div>}
|
||||||
|
{item.rarity && <div style={{ color: '#8b5cf6', fontSize: '12px', padding: '3px 10px', background: 'rgba(139, 92, 246, 0.2)', borderRadius: '4px', display: 'inline-block', marginBottom: '4px' }}>{item.rarity}</div>}
|
||||||
|
<div style={{ color: getStatusColor(item.status), fontSize: '12px', padding: '3px 10px', background: getStatusColor(item.status) + '20', borderRadius: '4px', display: 'inline-block' }}>{getStatusText(item.status)}</div>
|
||||||
|
{item.gradingScore && <div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginTop: '6px' }}>{item.gradingScore}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* 第2行:版本(彩色) + 已评级 + 评级公司 + 评级分数 + 三星 + 特殊标识 | 备注 */}
|
<div style={{ display: 'flex', gap: '8px', marginTop: '10px', flexWrap: 'wrap' }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '2px', flexWrap: 'wrap', marginBottom: '4px' }}>
|
{item.version && <span style={{ background: getVersionColor(item.version).bg, color: getVersionColor(item.version).color, fontSize: '12px', fontWeight: 'bold', padding: '4px 10px', borderRadius: '4px' }}>{item.version}</span>}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '2px', flexWrap: 'wrap' }}>
|
{item.packaging && <span style={{ background: 'rgba(255,255,255,0.08)', color: '#94a3b8', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.packaging}</span>}
|
||||||
{item.version && <span style={{ background: getVersionColor(item.version).bg, color: getVersionColor(item.version).color, fontSize: '10px', padding: '1px 5px', borderRadius: '3px' }}>{item.version}</span>}
|
{item.isGraded ? <span style={{ background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>已评级</span> : <span style={{ background: 'rgba(255,255,255,0.08)', color: '#64748b', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>未评级</span>}
|
||||||
{item.isGraded && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>已评级</span>}
|
{item.gradingCompany && <span style={{ background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.gradingCompany}</span>}
|
||||||
{item.gradingCompany && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>{item.gradingCompany.substring(0,4)}</span>}
|
{item.threeStar && <span style={{ background: 'rgba(251, 191, 36, 0.15)', color: '#fbbf24', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>三星</span>}
|
||||||
{item.gradingScore && <span style={{ background: 'rgba(249, 115, 22, 0.2)', color: '#f97316', fontSize: '9px', padding: '1px 4px', borderRadius: '2px', }}>{item.gradingScore}</span>}
|
{item.specialMark && <span style={{ background: 'rgba(139, 92, 246, 0.15)', color: '#8b5cf6', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.specialMark}</span>}
|
||||||
{item.threeStar && <span style={{ background: 'rgba(6, 182, 212, 0.15)', color: '#06b6d4', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>三星</span>}
|
{item.remark && <span style={{ background: 'rgba(100,116,139,0.2)', color: '#94a3b8', fontSize: '11px', padding: '2px 8px', borderRadius: '4px' }}>{item.remark}</span>}
|
||||||
{item.specialMark && <span style={{ background: 'rgba(139, 92, 246, 0.15)', color: '#a78bfa', fontSize: '9px', padding: '1px 4px', borderRadius: '2px' }}>{item.specialMark}</span>}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '2px', marginLeft: 'auto' }}>
|
|
||||||
{item.rarity && <span style={{ color: getRarityColor(item.rarity), fontSize: '9px', padding: '1px 4px', background: getRarityColor(item.rarity) + '20', borderRadius: '2px' }}>{item.rarity}</span>}
|
|
||||||
{item.remark && <span style={{ background: 'rgba(100,116,139,0.2)', color: '#94a3b8', fontSize: '9px', padding: '1px 4px', borderRadius: '2px', maxWidth: '80px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{item.remark}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* 第3行:成本 + 修复 + 评级 | 目标 + 出售 */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '10px', color: '#94a3b8', paddingTop: '4px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
{item.costPrice && <span>成本: <span style={{ color: '#e2e8f0' }}>¥{item.costPrice}</span></span>}
|
|
||||||
{item.repairFee && <span>修复: <span style={{ color: '#e2e8f0' }}>¥{item.repairFee}</span></span>}
|
|
||||||
{item.gradingFee && <span>评级: <span style={{ color: '#e2e8f0' }}>¥{item.gradingFee}</span></span>}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
{item.targetPrice && <span>目标: <span style={{ color: '#fbbf24' }}>¥{item.targetPrice}</span></span>}
|
|
||||||
{item.goalPrice && <span>出售: <span style={{ color: '#4ade80' }}>¥{item.goalPrice}</span></span>}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '10px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
|
{item.costPrice && <span style={{ color: '#94a3b8', fontSize: '12px' }}>成本: <span style={{ color: '#fff' }}>¥{item.costPrice}</span></span>}
|
||||||
|
{item.targetPrice && <span style={{ color: '#94a3b8', fontSize: '12px' }}>目标: <span style={{ color: '#f59e0b' }}>¥{item.targetPrice}</span></span>}
|
||||||
|
{item.goalPrice && <span style={{ color: '#94a3b8', fontSize: '12px' }}>出售: <span style={{ color: '#22c55e' }}>¥{item.goalPrice}</span></span>}
|
||||||
|
{item.repairFee && <span style={{ color: '#94a3b8', fontSize: '12px' }}>修复: <span style={{ color: '#fff' }}>¥{item.repairFee}</span></span>}
|
||||||
|
{item.gradingFee && <span style={{ color: '#94a3b8', fontSize: '12px' }}>评级: <span style={{ color: '#fff' }}>¥{item.gradingFee}</span></span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
@ -518,18 +381,7 @@ export default function List() {
|
||||||
<div style={{ minHeight: '100vh', overflowY: 'auto', background: '#0f172a', paddingBottom: '70px' }}>
|
<div style={{ minHeight: '100vh', overflowY: 'auto', background: '#0f172a', paddingBottom: '70px' }}>
|
||||||
<div style={{ padding: '20px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
<div style={{ padding: '20px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '16px' }}>
|
||||||
<div style={{ color: '#fff', fontSize: '20px' }}>
|
<div style={{ color: '#fff', fontSize: '20px', fontWeight: 'bold' }}>我的藏品 <span style={{ fontSize: '14px', color: '#fbbf24' }}>({filteredCollections.length})</span></div>
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button onClick={() => setActiveTab('collections')}
|
|
||||||
style={{ padding: '6px 16px', background: activeTab === 'collections' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'collections' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
|
|
||||||
我的藏品 ({filteredCollections.length})
|
|
||||||
</button>
|
|
||||||
<button onClick={() => setActiveTab('deals')}
|
|
||||||
style={{ padding: '6px 16px', background: activeTab === 'deals' ? '#fbbf24' : 'rgba(255,255,255,0.05)', color: activeTab === 'deals' ? '#1e293b' : '#fff', border: 'none', borderRadius: '6px', fontSize: '13px', fontWeight: '600', cursor: 'pointer' }}>
|
|
||||||
我的行情 ({filteredDeals.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
{filter && filterType && (
|
{filter && filterType && (
|
||||||
<button
|
<button
|
||||||
|
|
@ -538,7 +390,7 @@ export default function List() {
|
||||||
setFilterType('')
|
setFilterType('')
|
||||||
window.location.hash = '#/list'
|
window.location.hash = '#/list'
|
||||||
}}
|
}}
|
||||||
style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: 'none', borderRadius: '6px', padding: '4px 10px', fontSize: '12px', cursor: 'pointer' }}
|
style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#ef4444', border: 'none', borderRadius: '6px', padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
✕ 清除筛选
|
✕ 清除筛选
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -549,15 +401,13 @@ export default function List() {
|
||||||
|
|
||||||
{filter && filterType && (
|
{filter && filterType && (
|
||||||
<div style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', borderRadius: '8px', padding: '12px', marginBottom: '16px' }}>
|
<div style={{ background: 'rgba(59, 130, 246, 0.1)', border: '1px solid rgba(59, 130, 246, 0.3)', borderRadius: '8px', padding: '12px', marginBottom: '16px' }}>
|
||||||
<div style={{ color: '#60a5fa', fontSize: '12px', }}>当前筛选:</div>
|
<div style={{ color: '#60a5fa', fontSize: '13px', fontWeight: 'bold' }}>当前筛选:</div>
|
||||||
<div style={{ color: '#fff', fontSize: '14px', marginTop: '4px' }}>
|
<div style={{ color: '#fff', fontSize: '14px', marginTop: '4px' }}>
|
||||||
{getFilterLabel(filterType)} = <span style={{ color: '#fbbf24', }}>{getFilterValueLabel(filterType, filter)}</span>
|
{getFilterLabel(filterType)} = <span style={{ color: '#fbbf24', fontWeight: 'bold' }}>{getFilterValueLabel(filterType, filter)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'collections' && (
|
|
||||||
<>
|
|
||||||
{/* 搜索框 - 全字段搜索 */}
|
{/* 搜索框 - 全字段搜索 */}
|
||||||
<div style={{ position: 'relative', marginBottom: '16px' }}>
|
<div style={{ position: 'relative', marginBottom: '16px' }}>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
|
|
@ -570,7 +420,7 @@ export default function List() {
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
padding: '4px 40px 4px 8px',
|
padding: '12px 40px 12px 12px',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
|
|
@ -595,7 +445,7 @@ export default function List() {
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'flex-start'
|
justifyContent: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
|
|
@ -605,12 +455,15 @@ export default function List() {
|
||||||
|
|
||||||
{/* 排序表头按钮 */}
|
{/* 排序表头按钮 */}
|
||||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap', marginBottom: '12px' }}>
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap', marginBottom: '12px' }}>
|
||||||
<span style={{ color: '#94a3b8', fontSize: '12px', marginRight: '2px' }}>排序:</span>
|
<span style={{ color: '#94a3b8', fontSize: '13px', marginRight: '4px' }}>排序:</span>
|
||||||
{[
|
{[
|
||||||
{ key: 'code', label: '编号' },
|
{ key: 'code', label: '编号' },
|
||||||
|
{ key: 'prefixSerial', label: '冠字号' },
|
||||||
|
{ key: 'costPrice', label: '成本' },
|
||||||
|
{ key: 'goalPrice', label: '售价' },
|
||||||
|
{ key: 'category', label: '持仓类型' },
|
||||||
{ key: 'rarity', label: '珍惜度' },
|
{ key: 'rarity', label: '珍惜度' },
|
||||||
{ key: 'numberCategory', label: '号码分类' },
|
{ key: 'gradingScore', label: '评级分数' }
|
||||||
{ key: 'packaging', label: '包装类型' },
|
|
||||||
].map(item => (
|
].map(item => (
|
||||||
<div key={item.key} onClick={() => {
|
<div key={item.key} onClick={() => {
|
||||||
if (sortField === item.key) {
|
if (sortField === item.key) {
|
||||||
|
|
@ -620,9 +473,9 @@ export default function List() {
|
||||||
setSortOrder('desc')
|
setSortOrder('desc')
|
||||||
}
|
}
|
||||||
}} style={{
|
}} style={{
|
||||||
padding: '4px 8px',
|
padding: '8px 14px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
fontSize: '12px',
|
fontSize: '13px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
background: sortField === item.key ? (sortOrder === 'asc' ? '#22c55e' : '#fbbf24') : 'rgba(255,255,255,0.08)',
|
background: sortField === item.key ? (sortOrder === 'asc' ? '#22c55e' : '#fbbf24') : 'rgba(255,255,255,0.08)',
|
||||||
color: sortField === item.key ? '#fff' : '#94a3b8',
|
color: sortField === item.key ? '#fff' : '#94a3b8',
|
||||||
|
|
@ -633,70 +486,9 @@ export default function List() {
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 分页组件 - 仅在藏品tab下显示 */}
|
|
||||||
{activeTab === 'collections' && pagination.pages > 1 && (
|
|
||||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', justifyContent: 'flex-start', marginBottom: '12px', padding: '8px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px' }}>
|
|
||||||
<button onClick={() => { setPage(Math.max(1, page - 1)) }} disabled={page === 1} style={{ padding: '4px 10px', borderRadius: '6px', border: 'none', background: page === 1 ? 'rgba(255,255,255,0.1)' : '#3b82f6', color: '#fff', cursor: page === 1 ? 'not-allowed' : 'pointer', opacity: page === 1 ? 0.5 : 1 }}>上一页</button>
|
|
||||||
|
|
||||||
{Array.from({ length: Math.min(5, pagination.pages) }, (_, i) => {
|
|
||||||
let startPage = Math.max(1, page - 2)
|
|
||||||
return <button key={i} onClick={() => { setPage(startPage + i) }} style={{ padding: '4px 8px', borderRadius: '6px', border: 'none', background: page === startPage + i ? '#3b82f6' : 'rgba(255,255,255,0.08)', color: '#fff', cursor: 'pointer' }}>{startPage + i}</button>
|
|
||||||
})}
|
|
||||||
|
|
||||||
<button onClick={() => { setPage(Math.min(pagination.pages, page + 1)) }} disabled={page === pagination.pages} style={{ padding: '4px 10px', borderRadius: '6px', border: 'none', background: page === pagination.pages ? 'rgba(255,255,255,0.1)' : '#3b82f6', color: '#fff', cursor: page === pagination.pages ? 'not-allowed' : 'pointer', opacity: page === pagination.pages ? 0.5 : 1 }}>下一页</button>
|
|
||||||
|
|
||||||
<span style={{ color: '#94a3b8', fontSize: '11px', marginLeft: '8px' }}>共{pagination.total}条</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '16px' }}>
|
<div style={{ padding: '16px' }}>
|
||||||
{/* 行情tab内容 */}
|
|
||||||
{activeTab === 'deals' && (
|
|
||||||
<div>
|
|
||||||
{/* 行情搜索框 */}
|
|
||||||
<div style={{ marginBottom: '12px' }}>
|
|
||||||
<input type="text"
|
|
||||||
placeholder="🔍 搜索行情..."
|
|
||||||
value={dealSearch}
|
|
||||||
onChange={e => setDealSearch(e.target.value)}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
background: 'rgba(255,255,255,0.05)',
|
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
|
||||||
color: '#fff',
|
|
||||||
padding: '10px 12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontSize: '14px',
|
|
||||||
outline: 'none',
|
|
||||||
boxSizing: 'border-box'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{dealsLoading ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>加载中...</div>
|
|
||||||
) : filteredDeals.length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', padding: '60px 0' }}>
|
|
||||||
<div style={{ fontSize: '50px', opacity: 0.3 }}>📊</div>
|
|
||||||
<div style={{ color: '#64748b', marginTop: '16px' }}>{dealSearch ? '没有匹配的行情' : '暂无行情记录'}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
|
||||||
{filteredDeals.map(deal => (
|
|
||||||
<DealListItem key={deal.id} deal={deal} onRefresh={fetchMyDeals} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 藏品tab内容 */}
|
|
||||||
{activeTab === 'collections' && (
|
|
||||||
<>
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>加载中...</div>
|
<div style={{ textAlign: 'center', padding: '60px', color: '#64748b' }}>加载中...</div>
|
||||||
) : filteredCollections.length === 0 ? (
|
) : filteredCollections.length === 0 ? (
|
||||||
|
|
@ -705,11 +497,11 @@ export default function List() {
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '12px' }}>
|
||||||
{filteredCollections.map(item => (
|
{filteredCollections.map(item => (
|
||||||
<div key={item.id} onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '12px', cursor: 'pointer' }}>
|
<div key={item.id} onClick={() => goDetail(item.id)} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)', borderRadius: '12px', padding: '12px', cursor: 'pointer' }}>
|
||||||
<div style={{ height: '80px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'flex-start', fontSize: '32px', marginBottom: '10px' }}>🐉</div>
|
<div style={{ height: '80px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '32px', marginBottom: '10px' }}>🐉</div>
|
||||||
<div style={{ color: '#fff', fontSize: '11px', fontWeight: 'normal' }}>{item.code || '-'}</div>
|
<div style={{ color: '#fff', fontSize: '14px', fontWeight: '500' }}>{item.code || '-'}</div>
|
||||||
<div style={{ color: '#fbbf24', fontSize: '11px', fontFamily: 'monospace', marginTop: '2px' }}>{formatPrefixSerial(item.prefixSerial)}</div>
|
<div style={{ color: '#fbbf24', fontSize: '12px', fontFamily: 'monospace', marginTop: '2px' }}>{item.prefixSerial || '-'}</div>
|
||||||
<div style={{ display: 'flex', gap: '2px', marginTop: '6px', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '4px', marginTop: '6px', flexWrap: 'wrap' }}>
|
||||||
{item.gradingScore && <span style={{ color: '#fbbf24', fontSize: '11px', }}>{item.gradingScore}</span>}
|
{item.gradingScore && <span style={{ color: '#fbbf24', fontSize: '12px', fontWeight: 'bold' }}>{item.gradingScore}</span>}
|
||||||
{item.threeStar && <span style={{ color: '#fbbf24', fontSize: '10px' }}>⭐⭐⭐</span>}
|
{item.threeStar && <span style={{ color: '#fbbf24', fontSize: '10px' }}>⭐⭐⭐</span>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -718,237 +510,7 @@ export default function List() {
|
||||||
) : (
|
) : (
|
||||||
filteredCollections.map(item => <ListItem key={item.id} item={item} />)
|
filteredCollections.map(item => <ListItem key={item.id} item={item} />)
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 行情列表项组件
|
|
||||||
function DealListItem({ deal, onRefresh }) {
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
const [editing, setEditing] = useState(false)
|
|
||||||
const [editForm, setEditForm] = useState({})
|
|
||||||
const API_BASE = localStorage.getItem('API_BASE') || ''
|
|
||||||
|
|
||||||
const openEdit = () => {
|
|
||||||
// 从content中解析平台、出售者、购买者
|
|
||||||
let platform = '', seller = '', buyer = ''
|
|
||||||
if (deal.content) {
|
|
||||||
const platformMatch = deal.content.match(/平台:\s*([^\n]+)/)
|
|
||||||
const sellerMatch = deal.content.match(/出售者:\s*([^\n]+)/)
|
|
||||||
const buyerMatch = deal.content.match(/购买者:\s*([^\n]+)/)
|
|
||||||
if (platformMatch) platform = platformMatch[1].trim()
|
|
||||||
if (sellerMatch) seller = sellerMatch[1].trim()
|
|
||||||
if (buyerMatch) buyer = buyerMatch[1].trim()
|
|
||||||
}
|
|
||||||
setEditForm({
|
|
||||||
title: deal.title,
|
|
||||||
content: deal.content,
|
|
||||||
deal_price: deal.deal_price,
|
|
||||||
deal_date: deal.deal_date ? (typeof deal.deal_date === 'string' ? deal.deal_date.split('T')[0] : '') : '',
|
|
||||||
packaging: deal.packaging || '单张',
|
|
||||||
is_graded: deal.is_graded || false,
|
|
||||||
grading_company: deal.grading_company || '',
|
|
||||||
grading_score: deal.grading_score || '',
|
|
||||||
category: deal.category || '',
|
|
||||||
deal_no: deal.deal_no || '',
|
|
||||||
platform: platform,
|
|
||||||
seller: seller,
|
|
||||||
buyer: buyer
|
|
||||||
})
|
|
||||||
setEditing(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveEdit = async () => {
|
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
try {
|
|
||||||
await fetch(`${API_BASE}/api/deal/${deal.id}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(editForm)
|
|
||||||
})
|
|
||||||
setEditing(false)
|
|
||||||
onRefresh()
|
|
||||||
} catch(e) { alert('保存失败') }
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteDeal = async () => {
|
|
||||||
if (!confirm('确定删除这条行情?')) return
|
|
||||||
const token = localStorage.getItem('token')
|
|
||||||
try {
|
|
||||||
await fetch(`${API_BASE}/api/deal/${deal.id}`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
})
|
|
||||||
onRefresh()
|
|
||||||
} catch(e) { alert('删除失败') }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ background: 'rgba(255,255,255,0.05)', borderRadius: '10px', padding: '12px', border: '1px solid rgba(255,255,255,0.1)' }}>
|
|
||||||
{/* 简要展示 - 两行显示关键信息 */}
|
|
||||||
<div onClick={() => setExpanded(!expanded)} style={{ cursor: 'pointer', padding: '10px', background: 'rgba(0,0,0,0.2)', borderRadius: '8px' }}>
|
|
||||||
{/* 第一行:成交日期(月-日)+ 冠字号 + 包装 + 评级分数 + 价格 */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '6px', flexWrap: 'wrap', gap: '4px' }}>
|
|
||||||
{deal.deal_date && <span style={{ color: '#64748b', fontSize: '11px' }}>{deal.deal_date.slice(5)}</span>}
|
|
||||||
<span style={{ color: '#fbbf24', fontSize: '13px', fontFamily: 'monospace' }}>{deal.title?.split('-')[0] || '-'}</span>
|
|
||||||
{deal.packaging && <span style={{ background: 'rgba(139,92,246,0.15)', color: '#a78bfa', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{deal.packaging}</span>}
|
|
||||||
{deal.grading_company && <span style={{ background: 'rgba(6,182,212,0.15)', color: '#06b6d4', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{deal.grading_score}</span>}
|
|
||||||
<span style={{ color: '#22c55e', fontSize: '14px', fontWeight: 'bold' }}>¥{deal.deal_price?.toLocaleString()}</span>
|
|
||||||
</div>
|
|
||||||
{/* 第二行:编号 + 分类 + 大小号 + 展开箭头 */}
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
|
||||||
{deal.deal_no && <span style={{ color: '#fff', fontSize: '10px' }}>{deal.deal_no}</span>}
|
|
||||||
{deal.category && <span style={{ background: 'rgba(59,130,246,0.15)', color: '#60a5fa', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{deal.category}</span>}
|
|
||||||
{deal.content && (() => {
|
|
||||||
const sizeMatch = deal.content.match(/大小号:\s*([^\n]+)/)
|
|
||||||
return sizeMatch && <span style={{ background: 'rgba(34,197,94,0.15)', color: '#22c55e', fontSize: '10px', padding: '2px 6px', borderRadius: '4px' }}>{sizeMatch[1]}</span>
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
<span style={{ color: '#64748b', fontSize: '12px' }}>{expanded ? '▲ 收起' : '▼展开'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 展开详情 */}
|
|
||||||
{expanded && (
|
|
||||||
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
|
|
||||||
{editing ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
|
||||||
{/* 冠字号 */}
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>冠字号</div>
|
|
||||||
<input value={editForm.title?.split('-')[0] || ''} onChange={e => setEditForm({...editForm, title: `${e.target.value}-¥${editForm.deal_price}`})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fbbf24', fontSize: '14px', fontFamily: 'monospace' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 价格和日期 */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>价格</div>
|
|
||||||
<input type="number" value={editForm.deal_price} onChange={e => {
|
|
||||||
const val = parseFloat(e.target.value) || 0
|
|
||||||
const serial = editForm.title?.split('-')[0] || ''
|
|
||||||
setEditForm({...editForm, deal_price: val, title: `${serial}-¥${val}`})
|
|
||||||
}} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#22c55e', fontSize: '14px' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>日期</div>
|
|
||||||
<input type="date" value={editForm.deal_date} onChange={e => setEditForm({...editForm, deal_date: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '14px' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 包装和分类 */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>包装</div>
|
|
||||||
<select value={editForm.packaging} onChange={e => setEditForm({...editForm, packaging: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
|
|
||||||
<option value="单张">单张</option>
|
|
||||||
<option value="标十">标十</option>
|
|
||||||
<option value="标百">标百</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>分类</div>
|
|
||||||
<input value={editForm.category || ''} onChange={e => setEditForm({...editForm, category: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 评级 */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>评级机构</div>
|
|
||||||
<select value={editForm.grading_company || ''} onChange={e => setEditForm({...editForm, grading_company: e.target.value, is_graded: !!e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
|
|
||||||
<option value="">未评级</option>
|
|
||||||
<option value="PCGS">PCGS</option>
|
|
||||||
<option value="PMG">PMG</option>
|
|
||||||
<option value="ACG">ACG</option>
|
|
||||||
<option value="爱藏">爱藏</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>评级分数</div>
|
|
||||||
<input value={editForm.grading_score || ''} onChange={e => setEditForm({...editForm, grading_score: e.target.value})} placeholder="如: PC69, 67+" style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#06b6d4', fontSize: '13px' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 平台和出售者购买者 */}
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>平台</div>
|
|
||||||
<select value={editForm.platform || ''} onChange={e => setEditForm({...editForm, platform: e.target.value})} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }}>
|
|
||||||
<option value="">请选择</option>
|
|
||||||
<option value="淘宝">淘宝</option>
|
|
||||||
<option value="咸鱼">咸鱼</option>
|
|
||||||
<option value="抖音">抖音</option>
|
|
||||||
<option value="快手">快手</option>
|
|
||||||
<option value="微拍堂">微拍堂</option>
|
|
||||||
<option value="拼多多">拼多多</option>
|
|
||||||
<option value="一尘">一尘</option>
|
|
||||||
<option value="爱藏">爱藏</option>
|
|
||||||
<option value="其他">其他</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>出售者</div>
|
|
||||||
<input value={editForm.seller || ''} onChange={e => setEditForm({...editForm, seller: e.target.value})} placeholder="请输入出售者" style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>购买者</div>
|
|
||||||
<input value={editForm.buyer || ''} onChange={e => setEditForm({...editForm, buyer: e.target.value})} placeholder="请输入购买者" style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px' }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 备注 */}
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '11px', color: '#64748b', marginBottom: '4px' }}>备注</div>
|
|
||||||
<textarea value={editForm.content || ''} onChange={e => setEditForm({...editForm, content: e.target.value})} rows={2} style={{ width: '100%', padding: '10px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', color: '#fff', fontSize: '13px', boxSizing: 'border-box' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 保存取消按钮 */}
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
<button onClick={saveEdit} style={{ flex: 1, padding: '12px', background: '#22c55e', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>保存</button>
|
|
||||||
<button onClick={() => setEditing(false)} style={{ flex: 1, padding: '12px', background: '#64748b', color: '#fff', border: 'none', borderRadius: '8px', fontSize: '14px', fontWeight: '600', cursor: 'pointer' }}>取消</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: '12px', color: '#94a3b8', marginBottom: '8px' }}>
|
|
||||||
{deal.deal_no && <div>编号: <span style={{ color: '#fbbf24' }}>{deal.deal_no}</span></div>}
|
|
||||||
<div>冠字号: <span style={{ color: '#fbbf24' }}>{deal.title?.split('-')[0] || '-'}</span></div>
|
|
||||||
<div>价格: <span style={{ color: '#22c55e' }}>¥{deal.deal_price?.toLocaleString()}</span></div>
|
|
||||||
<div>日期: {deal.deal_date}</div>
|
|
||||||
<div>包装: {deal.packaging || '单张'}</div>
|
|
||||||
<div>分类: {deal.category || '-'}</div>
|
|
||||||
<div>评级: {deal.grading_company ? `${deal.grading_company} ${deal.grading_score || ''}` : '未评级'}</div>
|
|
||||||
{deal.content && (() => {
|
|
||||||
const platformMatch = deal.content.match(/平台:\s*([^\n]+)/)
|
|
||||||
const sellerMatch = deal.content.match(/出售者:\s*([^\n]+)/)
|
|
||||||
const buyerMatch = deal.content.match(/购买者:\s*([^\n]+)/)
|
|
||||||
const sizeMatch = deal.content.match(/大小号:\s*([^\n]+)/)
|
|
||||||
const tailMatch = deal.content.match(/尾号:\s*([^\n]+)/)
|
|
||||||
return (
|
|
||||||
<div style={{ marginTop: '4px' }}>
|
|
||||||
{platformMatch && <div>平台: {platformMatch[1]}</div>}
|
|
||||||
{sellerMatch && <div>出售者: {sellerMatch[1]}</div>}
|
|
||||||
{buyerMatch && buyerMatch[1].trim() !== '-' && <div>购买者: {buyerMatch[1]}</div>}
|
|
||||||
{tailMatch && <div>尾号: {tailMatch[1]}</div>}
|
|
||||||
{sizeMatch && <div>大小号: <span style={{ color: '#60a5fa' }}>{sizeMatch[1]}</span></div>}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})()}
|
|
||||||
{deal.content && <div style={{ marginTop: '8px', color: '#64748b', fontSize: '11px' }}>{deal.content}</div>}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
|
||||||
<button onClick={openEdit} style={{ flex: 1, padding: '8px', background: 'rgba(59,130,246,0.2)', color: '#60a5fa', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' }}>编辑</button>
|
|
||||||
<button onClick={deleteDeal} style={{ flex: 1, padding: '8px', background: 'rgba(239,68,68,0.2)', color: '#ef4444', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' }}>删除</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* Login - 登录页面
|
|
||||||
* Version: 1.2.85x
|
|
||||||
* 更新:
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { APP_VERSION } from '../config/version'
|
import { APP_VERSION } from '../config/version'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
|
@ -18,84 +12,21 @@ export default function Login() {
|
||||||
|
|
||||||
// 注册相关状态
|
// 注册相关状态
|
||||||
const [showRegister, setShowRegister] = useState(false)
|
const [showRegister, setShowRegister] = useState(false)
|
||||||
const [registerData, setRegisterData] = useState({ agreeTerms: false,
|
const [registerData, setRegisterData] = useState({ username: '', password: '', confirmPassword: '', email: '' })
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
email: '',
|
|
||||||
phone: '',
|
|
||||||
verifyCode: '',
|
|
||||||
inviteCode: ''
|
|
||||||
})
|
|
||||||
const [registerLoading, setRegisterLoading] = useState(false)
|
const [registerLoading, setRegisterLoading] = useState(false)
|
||||||
const [registerError, setRegisterError] = useState('')
|
const [registerError, setRegisterError] = useState('')
|
||||||
|
|
||||||
// 短信获取相关
|
// 生成验证码
|
||||||
const [sendingCode, setSendingCode] = useState(false)
|
|
||||||
const [codeCountdown, setCodeCountdown] = useState(0)
|
|
||||||
const [codeSent, setCodeSent] = useState(false)
|
|
||||||
|
|
||||||
// 生成获取
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
generateCaptcha()
|
generateCaptcha()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 获取倒计时
|
|
||||||
useEffect(() => {
|
|
||||||
if (codeCountdown > 0) {
|
|
||||||
const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [codeCountdown])
|
|
||||||
|
|
||||||
const generateCaptcha = () => {
|
const generateCaptcha = () => {
|
||||||
const num1 = Math.floor(Math.random() * 10)
|
const num1 = Math.floor(Math.random() * 10)
|
||||||
const num2 = Math.floor(Math.random() * 10)
|
const num2 = Math.floor(Math.random() * 10)
|
||||||
setCaptcha({ num1, num2, answer: '' })
|
setCaptcha({ num1, num2, answer: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送短信获取
|
|
||||||
const handleSendCode = async () => {
|
|
||||||
if (!registerData.phone) {
|
|
||||||
setRegisterError('请先输入手机号')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证手机号格式
|
|
||||||
const phoneRegex = /^1[3-9]\d{9}$/
|
|
||||||
if (!phoneRegex.test(registerData.phone)) {
|
|
||||||
setRegisterError('请输入正确的手机号')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSendingCode(true)
|
|
||||||
setRegisterError('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/auth/send-verification-code', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ phone: registerData.phone })
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
setCodeSent(true)
|
|
||||||
setCodeCountdown(60)
|
|
||||||
setRegisterError('')
|
|
||||||
} else {
|
|
||||||
setRegisterError(data.message || '发送失败')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setRegisterError('发送失败,请稍后重试')
|
|
||||||
} finally {
|
|
||||||
setSendingCode(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理注册
|
// 处理注册
|
||||||
const handleRegister = async () => {
|
const handleRegister = async () => {
|
||||||
// 验证输入
|
// 验证输入
|
||||||
|
|
@ -104,18 +35,13 @@ export default function Login() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!registerData.agreeTerms) {
|
if (registerData.username.length < 3) {
|
||||||
setRegisterError('请先同意用户协议')
|
setRegisterError('用户名至少 3 个字符')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (registerData.username.length < 2) {
|
if (registerData.password.length < 6) {
|
||||||
setRegisterError('用户名至少 2 个字符')
|
setRegisterError('密码至少 6 个字符')
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (registerData.password.length < 8 || !/[A-Z]/.test(registerData.password) || !/[a-z]/.test(registerData.password) || !/[0-9]/.test(registerData.password)) {
|
|
||||||
setRegisterError('密码至少8位,需包含大写、小写、数字')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,40 +50,17 @@ export default function Login() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证手机号
|
|
||||||
if (!registerData.phone) {
|
|
||||||
setRegisterError('手机号为必填项')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const phoneRegex = /^1[3-9]\d{9}$/
|
|
||||||
if (!phoneRegex.test(registerData.phone)) {
|
|
||||||
setRegisterError('请输入正确的手机号')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证短信获取
|
|
||||||
if (!registerData.verifyCode) {
|
|
||||||
setRegisterError('请输入短信获取')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setRegisterLoading(true)
|
setRegisterLoading(true)
|
||||||
setRegisterError('')
|
setRegisterError('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
username: registerData.username,
|
username: registerData.username,
|
||||||
password: registerData.password,
|
password: registerData.password
|
||||||
phone: registerData.phone,
|
|
||||||
verifyCode: registerData.verifyCode
|
|
||||||
}
|
}
|
||||||
if (registerData.email) {
|
if (registerData.email) {
|
||||||
payload.email = registerData.email
|
payload.email = registerData.email
|
||||||
}
|
}
|
||||||
if (registerData.inviteCode) {
|
|
||||||
payload.inviteCode = registerData.inviteCode
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch('/api/auth/register', {
|
const res = await fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -170,14 +73,12 @@ export default function Login() {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data.detail || data.error?.message || '注册失败')
|
throw new Error(data.error?.message || '注册失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
alert('注册成功!请登录')
|
alert('注册成功!请登录')
|
||||||
setShowRegister(false)
|
setShowRegister(false)
|
||||||
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '', inviteCode: '' })
|
setRegisterData({ username: '', password: '', confirmPassword: '', email: '' })
|
||||||
setCodeSent(false)
|
|
||||||
setCodeCountdown(0)
|
|
||||||
generateCaptcha()
|
generateCaptcha()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('注册错误:', err)
|
console.error('注册错误:', err)
|
||||||
|
|
@ -194,13 +95,17 @@ export default function Login() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (username.length < 2) {
|
if (username.length < 3) {
|
||||||
setError(ErrorCodes.E00021.message)
|
setError(ErrorCodes.E00021.message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError(ErrorCodes.E00022.message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// 验证获取
|
// 验证验证码
|
||||||
const expectedAnswer = captcha.num1 + captcha.num2
|
const expectedAnswer = captcha.num1 + captcha.num2
|
||||||
if (!captcha.answer || Number(captcha.answer) !== expectedAnswer) {
|
if (!captcha.answer || Number(captcha.answer) !== expectedAnswer) {
|
||||||
setError(ErrorCodes.E00012.message)
|
setError(ErrorCodes.E00012.message)
|
||||||
|
|
@ -265,6 +170,7 @@ export default function Login() {
|
||||||
background: '#fff'
|
background: '#fff'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<h1 style={{ color: '#fbbf24', fontSize: '28px', fontWeight: '700', margin: '16px 0 8px' }}>甲辰收藏</h1>
|
||||||
<p style={{ color: 'rgba(255,255,255,0.6)', fontSize: '14px', margin: 0 }}>生肖纪念钞管理系统</p>
|
<p style={{ color: 'rgba(255,255,255,0.6)', fontSize: '14px', margin: 0 }}>生肖纪念钞管理系统</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -335,11 +241,11 @@ export default function Login() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 获取 */}
|
{/* 验证码 */}
|
||||||
<div style={{ marginBottom: '24px' }}>
|
<div style={{ marginBottom: '24px' }}>
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
<div onClick={generateCaptcha} style={{
|
<div onClick={generateCaptcha} style={{
|
||||||
flex: 1, minWidth: "90px", width: "auto",
|
flex: 1,
|
||||||
padding: '14px',
|
padding: '14px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: '1px solid rgba(255,255,255,0.2)',
|
border: '1px solid rgba(255,255,255,0.2)',
|
||||||
|
|
@ -449,9 +355,7 @@ export default function Login() {
|
||||||
background: 'rgba(30, 41, 59, 0.95)',
|
background: 'rgba(30, 41, 59, 0.95)',
|
||||||
borderRadius: '16px',
|
borderRadius: '16px',
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
border: '1px solid rgba(255,255,255,0.1)',
|
border: '1px solid rgba(255,255,255,0.1)'
|
||||||
maxHeight: '90vh',
|
|
||||||
overflowY: 'auto'
|
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '600', marginBottom: '20px', textAlign: 'center' }}>
|
<h2 style={{ color: '#fbbf24', fontSize: '20px', fontWeight: '600', marginBottom: '20px', textAlign: 'center' }}>
|
||||||
注册新账号 🎉
|
注册新账号 🎉
|
||||||
|
|
@ -477,7 +381,7 @@ export default function Login() {
|
||||||
type="text"
|
type="text"
|
||||||
value={registerData.username}
|
value={registerData.username}
|
||||||
onChange={(e) => setRegisterData(prev => ({ ...prev, username: e.target.value }))}
|
onChange={(e) => setRegisterData(prev => ({ ...prev, username: e.target.value }))}
|
||||||
placeholder="用户名(至少 2 个字符)"
|
placeholder="用户名(至少 3 个字符)"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '14px',
|
padding: '14px',
|
||||||
|
|
@ -491,68 +395,6 @@ export default function Login() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 手机号 */}
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
value={registerData.phone}
|
|
||||||
onChange={(e) => setRegisterData(prev => ({ ...prev, phone: e.target.value }))}
|
|
||||||
placeholder="手机号(11位)"
|
|
||||||
maxLength={11}
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '14px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid rgba(255,255,255,0.2)',
|
|
||||||
background: 'rgba(255,255,255,0.05)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '16px',
|
|
||||||
outline: 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 短信获取 */}
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={registerData.verifyCode}
|
|
||||||
onChange={(e) => setRegisterData(prev => ({ ...prev, verifyCode: e.target.value }))}
|
|
||||||
placeholder="短信验证码"
|
|
||||||
maxLength={6}
|
|
||||||
style={{
|
|
||||||
flex: 1, minWidth: "90px", width: "auto",
|
|
||||||
padding: '14px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid rgba(255,255,255,0.2)',
|
|
||||||
background: 'rgba(255,255,255,0.05)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '16px',
|
|
||||||
outline: 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSendCode}
|
|
||||||
disabled={sendingCode || codeCountdown > 0}
|
|
||||||
style={{
|
|
||||||
minWidth: "90px", width: "auto",
|
|
||||||
padding: '14px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: 'none',
|
|
||||||
background: codeCountdown > 0 ? 'rgba(148, 163, 184, 0.3)' : 'linear-gradient(135deg, #22c55e 0%, #16a34a 100%)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '600',
|
|
||||||
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{codeCountdown > 0 ? `${codeCountdown}秒` : sendingCode ? '发送中...' : '获取验证码'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 邮箱(可选) */}
|
{/* 邮箱(可选) */}
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<input
|
<input
|
||||||
|
|
@ -579,7 +421,7 @@ export default function Login() {
|
||||||
type="password"
|
type="password"
|
||||||
value={registerData.password}
|
value={registerData.password}
|
||||||
onChange={(e) => setRegisterData(prev => ({ ...prev, password: e.target.value }))}
|
onChange={(e) => setRegisterData(prev => ({ ...prev, password: e.target.value }))}
|
||||||
placeholder="密码(至少8位,需大写+小写+数字)"
|
placeholder="密码(至少 6 个字符)"
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '14px',
|
padding: '14px',
|
||||||
|
|
@ -594,7 +436,7 @@ export default function Login() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 确认密码 */}
|
{/* 确认密码 */}
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '24px' }}>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={registerData.confirmPassword}
|
value={registerData.confirmPassword}
|
||||||
|
|
@ -613,56 +455,16 @@ export default function Login() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 邀请码(选填) */}
|
|
||||||
<div style={{ marginBottom: '24px' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={registerData.inviteCode}
|
|
||||||
onChange={(e) => setRegisterData(prev => ({ ...prev, inviteCode: e.target.value }))}
|
|
||||||
placeholder="邀请码(选填)"
|
|
||||||
style={{
|
|
||||||
width: '100%',
|
|
||||||
padding: '14px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: '1px solid rgba(255,255,255,0.2)',
|
|
||||||
background: 'rgba(255,255,255,0.05)',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '16px',
|
|
||||||
outline: 'none'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* 用户协议勾选 */}
|
|
||||||
<div style={{ marginBottom: '20px', display: 'flex', alignItems: 'flex-start', gap: '8px' }}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={registerData.agreeTerms}
|
|
||||||
onChange={(e) => setRegisterData(prev => ({ ...prev, agreeTerms: e.target.checked }))}
|
|
||||||
style={{ width: '18px', height: '18px', marginTop: '2px', cursor: 'pointer' }}
|
|
||||||
/>
|
|
||||||
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.7)', lineHeight: '1.5' }}>
|
|
||||||
我已阅读并同意
|
|
||||||
<span
|
|
||||||
onClick={() => window.open('/user_agreement.html', '_blank')}
|
|
||||||
style={{ color: '#fbbf24', cursor: 'pointer', textDecoration: 'underline' }}
|
|
||||||
>《用户协议》</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 按钮 */}
|
{/* 按钮 */}
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
<div style={{ display: 'flex', gap: '12px' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowRegister(false)
|
setShowRegister(false)
|
||||||
setRegisterData({ username: '', password: '', confirmPassword: '', email: '', phone: '', verifyCode: '', inviteCode: '' })
|
setRegisterData({ username: '', password: '', confirmPassword: '', email: '' })
|
||||||
setRegisterError('')
|
setRegisterError('')
|
||||||
setCodeSent(false)
|
|
||||||
setCodeCountdown(0)
|
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, minWidth: "90px", width: "auto",
|
flex: 1,
|
||||||
padding: '14px',
|
padding: '14px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
@ -679,7 +481,7 @@ export default function Login() {
|
||||||
onClick={handleRegister}
|
onClick={handleRegister}
|
||||||
disabled={registerLoading}
|
disabled={registerLoading}
|
||||||
style={{
|
style={{
|
||||||
flex: 1, minWidth: "90px", width: "auto",
|
flex: 1,
|
||||||
padding: '14px',
|
padding: '14px',
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
|
||||||
|
|
@ -1,224 +0,0 @@
|
||||||
/**
|
|
||||||
* News_YichensBoard - 一尘帖子页面
|
|
||||||
* Version: 1.2.75x
|
|
||||||
* 更新:
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
|
|
||||||
export default function YichensBoard() {
|
|
||||||
const [todayStats, setTodayStats] = useState({ total: 0, deals: 0, wants: 0, others: 0 })
|
|
||||||
const [todayCategory, setTodayCategory] = useState([])
|
|
||||||
const [posts, setPosts] = useState([])
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [expandedPosts, setExpandedPosts] = useState({})
|
|
||||||
const [postTypeFilter, setPostTypeFilter] = useState('all')
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState('')
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [totalPosts, setTotalPosts] = useState(0)
|
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
|
||||||
const API_BASE = localStorage.getItem('API_BASE') || ''
|
|
||||||
|
|
||||||
const today = new Date()
|
|
||||||
const dateStr = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate()
|
|
||||||
|
|
||||||
useEffect(() => { fetchTodayStats(); fetchPosts(1, '') }, [])
|
|
||||||
|
|
||||||
const fetchTodayStats = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(API_BASE + '/api/yichens/stats/today')
|
|
||||||
setTodayStats(await res.json())
|
|
||||||
} catch(e) { console.error(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchTodayCategory = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(API_BASE + '/api/yichens/stats/today-category')
|
|
||||||
setTodayCategory(await res.json())
|
|
||||||
} catch(e) { console.error(e) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPosts = async (p, cat) => {
|
|
||||||
setLoading(true)
|
|
||||||
const currentPage = p !== undefined ? p : page
|
|
||||||
const currentCat = cat !== undefined ? cat : categoryFilter
|
|
||||||
|
|
||||||
let url = API_BASE + '/api/yichens/posts?limit=390&offset=' + ((currentPage - 1) * 390)
|
|
||||||
if (postTypeFilter === 'deal') url += '&post_type=deal'
|
|
||||||
else if (postTypeFilter === 'want') url += '&post_type=want'
|
|
||||||
else if (postTypeFilter === 'other') url += '&post_type=normal'
|
|
||||||
try {
|
|
||||||
const res = await fetch(url)
|
|
||||||
let data = await res.json() || []
|
|
||||||
if (currentCat) {
|
|
||||||
if (currentCat === '龙') {
|
|
||||||
data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞')))
|
|
||||||
} else if (currentCat === '蛇') {
|
|
||||||
data = data.filter(p => p.category && p.category.includes('蛇'))
|
|
||||||
} else if (currentCat === '马') {
|
|
||||||
data = data.filter(p => p.category && p.category.includes('马'))
|
|
||||||
} else if (currentCat === '其他') {
|
|
||||||
data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 搜索关键词过滤 - 强匹配(完全包含)
|
|
||||||
if (searchKeyword) {
|
|
||||||
const kw = searchKeyword.trim()
|
|
||||||
if (kw) {
|
|
||||||
data = data.filter(p =>
|
|
||||||
(p.title && p.title.includes(kw)) ||
|
|
||||||
(p.content && p.content.includes(kw)) ||
|
|
||||||
(p.category && p.category.includes(kw)) ||
|
|
||||||
(p.contact && p.contact.includes(kw))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setPosts(data)
|
|
||||||
setTotalPosts(todayStats.total || 0)
|
|
||||||
} catch { setPosts([]) }
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => { if (todayStats.total > 0) fetchTodayCategory() }, [todayStats])
|
|
||||||
|
|
||||||
// 搜索关键词变化时重新获取数据
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1)
|
|
||||||
fetchPosts(1, '')
|
|
||||||
}, [searchKeyword])
|
|
||||||
|
|
||||||
const togglePost = id => setExpandedPosts(p => ({ ...p, [id]: !p[id] }))
|
|
||||||
|
|
||||||
const StatCard = ({ label, value, color, onClick }) => (
|
|
||||||
<div onClick={onClick} style={{
|
|
||||||
background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)',
|
|
||||||
borderRadius: 12, padding: '12px 8px', border: '1px solid #374151',
|
|
||||||
cursor: onClick ? 'pointer' : 'default',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{ color: '#9ca3af', fontSize: 11, marginBottom: 4 }}>{label}</div>
|
|
||||||
<div style={{ color: color || '#fff', fontSize: 20, fontWeight: 'bold' }}>{value}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '0' }}>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 10 }}>
|
|
||||||
📈 连体钞/纪念钞 <span style={{ color: '#9ca3af', fontWeight: 400 }}>{dateStr}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
|
||||||
<StatCard label='全部' value={todayStats.total} color='#3b82f6' onClick={() => { setPostTypeFilter('all'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
|
|
||||||
<StatCard label='出售' value={todayStats.deals} color='#10b981' onClick={() => { setPostTypeFilter('deal'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
|
|
||||||
<StatCard label='求购' value={todayStats.wants} color='#f59e0b' onClick={() => { setPostTypeFilter('want'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
|
|
||||||
<StatCard label='其他' value={todayStats.others || 0} color='#8b5cf6' onClick={() => { setPostTypeFilter('other'); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 12, marginBottom: 20, border: '1px solid #374151' }}>
|
|
||||||
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 8 }}>📊 今日分类统计</div>
|
|
||||||
<div style={{ display: 'flex', gap: 6, overflowX: 'auto', paddingBottom: 4 }}>
|
|
||||||
{todayCategory.map(cat => (
|
|
||||||
<span key={cat.category} style={{ padding: '4px 10px', background: 'rgba(59,130,246,0.2)', borderRadius: 16, color: '#93c5fd', fontSize: 11, whiteSpace: 'nowrap' }}>
|
|
||||||
{cat.category} ({cat.count})
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 搜索框 */}
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索标题、内容、分类..."
|
|
||||||
value={searchKeyword}
|
|
||||||
onChange={(e) => { setSearchKeyword(e.target.value); setPage(1); fetchPosts(1, '') }}
|
|
||||||
style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: '1px solid #374151', background: '#1f2937', color: '#fff', fontSize: 13, outline: 'none' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => { setSearchKeyword(''); setPage(1); fetchPosts(1, '') }}
|
|
||||||
style={{ padding: '10px 16px', borderRadius: 8, border: 'none', background: '#4b5563', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
|
|
||||||
清空
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{searchKeyword && <div style={{ color: '#9ca3af', fontSize: 12, marginTop: 6 }}>搜索: "{searchKeyword}",找到 {posts.length} 条结果</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
|
||||||
{[{key:'all',label:'全部'},{key:'deal',label:'出售'},{key:'want',label:'求购'},{key:'other',label:'其他'}].map(k => (
|
|
||||||
<button key={k.key} onClick={() => { setPostTypeFilter(k.key==='other'?'other':k.key); setCategoryFilter(''); setPage(1); fetchPosts(1, '') }}
|
|
||||||
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
|
|
||||||
background: postTypeFilter===k.key ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
|
|
||||||
{k.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|
||||||
{[{key:'',label:'全部'},{key:'龙',label:'龙钞'},{key:'蛇',label:'蛇钞'},{key:'马',label:'马钞'},{key:'其他',label:'其他'}].map(k => (
|
|
||||||
<button key={k.key} onClick={() => { setCategoryFilter(k.key); setPage(1); fetchPosts(1, k.key) }}
|
|
||||||
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
|
|
||||||
background: categoryFilter===k.key ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
|
|
||||||
{k.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? <div style={{textAlign:'center',color:'#9ca3af',padding:40}}>加载中...</div> : (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{posts.map(post => (
|
|
||||||
<div key={post.post_id} style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, border: '1px solid #374151' }}>
|
|
||||||
<div onClick={() => togglePost(post.post_id)} style={{ cursor: 'pointer' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
||||||
<span style={{ color: '#fff', fontSize: 15, fontWeight: 500, flex: 1 }}>{post.title||'无标题'}</span>
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<span style={{ color: post.post_type==='deal'?'#10b981':post.post_type==='want'?'#f59e0b':post.post_type==='normal'?'#8b5cf6':'#9ca3af', fontSize: 12 }}>
|
|
||||||
{post.post_type==='deal'?'出售':post.post_type==='want'?'求购':post.post_type==='normal'?'其他':'普通'}
|
|
||||||
</span>
|
|
||||||
<span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 11, background: post.category?.includes('龙') ? 'rgba(251,191,36,0.4)' : post.category?.includes('马') ? 'rgba(180,83,9,0.4)' : post.category?.includes('蛇') ? 'rgba(249,168,212,0.4)' : 'rgba(139,92,246,0.4)', color: post.category?.includes('龙') ? '#fde047' : post.category?.includes('马') ? '#d97706' : post.category?.includes('蛇') ? '#fbcfe8' : '#c4b5fd' }}>
|
|
||||||
{post.category || '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#9ca3af', fontSize: 12 }}>
|
|
||||||
<span>{post.author_username||'未知'}</span>
|
|
||||||
<span>{post.post_time?.substring(0,16)||''}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{expandedPosts[post.post_id] && post.content && (
|
|
||||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #374151' }}>
|
|
||||||
<div style={{ color: '#e2e8f0', fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 300, overflowY: 'auto' }}>
|
|
||||||
{post.content}
|
|
||||||
</div>
|
|
||||||
{post.url && <a href={post.url} target='_blank' rel='noopener noreferrer' style={{ display:'inline-block', marginTop:12, padding:'8px 16px', background:'rgba(59,130,246,0.2)', borderRadius:8, color:'#60a5fa', textDecoration:'none', fontSize:13 }}>查看原帖</a>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 分页按钮移到页面底部 */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#1e293b', borderRadius: 12, marginTop: 16 }}>
|
|
||||||
<div style={{ color: '#9ca3af', fontSize: 12 }}>
|
|
||||||
共 {totalPosts} 条帖子,{Math.ceil(totalPosts / 390)} 页,当前第 {page} 页
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
{page > 1 ? (
|
|
||||||
<button onClick={() => { const newPage = page - 1; setPage(newPage); fetchPosts(newPage, categoryFilter) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>上一页</button>
|
|
||||||
) : (
|
|
||||||
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>上一页</span>
|
|
||||||
)}
|
|
||||||
{posts.length >= 390 ? (
|
|
||||||
<button onClick={() => { const newPage = page + 1; setPage(newPage); fetchPosts(newPage, categoryFilter) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>下一页</button>
|
|
||||||
) : (
|
|
||||||
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>下一页</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,654 @@
|
||||||
|
// AI OCR 识别页面 - 支持拍照识别和手工录入
|
||||||
|
import React, { useState, useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
// 字段转换函数:camelCase → 字段编码
|
||||||
|
const convertField = (obj) => {
|
||||||
|
const map = {
|
||||||
|
// f99 系统字段
|
||||||
|
id: 'f99_90_id',
|
||||||
|
userId: 'f99_91_user_id',
|
||||||
|
// f01 基本信息
|
||||||
|
name: 'f01_01_name',
|
||||||
|
code: 'f01_02_code',
|
||||||
|
category: 'f01_03_category',
|
||||||
|
status: 'f01_04_status',
|
||||||
|
remark: 'f01_05_remark',
|
||||||
|
// f02 详细字段
|
||||||
|
prefixSerial: 'f02_10_prefix_serial',
|
||||||
|
version: 'f02_11_version',
|
||||||
|
packaging: 'f02_12_packaging',
|
||||||
|
rarity: 'f02_13_rarity',
|
||||||
|
// f03 评级信息
|
||||||
|
isGraded: 'f03_20_is_graded',
|
||||||
|
gradingCompany: 'f03_21_grading_company',
|
||||||
|
gradingScore: 'f03_22_grading_score',
|
||||||
|
threeStar: 'f03_23_three_star',
|
||||||
|
// f04 特殊信息
|
||||||
|
specialMark: 'f04_30_special_mark',
|
||||||
|
serialFeature: 'f04_31_serial_feature',
|
||||||
|
issuer: 'f04_32_issuer',
|
||||||
|
issueYear: 'f04_33_issue_year',
|
||||||
|
material: 'f04_34_material',
|
||||||
|
denomination: 'f04_35_denomination',
|
||||||
|
issueQuantity: 'f04_36_issue_quantity',
|
||||||
|
// f05 价格信息
|
||||||
|
costPrice: 'f05_40_cost_price',
|
||||||
|
targetPrice: 'f05_41_target_price',
|
||||||
|
goalPrice: 'f05_42_goal_price',
|
||||||
|
repairFee: 'f05_43_repair_fee',
|
||||||
|
gradingFee: 'f05_44_grading_fee',
|
||||||
|
// f06 其他信息
|
||||||
|
purpose: 'f06_50_purpose'
|
||||||
|
}
|
||||||
|
const result = {}
|
||||||
|
const numberFields = ['costPrice', 'targetPrice', 'goalPrice', 'repairFee', 'gradingFee']
|
||||||
|
for (const key in obj) {
|
||||||
|
let value = obj[key]
|
||||||
|
if (value === '' || value === null) {
|
||||||
|
value = null
|
||||||
|
} else if (numberFields.includes(key)) {
|
||||||
|
value = parseFloat(value)
|
||||||
|
if (isNaN(value)) value = null
|
||||||
|
}
|
||||||
|
result[map[key] || key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用 Input 组件
|
||||||
|
const Input = ({ form, handleChange, label, field, type = 'text', options = null, disabled = false }) => {
|
||||||
|
const inputRef = useRef(null)
|
||||||
|
const handleFocus = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onChange = (e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
if (type === 'number' && value !== '') {
|
||||||
|
const num = parseFloat(value)
|
||||||
|
handleChange(field, isNaN(num) ? '' : num)
|
||||||
|
} else {
|
||||||
|
handleChange(field, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: '12px' }} ref={inputRef}>
|
||||||
|
<div style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>{label}</div>
|
||||||
|
{options ? (
|
||||||
|
<select
|
||||||
|
value={form[field] || ''}
|
||||||
|
onChange={onChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
background: disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
color: disabled ? '#64748b' : '#fff',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
width: '100%'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">请选择</option>
|
||||||
|
{options.map(o => <option key={o.value} value={o.value} style={{color:'#000'}}>{o.label}</option>)}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={form[field] ?? ''}
|
||||||
|
onChange={onChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
background: disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.05)',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
color: disabled ? '#64748b' : '#fff',
|
||||||
|
padding: '10px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
width: '100%'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认表单数据
|
||||||
|
const getDefaultForm = () => ({
|
||||||
|
name: '龙钞',
|
||||||
|
code: '',
|
||||||
|
category: '自持',
|
||||||
|
rarity: '通货',
|
||||||
|
prefixSerial: '',
|
||||||
|
version: '2024 龙',
|
||||||
|
denomination: '',
|
||||||
|
status: 'in_collection',
|
||||||
|
packaging: '单张',
|
||||||
|
material: '',
|
||||||
|
issueQuantity: '',
|
||||||
|
purpose: '收藏',
|
||||||
|
isGraded: false,
|
||||||
|
gradingCompany: '',
|
||||||
|
gradingScore: '',
|
||||||
|
threeStar: false,
|
||||||
|
specialMark: '',
|
||||||
|
serialFeature: '',
|
||||||
|
issuer: '中国人民银行',
|
||||||
|
issueYear: '',
|
||||||
|
costPrice: '',
|
||||||
|
targetPrice: '',
|
||||||
|
goalPrice: '',
|
||||||
|
repairFee: '',
|
||||||
|
gradingFee: '',
|
||||||
|
remark: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function OCR() {
|
||||||
|
const [activeTab, setActiveTab] = useState('ai') // ai, manual, batch
|
||||||
|
const [mode, setMode] = useState('camera') // camera, form
|
||||||
|
const [form, setForm] = useState(getDefaultForm())
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [recognizing, setRecognizing] = useState(false)
|
||||||
|
const [selectedImage, setSelectedImage] = useState(null)
|
||||||
|
const [imagePreview, setImagePreview] = useState(null)
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
const videoRef = useRef(null)
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
|
||||||
|
// 选项定义
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: 'in_collection', label: '收藏中' },
|
||||||
|
{ value: 'selling', label: '出售中' },
|
||||||
|
{ value: 'sold', label: '已售' },
|
||||||
|
{ value: 'grading', label: '送评中' },
|
||||||
|
{ value: 'repairing', label: '修复中' },
|
||||||
|
{ value: 'transit', label: '在途中' },
|
||||||
|
{ value: 'other', label: '其他' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const categoryOptions = [
|
||||||
|
{ value: '自持', label: '自持' },
|
||||||
|
{ value: '寄存', label: '寄存' },
|
||||||
|
{ value: '寄售', label: '寄售' },
|
||||||
|
{ value: '共有', label: '共有' },
|
||||||
|
{ value: '寻号', label: '寻号' },
|
||||||
|
{ value: '其他', label: '其他' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const rarityOptions = [
|
||||||
|
{ value: '通货', label: '通货' },
|
||||||
|
{ value: '特色', label: '特色' },
|
||||||
|
{ value: '少见', label: '少见' },
|
||||||
|
{ value: '稀有', label: '稀有' },
|
||||||
|
{ value: '珍品', label: '珍品' },
|
||||||
|
{ value: '孤品', label: '孤品' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const packagingOptions = [
|
||||||
|
{ value: '标十', label: '标十' },
|
||||||
|
{ value: '标百', label: '标百' },
|
||||||
|
{ value: '单张', label: '单张' },
|
||||||
|
{ value: '裸钞', label: '裸钞' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const handleChange = (key, value) => {
|
||||||
|
setForm({ ...form, [key]: value })
|
||||||
|
if (key === 'targetPrice' && value) {
|
||||||
|
setForm(prev => ({ ...prev, status: 'sold' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择图片
|
||||||
|
const handleSelectImage = (e) => {
|
||||||
|
const file = e.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
setSelectedImage(file)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
setImagePreview(e.target.result)
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 OCR 识别
|
||||||
|
const handleRecognize = async () => {
|
||||||
|
if (!selectedImage) {
|
||||||
|
setError('请先选择图片')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setRecognizing(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('image', selectedImage)
|
||||||
|
|
||||||
|
// 使用 XMLHttpRequest 替代 fetch,兼容性更好
|
||||||
|
const data = await new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest()
|
||||||
|
xhr.open('POST', '/api/ocr/recognize')
|
||||||
|
xhr.setRequestHeader('Authorization', 'Bearer ' + token)
|
||||||
|
|
||||||
|
xhr.onload = function() {
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText)
|
||||||
|
resolve(data)
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('JSON解析失败: ' + xhr.responseText.substring(0, 100)))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(xhr.responseText)
|
||||||
|
reject(new Error(data.error?.message || data.detail || '识别失败'))
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error('请求失败: ' + xhr.status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.onerror = function() {
|
||||||
|
reject(new Error('网络错误'))
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.send(formData)
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 自动填充识别结果
|
||||||
|
if (data.fields) {
|
||||||
|
const recognizedForm = { ...getDefaultForm() }
|
||||||
|
|
||||||
|
// 映射识别字段到表单字段
|
||||||
|
if (data.fields.name) recognizedForm.name = data.fields.name
|
||||||
|
if (data.fields.code) recognizedForm.code = data.fields.code
|
||||||
|
if (data.fields.version) recognizedForm.version = data.fields.version
|
||||||
|
if (data.fields.prefix_serial) recognizedForm.prefixSerial = data.fields.prefix_serial
|
||||||
|
if (data.fields.denomination) recognizedForm.denomination = data.fields.denomination
|
||||||
|
if (data.fields.material) recognizedForm.material = data.fields.material
|
||||||
|
if (data.fields.issue_year) recognizedForm.issueYear = data.fields.issue_year
|
||||||
|
if (data.fields.issuer) recognizedForm.issuer = data.fields.issuer
|
||||||
|
if (data.fields.cost_price) recognizedForm.costPrice = data.fields.cost_price
|
||||||
|
if (data.fields.remark) recognizedForm.remark = data.fields.remark
|
||||||
|
|
||||||
|
setForm(recognizedForm)
|
||||||
|
setMode('form')
|
||||||
|
alert('识别成功!请检查并完善信息')
|
||||||
|
} else {
|
||||||
|
setError('识别结果为空')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('OCR 识别失败:', e)
|
||||||
|
setError('识别失败:' + e.message)
|
||||||
|
} finally {
|
||||||
|
setRecognizing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存藏品
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!form.name || !form.version) {
|
||||||
|
setError('名称和版别为必填项')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const formData = convertField(form)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/collections', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + token
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
throw new Error(data.error?.message || data.detail || '保存失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('保存成功!')
|
||||||
|
window.location.hash = '#/list'
|
||||||
|
window.refreshList?.()
|
||||||
|
window.refreshHome?.()
|
||||||
|
} catch (e) {
|
||||||
|
alert('保存失败:' + e.message)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拍照模式界面
|
||||||
|
if (mode === 'camera') {
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
|
||||||
|
{/* 顶部标签切换 */}
|
||||||
|
<div style={{ padding: '12px 16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('ai')}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px',
|
||||||
|
background: activeTab === 'ai' ? '#fbbf24' : 'rgba(255,255,255,0.05)',
|
||||||
|
color: activeTab === 'ai' ? '#1e293b' : '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🤖 AI 识别
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setActiveTab('manual');
|
||||||
|
setMode('form');
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px',
|
||||||
|
background: activeTab === 'manual' ? '#fbbf24' : 'rgba(255,255,255,0.05)',
|
||||||
|
color: activeTab === 'manual' ? '#1e293b' : '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✍️ 手工录入
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('batch')}
|
||||||
|
disabled
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '10px',
|
||||||
|
background: 'rgba(255,255,255,0.02)',
|
||||||
|
color: '#64748b',
|
||||||
|
border: '1px solid rgba(255,255,255,0.05)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'not-allowed'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📦 批量录入
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区域 */}
|
||||||
|
{activeTab === 'ai' && (
|
||||||
|
|
||||||
|
<div style={{ padding: '16px' }}>
|
||||||
|
{/* 图片选择区域 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(255,255,255,0.03)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: '12px'
|
||||||
|
}}>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
onChange={handleSelectImage}
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{imagePreview ? (
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src={imagePreview}
|
||||||
|
alt="已选择图片"
|
||||||
|
style={{ maxWidth: '100%', borderRadius: '8px', marginBottom: '16px' }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedImage(null); setImagePreview(null); }}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.2)',
|
||||||
|
color: '#ef4444',
|
||||||
|
border: '1px solid #ef4444',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🗑️ 重新选择
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRecognize}
|
||||||
|
disabled={recognizing}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: recognizing ? '#64748b' : '#fbbf24',
|
||||||
|
color: recognizing ? '#94a3b8' : '#1e293b',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: recognizing ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{recognizing ? '🔍 识别中...' : '🤖 开始识别'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
fileInputRef.current.setAttribute('capture', '');
|
||||||
|
fileInputRef.current.setAttribute('accept', 'image/*');
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '24px',
|
||||||
|
background: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
border: '2px dashed rgba(59, 130, 246, 0.5)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginBottom: '16px',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '40px', marginBottom: '12px' }}>📷</div>
|
||||||
|
<div style={{ color: '#60a5fa', fontSize: '15px', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||||
|
拍照识别
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
|
||||||
|
使用相机拍照并识别
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
fileInputRef.current.removeAttribute('capture');
|
||||||
|
fileInputRef.current.setAttribute('accept', 'image/*');
|
||||||
|
fileInputRef.current.click();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
padding: '24px',
|
||||||
|
background: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
border: '2px dashed rgba(34, 197, 94, 0.5)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '40px', marginBottom: '12px' }}>🖼️</div>
|
||||||
|
<div style={{ color: '#22c55e', fontSize: '15px', fontWeight: 'bold', marginBottom: '4px' }}>
|
||||||
|
从相册选择
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
|
||||||
|
从相册选择已有图片
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误提示 */}
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(239, 68, 68, 0.2)',
|
||||||
|
border: '1px solid #ef4444',
|
||||||
|
color: '#ef4444',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
marginBottom: '12px'
|
||||||
|
}}>
|
||||||
|
⚠️ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<div style={{
|
||||||
|
background: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
border: '1px solid rgba(59, 130, 246, 0.3)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '16px',
|
||||||
|
marginTop: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{ color: '#60a5fa', fontSize: '14px', fontWeight: 'bold', marginBottom: '8px' }}>💡 识别说明</div>
|
||||||
|
<ul style={{ color: '#94a3b8', fontSize: '13px', paddingLeft: '20px', margin: 0 }}>
|
||||||
|
<li>支持拍照或从相册选择图片</li>
|
||||||
|
<li>自动识别名称、版别、冠字序号等字段</li>
|
||||||
|
<li>识别结果可手动修改完善</li>
|
||||||
|
<li>建议拍摄清晰、光线充足的正面照片</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单模式界面
|
||||||
|
return (
|
||||||
|
<div style={{ background: '#0f172a', minHeight: '100vh', paddingBottom: '80px' }}>
|
||||||
|
{/* 顶部 */}
|
||||||
|
<div style={{ padding: '16px', background: 'rgba(255,255,255,0.02)', borderBottom: '1px solid rgba(255,255,255,0.05)', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('camera')}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(255,255,255,0.1)',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
width: '36px',
|
||||||
|
height: '36px',
|
||||||
|
fontSize: '20px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#fff', fontSize: '18px', fontWeight: 'bold' }}>确认信息</div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '13px' }}>请检查并完善识别结果</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
margin: '16px',
|
||||||
|
background: 'rgba(239, 68, 68, 0.2)',
|
||||||
|
border: '1px solid #ef4444',
|
||||||
|
color: '#ef4444',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}>
|
||||||
|
⚠️ {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ padding: '16px' }}>
|
||||||
|
{/* 基本信息 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>基本信息</div>
|
||||||
|
<Input form={form} handleChange={handleChange} label="名称 *" field="name" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="版别 *" field="version" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="冠字序号" field="prefixSerial" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="面值" field="denomination" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="材质" field="material" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="发行方" field="issuer" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="发行年份" field="issueYear" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 价格信息 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>价格信息</div>
|
||||||
|
<Input form={form} handleChange={handleChange} label="成本价" field="costPrice" type="number" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="目标价" field="targetPrice" type="number" />
|
||||||
|
<Input form={form} handleChange={handleChange} label="出售价" field="goalPrice" type="number" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 备注 */}
|
||||||
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
||||||
|
<div style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>备注</div>
|
||||||
|
<textarea
|
||||||
|
value={form.remark || ''}
|
||||||
|
onChange={(e) => handleChange('remark', e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', padding: '10px', borderRadius: '8px', width: '100%', resize: 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 保存按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={saving ? null : handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '14px',
|
||||||
|
background: saving ? '#64748b' : '#fbbf24',
|
||||||
|
color: saving ? '#94a3b8' : '#1e293b',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: saving ? 'not-allowed' : 'pointer',
|
||||||
|
marginBottom: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? '保存中...' : '✅ 保存藏品'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('camera')}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '14px',
|
||||||
|
background: 'rgba(255,255,255,0.05)',
|
||||||
|
color: '#94a3b8',
|
||||||
|
border: '1px solid rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: '10px',
|
||||||
|
fontSize: '14px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄 重新识别
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,3 @@
|
||||||
/**
|
|
||||||
* Settings - 设置页面
|
|
||||||
* Version: 1.2.75x
|
|
||||||
* 更新:
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
|
@ -22,13 +16,6 @@ export default function Settings() {
|
||||||
bio: ''
|
bio: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
// 手机验证码相关状态
|
|
||||||
const [phoneChanged, setPhoneChanged] = useState(false)
|
|
||||||
const [newPhone, setNewPhone] = useState('')
|
|
||||||
const [verifyCode, setVerifyCode] = useState('')
|
|
||||||
const [sendingCode, setSendingCode] = useState(false)
|
|
||||||
const [codeCountdown, setCodeCountdown] = useState(0)
|
|
||||||
|
|
||||||
const [passwordForm, setPasswordForm] = useState({
|
const [passwordForm, setPasswordForm] = useState({
|
||||||
oldPassword: '',
|
oldPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
|
|
@ -42,14 +29,6 @@ export default function Settings() {
|
||||||
fetchUserInfo()
|
fetchUserInfo()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 验证码倒计时
|
|
||||||
useEffect(() => {
|
|
||||||
if (codeCountdown > 0) {
|
|
||||||
const timer = setTimeout(() => setCodeCountdown(codeCountdown - 1), 1000)
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [codeCountdown])
|
|
||||||
|
|
||||||
const fetchUserInfo = async () => {
|
const fetchUserInfo = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/users/me', {
|
const res = await fetch('/api/users/me', {
|
||||||
|
|
@ -66,10 +45,12 @@ export default function Settings() {
|
||||||
address: data.address || '',
|
address: data.address || '',
|
||||||
bio: data.bio || ''
|
bio: data.bio || ''
|
||||||
})
|
})
|
||||||
setNewPhone(data.phone || '')
|
} else {
|
||||||
|
console.error('获取用户信息失败:', res.status)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取用户信息异常:', e)
|
console.error('获取用户信息异常:', e)
|
||||||
|
// 尝试从本地存储获取
|
||||||
const userStr = localStorage.getItem('user')
|
const userStr = localStorage.getItem('user')
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -82,7 +63,6 @@ export default function Settings() {
|
||||||
address: user.address || '',
|
address: user.address || '',
|
||||||
bio: user.bio || ''
|
bio: user.bio || ''
|
||||||
})
|
})
|
||||||
setNewPhone(user.phone || '')
|
|
||||||
} catch (e2) {
|
} catch (e2) {
|
||||||
setError('获取用户信息失败')
|
setError('获取用户信息失败')
|
||||||
}
|
}
|
||||||
|
|
@ -92,92 +72,26 @@ export default function Settings() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 发送手机验证码
|
|
||||||
const handleSendCode = async () => {
|
|
||||||
if (!newPhone) {
|
|
||||||
setError('请输入手机号')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const phoneRegex = /^1[3-9]\d{9}$/
|
|
||||||
if (!phoneRegex.test(newPhone)) {
|
|
||||||
setError('请输入正确的手机号')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setSendingCode(true)
|
|
||||||
setError('')
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/auth/send-verification-code', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ phone: newPhone })
|
|
||||||
})
|
|
||||||
|
|
||||||
const data = await res.json()
|
|
||||||
|
|
||||||
if (data.success) {
|
|
||||||
setPhoneChanged(true)
|
|
||||||
setCodeCountdown(60)
|
|
||||||
setSuccess('验证码已发送到 ' + newPhone.substring(0,3) + '****' + newPhone.substring(7))
|
|
||||||
} else {
|
|
||||||
setError(data.message || '发送失败')
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError('发送失败,请稍后重试')
|
|
||||||
} finally {
|
|
||||||
setSendingCode(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setSuccess('')
|
setSuccess('')
|
||||||
|
|
||||||
// 如果更换了手机号,需要验证码
|
|
||||||
if (newPhone !== form.phone) {
|
|
||||||
if (!verifyCode) {
|
|
||||||
setError('请输入手机验证码')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updateData = {
|
|
||||||
username: form.username,
|
|
||||||
email: form.email || null,
|
|
||||||
phone: newPhone,
|
|
||||||
address: form.address,
|
|
||||||
bio: form.bio
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果更换了手机号,添加验证码
|
|
||||||
if (newPhone !== form.phone) {
|
|
||||||
updateData.verifyCode = verifyCode
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch('/api/users/me', {
|
const res = await fetch('/api/users/me', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': 'Bearer ' + token,
|
'Authorization': 'Bearer ' + token,
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
body: JSON.stringify(updateData)
|
body: JSON.stringify(form)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setSuccess('保存成功!')
|
setSuccess('保存成功!')
|
||||||
setForm({...form, phone: newPhone})
|
// 更新本地存储的用户信息
|
||||||
setVerifyCode('')
|
|
||||||
setPhoneChanged(false)
|
|
||||||
// 更新本地存储
|
|
||||||
const userStr = localStorage.getItem('user')
|
const userStr = localStorage.getItem('user')
|
||||||
if (userStr) {
|
if (userStr) {
|
||||||
const user = JSON.parse(userStr)
|
const user = JSON.parse(userStr)
|
||||||
|
|
@ -188,7 +102,7 @@ export default function Settings() {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
setError(data.error?.message || data.detail || '保存失败')
|
setError(data.error?.message || '保存失败')
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError('保存失败,请重试')
|
setError('保存失败,请重试')
|
||||||
|
|
@ -212,14 +126,16 @@ export default function Settings() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (passwordForm.newPassword.length < 8) {
|
if (passwordForm.newPassword.length < 6) {
|
||||||
setError('密码至少8位,需包含大写+小写+数字')
|
setError('密码长度至少6位')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
|
console.log('开始修改密码...')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('发送请求到 /api/auth/change-password')
|
||||||
const res = await fetch('/api/auth/change-password', {
|
const res = await fetch('/api/auth/change-password', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -232,7 +148,9 @@ export default function Settings() {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
console.log('响应状态:', res.status)
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
console.log('响应数据:', data)
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
setSuccess('密码修改成功!')
|
setSuccess('密码修改成功!')
|
||||||
|
|
@ -282,12 +200,11 @@ export default function Settings() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>邮箱(选填)</label>
|
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>邮箱</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={form.email}
|
value={form.email}
|
||||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
placeholder="选填"
|
|
||||||
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -296,69 +213,27 @@ export default function Settings() {
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机号</label>
|
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机号</label>
|
||||||
<input
|
<input
|
||||||
type="tel"
|
type="tel"
|
||||||
value={newPhone}
|
value={form.phone}
|
||||||
onChange={(e) => {
|
onChange={(e) => setForm({ ...form, phone: e.target.value })}
|
||||||
setNewPhone(e.target.value)
|
|
||||||
setPhoneChanged(true)
|
|
||||||
setVerifyCode('')
|
|
||||||
}}
|
|
||||||
placeholder="11位手机号"
|
|
||||||
maxLength={11}
|
|
||||||
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 手机验证码 - 仅在手机号变更时显示 */}
|
|
||||||
{phoneChanged && (
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>手机验证码</label>
|
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>地址</label>
|
||||||
<div style={{ display: 'flex', gap: '12px' }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={verifyCode}
|
|
||||||
onChange={(e) => setVerifyCode(e.target.value)}
|
|
||||||
placeholder="6位验证码"
|
|
||||||
maxLength={6}
|
|
||||||
style={{ flex: 1, padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSendCode}
|
|
||||||
disabled={sendingCode || codeCountdown > 0}
|
|
||||||
style={{
|
|
||||||
width: '120px',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '8px',
|
|
||||||
border: 'none',
|
|
||||||
background: codeCountdown > 0 ? '#4b5563' : '#22c55e',
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: '14px',
|
|
||||||
cursor: codeCountdown > 0 ? 'not-allowed' : 'pointer'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{codeCountdown > 0 ? codeCountdown + '秒' : sendingCode ? '发送中...' : '获取验证码'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>地址(选填)</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.address}
|
value={form.address}
|
||||||
onChange={(e) => setForm({ ...form, address: e.target.value })}
|
onChange={(e) => setForm({ ...form, address: e.target.value })}
|
||||||
placeholder="选填"
|
|
||||||
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ marginBottom: '16px' }}>
|
<div style={{ marginBottom: '16px' }}>
|
||||||
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>简介(选填)</label>
|
<label style={{ display: 'block', color: '#94a3b8', fontSize: '14px', marginBottom: '8px' }}>简介</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={form.bio}
|
value={form.bio}
|
||||||
onChange={(e) => setForm({ ...form, bio: e.target.value })}
|
onChange={(e) => setForm({ ...form, bio: e.target.value })}
|
||||||
placeholder="选填"
|
|
||||||
rows={3}
|
rows={3}
|
||||||
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff', resize: 'none' }}
|
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff', resize: 'none' }}
|
||||||
/>
|
/>
|
||||||
|
|
@ -385,8 +260,8 @@ export default function Settings() {
|
||||||
type="password"
|
type="password"
|
||||||
value={passwordForm.oldPassword}
|
value={passwordForm.oldPassword}
|
||||||
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
|
onChange={(e) => setPasswordForm({ ...passwordForm, oldPassword: e.target.value })}
|
||||||
placeholder="请输入当前密码"
|
|
||||||
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
||||||
|
placeholder="请输入当前密码"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -396,8 +271,8 @@ export default function Settings() {
|
||||||
type="password"
|
type="password"
|
||||||
value={passwordForm.newPassword}
|
value={passwordForm.newPassword}
|
||||||
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
onChange={(e) => setPasswordForm({ ...passwordForm, newPassword: e.target.value })}
|
||||||
placeholder="至少8位,需包含大写+小写+数字"
|
|
||||||
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
||||||
|
placeholder="至少6位"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -407,8 +282,8 @@ export default function Settings() {
|
||||||
type="password"
|
type="password"
|
||||||
value={passwordForm.confirmPassword}
|
value={passwordForm.confirmPassword}
|
||||||
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
onChange={(e) => setPasswordForm({ ...passwordForm, confirmPassword: e.target.value })}
|
||||||
placeholder="再次输入新密码"
|
|
||||||
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
style={{ width: '100%', padding: '12px', borderRadius: '8px', border: '1px solid #334155', background: '#0f172a', color: '#fff' }}
|
||||||
|
placeholder="再次输入新密码"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,4 @@
|
||||||
/**
|
// 统计分析页面 - 支持点击跳转
|
||||||
* Stats - 统计页面
|
|
||||||
* Version: 1.2.70x
|
|
||||||
* 更新:
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
import React, { useState, useEffect } from 'react'
|
||||||
import { APP_VERSION } from '../config/version'
|
import { APP_VERSION } from '../config/version'
|
||||||
|
|
||||||
|
|
@ -15,7 +10,6 @@ export default function Stats() {
|
||||||
byGrading: [],
|
byGrading: [],
|
||||||
byPackaging: [],
|
byPackaging: [],
|
||||||
byRarity: [],
|
byRarity: [],
|
||||||
byNumberCategory: [],
|
|
||||||
byVersion: [],
|
byVersion: [],
|
||||||
byGradingCompany: [],
|
byGradingCompany: [],
|
||||||
byGradingScore: [],
|
byGradingScore: [],
|
||||||
|
|
@ -55,7 +49,6 @@ export default function Stats() {
|
||||||
byGrading: data.byGrading || [],
|
byGrading: data.byGrading || [],
|
||||||
byPackaging: data.byPackaging || [],
|
byPackaging: data.byPackaging || [],
|
||||||
byRarity: data.byRarity || [],
|
byRarity: data.byRarity || [],
|
||||||
byNumberCategory: data.byNumberCategory || [],
|
|
||||||
byVersion: data.byVersion || [],
|
byVersion: data.byVersion || [],
|
||||||
byGradingCompany: data.byGradingCompany || [],
|
byGradingCompany: data.byGradingCompany || [],
|
||||||
byGradingScore: data.byGradingScore || [],
|
byGradingScore: data.byGradingScore || [],
|
||||||
|
|
@ -93,9 +86,6 @@ export default function Stats() {
|
||||||
gradingCompany: 'gradingCompany',
|
gradingCompany: 'gradingCompany',
|
||||||
gradingScore: 'gradingScore',
|
gradingScore: 'gradingScore',
|
||||||
specialMark: 'specialMark',
|
specialMark: 'specialMark',
|
||||||
numberCategory: 'numberCategory',
|
|
||||||
gradingCompany: 'gradingCompany',
|
|
||||||
gradingScore: 'gradingScore',
|
|
||||||
profitLoss: 'profitLoss'
|
profitLoss: 'profitLoss'
|
||||||
}
|
}
|
||||||
return map[type] || type
|
return map[type] || type
|
||||||
|
|
@ -104,15 +94,10 @@ export default function Stats() {
|
||||||
// 颜色配置
|
// 颜色配置
|
||||||
const colors = {
|
const colors = {
|
||||||
packaging: { '标十': '#22c55e', '标百': '#3b82f6', '单张': '#f59e0b', '裸钞': '#64748b' },
|
packaging: { '标十': '#22c55e', '标百': '#3b82f6', '单张': '#f59e0b', '裸钞': '#64748b' },
|
||||||
rarity: { '通货': '#64748b', '特色': '#22c55e', '少见': '#3b82f6', '稀有': '#8b5cf6', '珍品': '#ef4444', '孤品': '#fbbf24' },
|
rarity: { '通货': '#64748b', '特色': '#22c55e', '少见': '#3b82f6', '稀有': '#8b5cf6', '珍品': '#f59e0b', '孤品': '#ef4444' },
|
||||||
status: { 'in_collection': '#22c55e', 'selling': '#f59e0b', 'sold': '#ef4444', 'grading': '#8b5cf6', 'repairing': '#f97316', 'transit': '#06b6d4', 'seeking': '#ec4899' },
|
status: { 'in_collection': '#22c55e', 'selling': '#f59e0b', 'sold': '#ef4444', 'grading': '#8b5cf6', 'repairing': '#f97316', 'transit': '#06b6d4', 'seeking': '#ec4899' },
|
||||||
category: { '自持': '#22c55e', '寄存': '#3b82f6', '寄售': '#f59e0b', '共有': '#8b5cf6', '寻号': '#06b6d4', '生肖钞': '#22c55e', '其他': '#64748b' },
|
category: { '自持': '#22c55e', '寄存': '#3b82f6', '寄售': '#f59e0b', '共有': '#8b5cf6', '寻号': '#06b6d4', '生肖钞': '#22c55e', '其他': '#64748b' },
|
||||||
profitLoss: { 'profit': '#22c55e', 'loss': '#ef4444' },
|
profitLoss: { 'profit': '#22c55e', 'loss': '#ef4444' }
|
||||||
numberCategory: { '无2347': '#8b5cf6', '无347': '#ef4444', '无247': '#fbbf24', '无47': '#3b82f6', '带7号': '#06b6d4', '带4号': '#22c55e', '其他': '#64748b' },
|
|
||||||
version: {},
|
|
||||||
gradingCompany: {},
|
|
||||||
gradingScore: {},
|
|
||||||
specialMark: {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标签映射
|
// 标签映射
|
||||||
|
|
@ -132,14 +117,8 @@ export default function Stats() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const colorPalette = ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#06b6d4', '#ec4899', '#f97316', '#14b8a6', '#a855f7']
|
|
||||||
const getColor = (type, value) => {
|
const getColor = (type, value) => {
|
||||||
if (colors[type]?.[value]) return colors[type][value]
|
return colors[type]?.[value] || '#64748b'
|
||||||
// 动态生成颜色
|
|
||||||
const key = String(value)
|
|
||||||
let hash = 0
|
|
||||||
for (let i = 0; i < key.length; i++) hash = key.charCodeAt(i) + ((hash << 5) - hash)
|
|
||||||
return colorPalette[Math.abs(hash) % colorPalette.length]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLabel = (type, value) => {
|
const getLabel = (type, value) => {
|
||||||
|
|
@ -151,23 +130,11 @@ export default function Stats() {
|
||||||
return Number(val).toLocaleString('zh-CN')
|
return Number(val).toLocaleString('zh-CN')
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberCategoryOrder = ['无2347', '无347', '无247', '无47', '带7号', '带4号', '其他']
|
|
||||||
const getSortedData = (data, type) => {
|
|
||||||
if (type === 'numberCategory') {
|
|
||||||
return [...data].sort((a, b) => {
|
|
||||||
const order = numberCategoryOrder.indexOf(a.numberCategory)
|
|
||||||
const order2 = numberCategoryOrder.indexOf(b.numberCategory)
|
|
||||||
return order - order2
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
const DistributionCard = ({ title, data, type, valueKey, labelKey }) => (
|
const DistributionCard = ({ title, data, type, valueKey, labelKey }) => (
|
||||||
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
<div style={{ background: 'rgba(255,255,255,0.03)', borderRadius: '12px', padding: '16px', marginBottom: '12px' }}>
|
||||||
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>{title}</div>
|
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '12px' }}>{title}</div>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '8px' }}>
|
||||||
{getSortedData(data, type).map((item, index) => {
|
{data.slice(0, 6).map((item, index) => {
|
||||||
const value = item[valueKey]
|
const value = item[valueKey]
|
||||||
const label = getLabel(type, item[labelKey] || value)
|
const label = getLabel(type, item[labelKey] || value)
|
||||||
const color = getColor(type, value)
|
const color = getColor(type, value)
|
||||||
|
|
@ -192,17 +159,13 @@ export default function Stats() {
|
||||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.05)'
|
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.05)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', marginBottom: '4px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', flex: 1, overflow: 'hidden' }}>
|
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: color }} />
|
||||||
<div style={{ width: '8px', height: '8px', borderRadius: '2px', background: color, flexShrink: 0 }} />
|
<div style={{ color: '#fff', fontSize: '13px', fontWeight: '500', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
<div style={{ color: color, fontSize: '12px', fontWeight: '500', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{label}
|
{label}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ color: '#fff', fontSize: '14px', fontWeight: 'bold', marginLeft: '8px', flexShrink: 0 }}>
|
<div style={{ color: '#fff', fontSize: '16px', fontWeight: 'bold' }}>{item.count}</div>
|
||||||
{item.count}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -238,25 +201,6 @@ export default function Stats() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '16px' }}>
|
<div style={{ padding: '16px' }}>
|
||||||
{/* 总统计 */}
|
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.2) 0%, rgba(251, 191, 36, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(251, 191, 36, 0.3)' }}>
|
|
||||||
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>📊 总统计</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }}>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>总数量</div>
|
|
||||||
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{stats.totalCount}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>已评级</div>
|
|
||||||
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{gradedCount}</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>未评级</div>
|
|
||||||
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{ungradedCount}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 财务统计 */}
|
{/* 财务统计 */}
|
||||||
<div style={{ background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(34, 197, 94, 0.3)' }}>
|
<div style={{ background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.2) 0%, rgba(34, 197, 94, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(34, 197, 94, 0.3)' }}>
|
||||||
<div style={{ color: '#22c55e', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>💰 财务统计</div>
|
<div style={{ color: '#22c55e', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>💰 财务统计</div>
|
||||||
|
|
@ -283,9 +227,28 @@ export default function Stats() {
|
||||||
{/* 盈亏统计移到顶部 */}
|
{/* 盈亏统计移到顶部 */}
|
||||||
<DistributionCard title="💰 盈亏统计" data={stats.byProfitLoss} type="profitLoss" valueKey="type" labelKey="label" />
|
<DistributionCard title="💰 盈亏统计" data={stats.byProfitLoss} type="profitLoss" valueKey="type" labelKey="label" />
|
||||||
|
|
||||||
<DistributionCard title="📦 包装分布" data={stats.byPackaging} type="packaging" valueKey="packaging" />
|
{/* 总统计 */}
|
||||||
<DistributionCard title="🔢 号码分类分布" data={stats.byNumberCategory} type="numberCategory" valueKey="numberCategory" />
|
<div style={{ background: 'linear-gradient(135deg, rgba(251, 191, 36, 0.2) 0%, rgba(251, 191, 36, 0.05) 100%)', borderRadius: '12px', padding: '20px', marginBottom: '16px', border: '1px solid rgba(251, 191, 36, 0.3)' }}>
|
||||||
|
<div style={{ color: '#fbbf24', fontSize: '14px', fontWeight: 'bold', marginBottom: '16px' }}>📊 总统计</div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }}>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>总数量</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{stats.totalCount}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>已评级</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{gradedCount}</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '12px', marginBottom: '4px' }}>未评级</div>
|
||||||
|
<div style={{ color: '#fff', fontSize: '24px', fontWeight: 'bold' }}>{ungradedCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DistributionCard title="📋 状态分布" data={stats.byStatus} type="status" valueKey="status" />
|
<DistributionCard title="📋 状态分布" data={stats.byStatus} type="status" valueKey="status" />
|
||||||
|
<DistributionCard title="💼 持仓类型分布" data={stats.byCategory} type="category" valueKey="category" />
|
||||||
|
<DistributionCard title="📦 包装分布" data={stats.byPackaging} type="packaging" valueKey="packaging" />
|
||||||
<DistributionCard title="⭐ 珍惜度分布" data={stats.byRarity} type="rarity" valueKey="rarity" />
|
<DistributionCard title="⭐ 珍惜度分布" data={stats.byRarity} type="rarity" valueKey="rarity" />
|
||||||
<DistributionCard title="🏷️ 版别分布" data={stats.byVersion} type="version" valueKey="version" />
|
<DistributionCard title="🏷️ 版别分布" data={stats.byVersion} type="version" valueKey="version" />
|
||||||
<DistributionCard title="🏅 评级机构分布" data={stats.byGradingCompany} type="gradingCompany" valueKey="company" />
|
<DistributionCard title="🏅 评级机构分布" data={stats.byGradingCompany} type="gradingCompany" valueKey="company" />
|
||||||
|
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
/**
|
|
||||||
* YichensBoard - 一尘看板页面
|
|
||||||
* Version: 1.2.70x
|
|
||||||
* 更新:
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react'
|
|
||||||
|
|
||||||
export default function YichensBoard() {
|
|
||||||
const [todayStats, setTodayStats] = useState({ total: 0, deals: 0, wants: 0, others: 0 })
|
|
||||||
const [todayCategory, setTodayCategory] = useState([])
|
|
||||||
const [posts, setPosts] = useState([])
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [error, setError] = useState(null)
|
|
||||||
const [expandedPosts, setExpandedPosts] = useState({})
|
|
||||||
const [postTypeFilter, setPostTypeFilter] = useState('all')
|
|
||||||
const [categoryFilter, setCategoryFilter] = useState('')
|
|
||||||
const [page, setPage] = useState(1)
|
|
||||||
const [totalPosts, setTotalPosts] = useState(0)
|
|
||||||
const [searchKeyword, setSearchKeyword] = useState('')
|
|
||||||
|
|
||||||
const today = new Date()
|
|
||||||
const dateStr = today.getFullYear() + '/' + (today.getMonth() + 1) + '/' + today.getDate()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('YichensBoard: 开始加载数据')
|
|
||||||
fetchTodayStats()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const fetchTodayStats = async () => {
|
|
||||||
try {
|
|
||||||
console.log('YichensBoard: 请求 /api/yichens/stats/today')
|
|
||||||
const res = await fetch('/api/yichens/stats/today')
|
|
||||||
if (!res.ok) throw new Error('stats API error: ' + res.status)
|
|
||||||
const data = await res.json()
|
|
||||||
console.log('YichensBoard: stats data', data)
|
|
||||||
setTodayStats(data)
|
|
||||||
} catch(e) {
|
|
||||||
console.error('YichensBoard: fetchTodayStats error', e)
|
|
||||||
setError(e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchPosts = async (p, cat, kw) => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
const currentPage = p !== undefined ? p : page
|
|
||||||
const currentCat = cat !== undefined ? cat : categoryFilter
|
|
||||||
const searchKw = kw !== undefined ? kw : searchKeyword
|
|
||||||
|
|
||||||
let url = '/api/yichens/posts?limit=390&offset=' + ((currentPage - 1) * 390)
|
|
||||||
if (postTypeFilter === 'deal') url += '&post_type=deal'
|
|
||||||
else if (postTypeFilter === 'want') url += '&post_type=want'
|
|
||||||
else if (postTypeFilter === 'other') url += '&post_type=normal'
|
|
||||||
if (searchKw && searchKw.trim()) url += '&keyword=' + encodeURIComponent(searchKw.trim())
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('YichensBoard: 请求 posts', url)
|
|
||||||
const res = await fetch(url)
|
|
||||||
if (!res.ok) throw new Error('posts API error: ' + res.status)
|
|
||||||
let data = await res.json() || []
|
|
||||||
// 确保data是数组
|
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
data = data.posts || data.data || []
|
|
||||||
}
|
|
||||||
console.log('YichensBoard: posts data count', data.length)
|
|
||||||
if (currentCat && Array.isArray(data)) {
|
|
||||||
if (currentCat === '龙') {
|
|
||||||
data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞')))
|
|
||||||
} else if (currentCat === '蛇') {
|
|
||||||
data = data.filter(p => p.category && p.category.includes('蛇'))
|
|
||||||
} else if (currentCat === '马') {
|
|
||||||
data = data.filter(p => p.category && p.category.includes('马'))
|
|
||||||
} else if (currentCat === '其他') {
|
|
||||||
data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 确保data是数组
|
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
data = data.posts || data.data || []
|
|
||||||
}
|
|
||||||
console.log('YichensBoard: posts data count', data.length)
|
|
||||||
if (currentCat && Array.isArray(data)) {
|
|
||||||
if (currentCat === '龙') {
|
|
||||||
data = data.filter(p => p.category && (p.category.includes('龙') || p.category.includes('纪念钞')))
|
|
||||||
} else if (currentCat === '蛇') {
|
|
||||||
data = data.filter(p => p.category && p.category.includes('蛇'))
|
|
||||||
} else if (currentCat === '马') {
|
|
||||||
data = data.filter(p => p.category && p.category.includes('马'))
|
|
||||||
} else if (currentCat === '其他') {
|
|
||||||
data = data.filter(p => p.category && !p.category.includes('龙') && !p.category.includes('纪念钞') && !p.category.includes('蛇') && !p.category.includes('马'))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 支持新格式 {posts:[], total:xxx} 或旧格式 [{},{}]
|
|
||||||
const postsArray = Array.isArray(data) ? data : (data.posts || [])
|
|
||||||
const totalCount = data.total || todayStats.total || postsArray.length
|
|
||||||
setPosts(postsArray)
|
|
||||||
setTotalPosts(totalCount)
|
|
||||||
} catch(e) {
|
|
||||||
console.error('YichensBoard: fetchPosts error', e)
|
|
||||||
setError(e.message)
|
|
||||||
setPosts([])
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (todayStats.total > 0) {
|
|
||||||
fetchPosts(1, categoryFilter, searchKeyword)
|
|
||||||
}
|
|
||||||
}, [todayStats, categoryFilter, postTypeFilter])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setPage(1)
|
|
||||||
fetchPosts(1, categoryFilter, searchKeyword)
|
|
||||||
}, [searchKeyword])
|
|
||||||
|
|
||||||
const togglePost = id => setExpandedPosts(p => ({ ...p, [id]: !p[id] }))
|
|
||||||
|
|
||||||
const StatCard = ({ label, value, color, onClick }) => (
|
|
||||||
<div onClick={() => { setLoading(true); if (onClick) onClick(); }}
|
|
||||||
style={{
|
|
||||||
background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)',
|
|
||||||
borderRadius: 12, padding: '12px 8px', border: '1px solid #374151',
|
|
||||||
cursor: onClick ? 'pointer' : 'default',
|
|
||||||
textAlign: 'center'
|
|
||||||
}}>
|
|
||||||
<div style={{ color: '#9ca3af', fontSize: 11, marginBottom: 4 }}>{label}</div>
|
|
||||||
<div style={{ color: color || '#fff', fontSize: 20, fontWeight: 'bold' }}>{value}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 20, textAlign: 'center', color: '#ef4444' }}>
|
|
||||||
<div>加载失败: {error}</div>
|
|
||||||
<button onClick={() => { setError(null); fetchTodayStats(); }} style={{ marginTop: 10, padding: '8px 16px', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: 6, cursor: 'pointer' }}>
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '0' }}>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 10 }}>
|
|
||||||
📈 连体钞/纪念钞 <span style={{ color: '#9ca3af', fontWeight: 400 }}>{dateStr}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
|
||||||
<StatCard label='全部' value={todayStats.total} color='#3b82f6' onClick={() => { setPostTypeFilter('all'); setCategoryFilter(''); setPage(1); }} />
|
|
||||||
<StatCard label='出售' value={todayStats.deals} color='#10b981' onClick={() => { setPostTypeFilter('deal'); setCategoryFilter(''); setPage(1); }} />
|
|
||||||
<StatCard label='求购' value={todayStats.wants} color='#f59e0b' onClick={() => { setPostTypeFilter('want'); setCategoryFilter(''); setPage(1); }} />
|
|
||||||
<StatCard label='其他' value={todayStats.others || 0} color='#8b5cf6' onClick={() => { setPostTypeFilter('other'); setCategoryFilter(''); setPage(1); }} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 12, marginBottom: 20, border: '1px solid #374151' }}>
|
|
||||||
<div style={{ color: '#f9fafb', fontSize: 14, fontWeight: 600, marginBottom: 8 }}>📊 今日分类统计</div>
|
|
||||||
<div style={{ color: '#9ca3af', fontSize: 12 }}>龙: {todayStats.dragons || 0} | 马: {todayStats.horses || 0} | 蛇: {todayStats.snakes || 0} | 天马: {todayStats.tianma || 0}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="搜索标题、内容、分类..."
|
|
||||||
value={searchKeyword}
|
|
||||||
onChange={(e) => { const kw = e.target.value; setSearchKeyword(kw); setPage(1); fetchPosts(1, '', kw) }}
|
|
||||||
style={{ flex: 1, padding: '10px 14px', borderRadius: 8, border: '1px solid #374151', background: '#1f2937', color: '#fff', fontSize: 13, outline: 'none' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => { setSearchKeyword(''); setPage(1); fetchPosts(1, '') }}
|
|
||||||
style={{ padding: '10px 16px', borderRadius: 8, border: 'none', background: '#4b5563', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
|
|
||||||
清空
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{searchKeyword && <div style={{ color: '#9ca3af', fontSize: 12, marginTop: 6 }}>搜索: "{searchKeyword}",共找到 {totalPosts} 条结果</div>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
|
||||||
{[{key:'all',label:'全部'},{key:'deal',label:'出售'},{key:'want',label:'求购'},{key:'other',label:'其他'}].map(k => (
|
|
||||||
<button key={k.key} onClick={() => { setPostTypeFilter(k.key==='other'?'other':k.key); setCategoryFilter(''); setPage(1); }}
|
|
||||||
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
|
|
||||||
background: postTypeFilter===k.key ? 'linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
|
|
||||||
{k.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|
||||||
{[{key:'',label:'全部'},{key:'龙',label:'龙钞'},{key:'蛇',label:'蛇钞'},{key:'马',label:'马钞'},{key:'其他',label:'其他'}].map(k => (
|
|
||||||
<button key={k.key} onClick={() => { setCategoryFilter(k.key); setPage(1); }}
|
|
||||||
style={{ padding: '8px 16px', borderRadius: 8, border: 'none',
|
|
||||||
background: categoryFilter===k.key ? 'linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%)' : '#374151', color: '#fff', cursor: 'pointer', fontSize: 13 }}>
|
|
||||||
{k.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? <div style={{textAlign:'center',color:'#9ca3af',padding:40}}>加载中...</div> : (
|
|
||||||
<div>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{posts.length === 0 ? (
|
|
||||||
<div style={{ textAlign: 'center', color: '#9ca3af', padding: 40 }}>暂无帖子数据</div>
|
|
||||||
) : (
|
|
||||||
posts.map(post => (
|
|
||||||
<div key={post.post_id} style={{ background: 'linear-gradient(145deg, #1f2937 0%, #111827 100%)', borderRadius: 12, padding: 16, border: '1px solid #374151' }}>
|
|
||||||
<div onClick={() => togglePost(post.post_id)} style={{ cursor: 'pointer' }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
|
||||||
<span style={{ color: '#fff', fontSize: 15, fontWeight: 500, flex: 1 }}>{post.title||'无标题'}</span>
|
|
||||||
<div style={{ display: 'flex', gap: 4 }}>
|
|
||||||
<span style={{ color: post.post_type==='deal'?'#10b981':post.post_type==='want'?'#f59e0b':post.post_type==='normal'?'#8b5cf6':'#9ca3af', fontSize: 12 }}>
|
|
||||||
{post.post_type==='deal'?'出售':post.post_type==='want'?'求购':post.post_type==='normal'?'其他':'普通'}
|
|
||||||
</span>
|
|
||||||
<span style={{ padding: '2px 8px', borderRadius: 4, fontSize: 11, background: post.category?.includes('龙') ? 'rgba(251,191,36,0.4)' : post.category?.includes('马') ? 'rgba(180,83,9,0.4)' : post.category?.includes('蛇') ? 'rgba(249,168,212,0.4)' : 'rgba(139,92,246,0.4)', color: post.category?.includes('龙') ? '#fde047' : post.category?.includes('马') ? '#d97706' : post.category?.includes('蛇') ? '#fbcfe8' : '#c4b5fd' }}>
|
|
||||||
{post.category || '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', color: '#9ca3af', fontSize: 12 }}>
|
|
||||||
<span>{post.author_username||'未知'}</span>
|
|
||||||
<span>{post.post_time?.substring(0,16)||''}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{expandedPosts[post.post_id] && post.content && (
|
|
||||||
<div style={{ marginTop: 12, paddingTop: 12, borderTop: '1px solid #374151' }}>
|
|
||||||
<div style={{ color: '#e2e8f0', fontSize: 13, lineHeight: 1.6, whiteSpace: 'pre-wrap', wordBreak: 'break-word', maxHeight: 300, overflowY: 'auto' }}>
|
|
||||||
{post.content}
|
|
||||||
</div>
|
|
||||||
{post.url && <a href={post.url} target='_blank' rel='noopener noreferrer' style={{ display:'inline-block', marginTop:12, padding:'8px 16px', background:'rgba(59,130,246,0.2)', borderRadius:8, color:'#60a5fa', textDecoration:'none', fontSize:13 }}>查看原帖</a>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: '#1e293b', borderRadius: 12, marginTop: 16 }}>
|
|
||||||
<div style={{ color: '#9ca3af', fontSize: 12 }}>
|
|
||||||
共 {totalPosts} 条结果,{Math.ceil(totalPosts / 390)} 页,当前第 {page} 页
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
{page > 1 ? (
|
|
||||||
<button onClick={() => { const newPage = page - 1; setPage(newPage); fetchPosts(newPage, categoryFilter, searchKeyword) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>上一页</button>
|
|
||||||
) : (
|
|
||||||
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>上一页</span>
|
|
||||||
)}
|
|
||||||
{posts.length >= 390 && totalPosts > page * 390 ? (
|
|
||||||
<button onClick={() => { const newPage = page + 1; setPage(newPage); fetchPosts(newPage, categoryFilter, searchKeyword) }} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: '#3b82f6', color: '#fff', cursor: 'pointer', fontSize: 12 }}>下一页</button>
|
|
||||||
) : (
|
|
||||||
<span style={{ padding: '6px 12px', borderRadius: 6, background: '#374151', color: '#6b7280', fontSize: 12 }}>下一页</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
||||||
from sqlalchemy import Table, MetaData
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from pydantic import BaseModel
|
|
||||||
from typing import Optional, List
|
|
||||||
from datetime import datetime, date
|
|
||||||
from app.core.database import get_db, engine
|
|
||||||
from app.models.models import User
|
|
||||||
from app.routers.auth import get_current_user
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/news", tags=["资讯"])
|
|
||||||
metadata = MetaData()
|
|
||||||
|
|
||||||
# 分类表
|
|
||||||
categories_table = Table('news_categories', metadata, autoload_with=engine)
|
|
||||||
news_table = Table('news', metadata, autoload_with=engine)
|
|
||||||
user_posts_table = Table('user_posts', metadata, autoload_with=engine)
|
|
||||||
users_table = Table('users', metadata, autoload_with=engine)
|
|
||||||
deals_table = Table('deals', metadata, autoload_with=engine)
|
|
||||||
notifications_table = Table('notifications', metadata, autoload_with=engine)
|
|
||||||
|
|
||||||
# ============ 获取分类 ============
|
|
||||||
@router.get("/categories")
|
|
||||||
def get_categories(db: Session = Depends(get_db)):
|
|
||||||
results = db.query(categories_table).order_by(categories_table.c.sort_order).all()
|
|
||||||
return [dict(r._mapping) for r in results]
|
|
||||||
|
|
||||||
# ============ 获取资讯 ============
|
|
||||||
@router.get("")
|
|
||||||
def get_news(
|
|
||||||
category_id: Optional[int] = None,
|
|
||||||
page: int = 1,
|
|
||||||
limit: int = 20,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
query = db.query(news_table)
|
|
||||||
if category_id:
|
|
||||||
query = query.filter(news_table.c.category_id == category_id)
|
|
||||||
offset = (page - 1) * limit
|
|
||||||
results = query.order_by(news_table.c.created_at.desc()).offset(offset).limit(limit).all()
|
|
||||||
return [dict(r._mapping) for r in results]
|
|
||||||
|
|
||||||
# ============ 获取用户发布 ============
|
|
||||||
@router.get("/posts")
|
|
||||||
def get_posts(
|
|
||||||
post_type: Optional[str] = None,
|
|
||||||
status: str = "active",
|
|
||||||
page: int = 1,
|
|
||||||
limit: int = 20,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
query = db.query(user_posts_table).filter(user_posts_table.c.status == status)
|
|
||||||
if post_type:
|
|
||||||
query = query.filter(user_posts_table.c.post_type == post_type)
|
|
||||||
offset = (page - 1) * limit
|
|
||||||
results = query.order_by(user_posts_table.c.created_at.desc()).offset(offset).limit(limit).all()
|
|
||||||
return [dict(r._mapping) for r in results]
|
|
||||||
|
|
||||||
# ============ 创建发布 ============
|
|
||||||
class PostCreate(BaseModel):
|
|
||||||
post_type: str
|
|
||||||
title: str
|
|
||||||
content: Optional[str] = None
|
|
||||||
zodiac_type: Optional[str] = None
|
|
||||||
packaging: Optional[str] = None
|
|
||||||
|
|
||||||
@router.post("/posts")
|
|
||||||
def create_post(
|
|
||||||
post: PostCreate,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
result = db.execute(user_posts_table.insert().values(
|
|
||||||
user_id=current_user.f99_90_id,
|
|
||||||
post_type=post.post_type,
|
|
||||||
title=post.title,
|
|
||||||
content=post.content,
|
|
||||||
zodiac_type=post.zodiac_type,
|
|
||||||
packaging=post.packaging,
|
|
||||||
status="pending"
|
|
||||||
))
|
|
||||||
db.commit()
|
|
||||||
return {"success": True, "id": result.inserted_primary_key[0]}
|
|
||||||
|
|
||||||
# ============ 成交数据 ============
|
|
||||||
@router.get("/deals")
|
|
||||||
def get_deals(
|
|
||||||
zodiac_type: Optional[str] = None,
|
|
||||||
limit: int = 20,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
query = db.query(deals_table)
|
|
||||||
if zodiac_type:
|
|
||||||
query = query.filter(deals_table.c.zodiac_type == zodiac_type)
|
|
||||||
results = query.order_by(deals_table.c.deal_date.desc()).limit(limit).all()
|
|
||||||
return [dict(r._mapping) for r in results]
|
|
||||||
|
|
||||||
# ============ 通知 ============
|
|
||||||
@router.get("/notifications")
|
|
||||||
def get_notifications(limit: int = 10, db: Session = Depends(get_db)):
|
|
||||||
results = db.query(notifications_table).filter(
|
|
||||||
notifications_table.c.is_published == True
|
|
||||||
).order_by(notifications_table.c.created_at.desc()).limit(limit).all()
|
|
||||||
return [dict(r._mapping) for r in results]
|
|
||||||
|
|
||||||
# ============ 首页数据 ============
|
|
||||||
@router.get("/home")
|
|
||||||
def get_home(db: Session = Depends(get_db)):
|
|
||||||
# 推荐发布
|
|
||||||
posts = db.query(user_posts_table).filter(
|
|
||||||
user_posts_table.c.status == "active"
|
|
||||||
).order_by(user_posts_table.c.created_at.desc()).limit(10).all()
|
|
||||||
|
|
||||||
# 成交
|
|
||||||
deals = db.query(deals_table).order_by(
|
|
||||||
deals_table.c.deal_date.desc()
|
|
||||||
).limit(10).all()
|
|
||||||
|
|
||||||
# 通知
|
|
||||||
notices = db.query(notifications_table).filter(
|
|
||||||
notifications_table.c.is_published == True
|
|
||||||
).order_by(notifications_table.c.created_at.desc()).limit(5).all()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"posts": [dict(p._mapping) for p in posts],
|
|
||||||
"deals": [dict(d._mapping) for d in deals],
|
|
||||||
"notices": [dict(n._mapping) for n in notices]
|
|
||||||
}
|
|
||||||
|
|
@ -1,129 +0,0 @@
|
||||||
/**
|
|
||||||
* 号码分类工具
|
|
||||||
* 根据冠字号自动分类为:圆圆号、倒置号、金马王、金马号、金山王、天马王、金山号、天马号、朦胧号、如意号、钻石号、永恒号、带7号、带4号
|
|
||||||
*/
|
|
||||||
|
|
||||||
// 分类定义(按优先级排序)
|
|
||||||
const CATEGORIES = [
|
|
||||||
{ name: '圆圆号', mustNot: ['1','2','3','4','5','7'], mustHave: [] },
|
|
||||||
{ name: '倒置号', mustNot: ['2','3','4','5','7'], mustHave: ['1'] },
|
|
||||||
{ name: '金马王', mustNot: ['1','2','3','4','7'], mustHave: ['5'] },
|
|
||||||
{ name: '金马号', mustNot: ['2','3','4','7'], mustHave: ['1','5'] },
|
|
||||||
{ name: '金山王', mustNot: ['1','2','4','5','7'], mustHave: ['3'] },
|
|
||||||
{ name: '天马王', mustNot: ['1','2','4','7'], mustHave: ['3','5'] },
|
|
||||||
{ name: '金山号', mustNot: ['2','4','5','7'], mustHave: ['1','3'] },
|
|
||||||
{ name: '天马号', mustNot: ['2','4','7'], mustHave: ['1','3','5'] },
|
|
||||||
{ name: '朦胧号', mustNot: ['3','4','5','7'], mustHave: [] },
|
|
||||||
{ name: '如意号', mustNot: ['1','3','4','7'], mustHave: [] },
|
|
||||||
{ name: '钻石号', mustNot: ['3','4','7'], mustHave: [] },
|
|
||||||
{ name: '永恒号', mustNot: ['4','7'], mustHave: [] },
|
|
||||||
{ name: '带7号', mustNot: ['4'], mustHave: ['7'] },
|
|
||||||
{ name: '带4号', mustNot: [], mustHave: ['4'] },
|
|
||||||
]
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提取冠字号中的数字部分
|
|
||||||
* @param {string} serial - 冠字号,如 J0123456789 或 J0123456781 或 J0123456701
|
|
||||||
* @returns {object} - { digits: 数字串, type: 'single'|'ten'|'hundred' }
|
|
||||||
*/
|
|
||||||
export function extractDigits(serial) {
|
|
||||||
if (!serial) return { digits: '', type: 'single' }
|
|
||||||
|
|
||||||
// 去掉J,取数字部分
|
|
||||||
const nums = serial.replace(/J/g, '').replace(/\D/g, '')
|
|
||||||
|
|
||||||
// 判断类型
|
|
||||||
if (nums.endsWith('01')) {
|
|
||||||
// 标百:去掉最后2位
|
|
||||||
return { digits: nums.slice(0, -2), type: 'hundred' }
|
|
||||||
} else if (nums.endsWith('1')) {
|
|
||||||
// 标十:去掉最后1位
|
|
||||||
return { digits: nums.slice(0, -1), type: 'ten' }
|
|
||||||
} else {
|
|
||||||
// 单张:全部数字
|
|
||||||
return { digits: nums, type: 'single' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 号码分类函数
|
|
||||||
* @param {string} serial - 冠字号
|
|
||||||
* @returns {string} - 分类名称
|
|
||||||
*/
|
|
||||||
export function getNumberCategory(serial) {
|
|
||||||
const { digits } = extractDigits(serial)
|
|
||||||
|
|
||||||
if (!digits || digits.length < 7) {
|
|
||||||
return '其他'
|
|
||||||
}
|
|
||||||
|
|
||||||
// 按优先级匹配
|
|
||||||
for (const cat of CATEGORIES) {
|
|
||||||
if (matchesCategory(digits, cat)) {
|
|
||||||
return cat.name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return '其他'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查数字是否匹配分类条件
|
|
||||||
* @param {string} digits - 数字串
|
|
||||||
* @param {object} category - 分类定义
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
function matchesCategory(digits, category) {
|
|
||||||
const mustNot = category.mustNot
|
|
||||||
const mustHave = category.mustHave
|
|
||||||
|
|
||||||
// 1. 检查必须不含的数字
|
|
||||||
for (const n of mustNot) {
|
|
||||||
if (digits.includes(n)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 检查必须含有的数字
|
|
||||||
for (const n of mustHave) {
|
|
||||||
if (!digits.includes(n)) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取分类颜色
|
|
||||||
* @param {string} category - 分类名称
|
|
||||||
* @returns {string} - 颜色 hex
|
|
||||||
*/
|
|
||||||
export function getNumberCategoryColor(category) {
|
|
||||||
const colors = {
|
|
||||||
'圆圆号': '#8b5cf6', // 紫
|
|
||||||
'倒置号': '#ec4899', // 粉
|
|
||||||
'金马王': '#f59e0b', // 金
|
|
||||||
'金马号': '#ef4444', // 红
|
|
||||||
'金山王': '#14b8a6', // 青
|
|
||||||
'天马王': '#06b6d4', // 蓝
|
|
||||||
'金山号': '#0d9488', // 绿松石
|
|
||||||
'天马号': '#22c55e', // 绿
|
|
||||||
'朦胧号': '#6366f1', // 靛蓝
|
|
||||||
'如意号': '#a855f7', // 紫红
|
|
||||||
'钻石号': '#eab308', // 黄
|
|
||||||
'永恒号': '#3b82f6', // <20><><EFBFBD>
|
|
||||||
'带7号': '#f97316', // 橙
|
|
||||||
'带4号': '#64748b', // 灰
|
|
||||||
'其他': '#94a3b8',
|
|
||||||
}
|
|
||||||
return colors[category] || colors['其他']
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 分类优先级(用于排序)
|
|
||||||
*/
|
|
||||||
export const NUMBER_CATEGORY_ORDER = CATEGORIES.map(c => c.name)
|
|
||||||
|
|
||||||
// 可选值列表(用于下拉框)
|
|
||||||
export const NUMBER_CATEGORY_OPTIONS = CATEGORIES.map(c => ({ value: c.name, label: c.name }))
|
|
||||||
|
|
@ -1,240 +0,0 @@
|
||||||
# Logo 使用规范
|
|
||||||
|
|
||||||
**版本**: v1.0.0
|
|
||||||
**更新日期**: 2026-03-16
|
|
||||||
**状态**: ✅ 官方指定 Logo
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐉 官方 Logo
|
|
||||||
|
|
||||||
### 主 Logo
|
|
||||||
|
|
||||||
**文件**: `jiachenlong-logo.png`
|
|
||||||
|
|
||||||
**位置**:
|
|
||||||
- 本地:`/static/images/jiachenlong-logo.png`
|
|
||||||
- 前端服务器:`/var/www/html/static/images/jiachenlong-logo.png`
|
|
||||||
|
|
||||||
**规格**:
|
|
||||||
- 格式:PNG
|
|
||||||
- 大小:606KB
|
|
||||||
- 尺寸:正方形(适合圆形裁剪)
|
|
||||||
- 颜色:橙色(中国传统色)
|
|
||||||
- 设计:龙型环绕 + "甲辰收藏"文字
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 使用场景
|
|
||||||
|
|
||||||
### 1. 登录页面
|
|
||||||
|
|
||||||
**文件**: `frontend/src/pages/Login.jsx`
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<img
|
|
||||||
src="/static/images/jiachenlong-logo.png"
|
|
||||||
alt="甲辰收藏"
|
|
||||||
style={{
|
|
||||||
width: '200px',
|
|
||||||
height: '200px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
boxShadow: '0 0 40px rgba(251, 191, 36, 0.4)',
|
|
||||||
background: '#fff'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 首页
|
|
||||||
|
|
||||||
**文件**: `frontend/src/pages/Home.jsx`
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<img
|
|
||||||
src="/static/images/jiachenlong-logo.png"
|
|
||||||
alt="甲辰收藏"
|
|
||||||
style={{
|
|
||||||
width: '48px',
|
|
||||||
height: '48px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
objectFit: 'cover'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 藏品详情页
|
|
||||||
|
|
||||||
**文件**: `frontend/src/pages/Detail.jsx`
|
|
||||||
|
|
||||||
```jsx
|
|
||||||
<img
|
|
||||||
src="/static/images/jiachenlong-logo.png"
|
|
||||||
alt="甲辰收藏"
|
|
||||||
onError={(e) => {
|
|
||||||
e.target.src = '/static/images/jiachenlong-logo.png';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 样式规范
|
|
||||||
|
|
||||||
### 圆形样式(推荐)
|
|
||||||
|
|
||||||
```css
|
|
||||||
.logo {
|
|
||||||
width: 200px;
|
|
||||||
height: 200px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
box-shadow: 0 0 40px rgba(251, 191, 36, 0.4);
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 小尺寸(导航栏等)
|
|
||||||
|
|
||||||
```css
|
|
||||||
.logo-small {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 中等尺寸
|
|
||||||
|
|
||||||
```css
|
|
||||||
.logo-medium {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 部署规范
|
|
||||||
|
|
||||||
### 部署脚本
|
|
||||||
|
|
||||||
**文件**: `scripts/deploy.sh`
|
|
||||||
|
|
||||||
部署脚本会自动:
|
|
||||||
1. ✅ 检查 Logo 文件是否存在
|
|
||||||
2. ✅ 部署前端构建文件
|
|
||||||
3. ✅ 部署 Logo 到服务器
|
|
||||||
4. ✅ 重启 Nginx
|
|
||||||
|
|
||||||
### 部署命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 测试环境
|
|
||||||
./scripts/deploy.sh 1.0.0 test
|
|
||||||
|
|
||||||
# 生产环境
|
|
||||||
./scripts/deploy.sh 1.0.0 production
|
|
||||||
```
|
|
||||||
|
|
||||||
### 手动部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 构建前端
|
|
||||||
cd frontend
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 2. 部署到服务器
|
|
||||||
scp -r dist/* root@8.149.137.26:/var/www/html/
|
|
||||||
scp static/images/jiachenlong-logo.png root@8.149.137.26:/var/www/html/static/images/
|
|
||||||
|
|
||||||
# 3. 重启 Nginx
|
|
||||||
ssh root@8.149.137.26 "nginx -s reload"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ 注意事项
|
|
||||||
|
|
||||||
### 必须遵守
|
|
||||||
|
|
||||||
1. ✅ **统一使用** `jiachenlong-logo.png`
|
|
||||||
2. ✅ **禁止使用** 旧版 `logo.jpg`、`dragon-logo.jpg`、`title_logo.svg`
|
|
||||||
3. ✅ **保持比例** - 始终使用正方形容器
|
|
||||||
4. ✅ **圆形裁剪** - 使用 `border-radius: 50%`
|
|
||||||
5. ✅ **白色背景** - Logo 需要白色背景衬托
|
|
||||||
|
|
||||||
### 禁止行为
|
|
||||||
|
|
||||||
- ❌ 不要修改 Logo 颜色
|
|
||||||
- ❌ 不要拉伸变形
|
|
||||||
- ❌ 不要添加其他效果
|
|
||||||
- ❌ 不要使用其他 Logo 文件
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 文件位置
|
|
||||||
|
|
||||||
### 本地开发
|
|
||||||
|
|
||||||
```
|
|
||||||
jiachenlong/
|
|
||||||
└── static/
|
|
||||||
└── images/
|
|
||||||
└── jiachenlong-logo.png # ✅ 官方 Logo
|
|
||||||
```
|
|
||||||
|
|
||||||
### 前端服务器
|
|
||||||
|
|
||||||
```
|
|
||||||
/var/www/html/
|
|
||||||
└── static/
|
|
||||||
└── images/
|
|
||||||
└── jiachenlong-logo.png # ✅ 官方 Logo
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 更新流程
|
|
||||||
|
|
||||||
如需更新 Logo:
|
|
||||||
|
|
||||||
1. **替换文件**
|
|
||||||
```bash
|
|
||||||
cp new-logo.png /static/images/jiachenlong-logo.png
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **重新构建**
|
|
||||||
```bash
|
|
||||||
cd frontend
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **部署到服务器**
|
|
||||||
```bash
|
|
||||||
./scripts/deploy.sh 1.0.1 production
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **验证部署**
|
|
||||||
```bash
|
|
||||||
curl http://8.149.137.26/static/images/jiachenlong-logo.png -o /tmp/logo-check.png
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Logo 对比
|
|
||||||
|
|
||||||
| 文件 | 状态 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| `jiachenlong-logo.png` | ✅ **官方指定** | 橙色圆形龙型 Logo |
|
|
||||||
| `logo.jpg` | ❌ 废弃 | 旧版 Logo |
|
|
||||||
| `dragon-logo.jpg` | ❌ 废弃 | 旧版龙型 Logo |
|
|
||||||
| `title_logo.svg` | ❌ 废弃 | 旧版 SVG Logo |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**所有部署必须使用 `jiachenlong-logo.png`!**
|
|
||||||
|
|
||||||
**最后更新**: 2026-03-16
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
# 图片资源
|
|
||||||
|
|
||||||
本目录存放项目的所有图片资源。
|
|
||||||
|
|
||||||
## 📁 文件列表
|
|
||||||
|
|
||||||
- `logo.jpg` - 系统主 Logo(106KB, 512x512)
|
|
||||||
|
|
||||||
## 🎨 使用方式
|
|
||||||
|
|
||||||
### 前端访问
|
|
||||||
```jsx
|
|
||||||
<img src="/static/images/logo.jpg" alt="logo" />
|
|
||||||
```
|
|
||||||
|
|
||||||
### 后端访问(FastAPI)
|
|
||||||
```python
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📐 建议尺寸
|
|
||||||
|
|
||||||
- **Logo**: 512x512 或更大(用于缩放)
|
|
||||||
- **背景图**: 1920x1080(全屏背景)
|
|
||||||
- **头像**: 200x200(用户头像)
|
|
||||||
|
|
||||||
## 📦 格式建议
|
|
||||||
|
|
||||||
- **Logo**: PNG(透明背景)或 JPG
|
|
||||||
- **照片**: JPG(压缩比好)
|
|
||||||
- **图标**: SVG(矢量可缩放)或 PNG
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**最后更新**: 2026-03-16
|
|
||||||
|
Before Width: | Height: | Size: 133 KiB |
|
|
@ -1,26 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 80">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="goldGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" stop-color="#FFE4B5"/>
|
|
||||||
<stop offset="25%" stop-color="#FFD700"/>
|
|
||||||
<stop offset="50%" stop-color="#FFA500"/>
|
|
||||||
<stop offset="75%" stop-color="#DAA520"/>
|
|
||||||
<stop offset="100%" stop-color="#B8860B"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="glow">
|
|
||||||
<feGaussianBlur stdDeviation="1.5" result="blur"/>
|
|
||||||
<feFlood flood-color="#FFD700" flood-opacity="0.6"/>
|
|
||||||
<feComposite in2="blur" operator="in"/>
|
|
||||||
<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>
|
|
||||||
</filter>
|
|
||||||
<filter id="shadow">
|
|
||||||
<feDropShadow dx="2" dy="3" stdDeviation="2" flood-color="#000" flood-opacity="0.5"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Main title -->
|
|
||||||
<text x="0" y="45" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="52" font-weight="bold" fill="url(#goldGrad)" filter="url(#shadow)">甲辰收藏</text>
|
|
||||||
|
|
||||||
<!-- Subtitle -->
|
|
||||||
<text x="0" y="72" font-family="'Kaiti SC', 'STKaiti', 'KaiTi', 'SimKai', serif" font-size="20" fill="#DAA520" letter-spacing="4">生肖纪念钞管理系统</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.2 KiB |
|
|
@ -6,13 +6,10 @@ import { join } from 'path'
|
||||||
// 从 config/VERSION 文件读取版本号
|
// 从 config/VERSION 文件读取版本号
|
||||||
function getVersion() {
|
function getVersion() {
|
||||||
try {
|
try {
|
||||||
const versionFile = join(__dirname, 'config', 'VERSION')
|
const versionFile = join(__dirname, '..', 'config', 'VERSION')
|
||||||
const content = readFileSync(versionFile, 'utf-8').trim()
|
const content = readFileSync(versionFile, 'utf-8')
|
||||||
// 移除 VERSION= 前缀
|
const match = content.match(/^VERSION=(.*)$/m)
|
||||||
if (content.startsWith('VERSION=')) {
|
return match ? match[1].trim() : '0.0.0'
|
||||||
return content.substring(7).trim()
|
|
||||||
}
|
|
||||||
return content || '0.0.0'
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('读取 VERSION 文件失败:', e.message)
|
console.error('读取 VERSION 文件失败:', e.message)
|
||||||
return '0.0.0'
|
return '0.0.0'
|
||||||
|
|
@ -20,42 +17,42 @@ function getVersion() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const APP_VERSION = getVersion()
|
const APP_VERSION = getVersion()
|
||||||
console.log('📦 构建版本:v' + APP_VERSION)
|
console.log(`📦 构建版本:v${APP_VERSION}`)
|
||||||
|
|
||||||
// 构建后执行 - 更新 dist/index.html 的 title
|
// 构建时自动更新 index.html 的 title
|
||||||
function updateHtmlTitle() {
|
function updateHtmlTitle() {
|
||||||
return {
|
|
||||||
name: 'update-html-title',
|
|
||||||
closeBundle() {
|
|
||||||
try {
|
try {
|
||||||
const htmlPath = join(__dirname, 'dist', 'index.html')
|
const htmlPath = join(__dirname, 'index.html')
|
||||||
let htmlContent = readFileSync(htmlPath, 'utf-8')
|
let htmlContent = readFileSync(htmlPath, 'utf-8')
|
||||||
// 替换 <title>甲辰收藏 vXXX</title>
|
// 替换 <title>甲辰收藏 vXXX</title>
|
||||||
htmlContent = htmlContent.replace(
|
htmlContent = htmlContent.replace(
|
||||||
/<title>甲辰收藏 v[\d.]+<\/title>/,
|
/<title>甲辰收藏 v[\d.]+<\/title>/,
|
||||||
'<title>甲辰收藏 v' + APP_VERSION + '</title>'
|
`<title>甲辰收藏 v${APP_VERSION}</title>`
|
||||||
)
|
)
|
||||||
writeFileSync(htmlPath, htmlContent, 'utf-8')
|
writeFileSync(htmlPath, htmlContent, 'utf-8')
|
||||||
console.log('✅ 已更新 dist/index.html title: 甲辰收藏 v' + APP_VERSION)
|
console.log(`✅ 已更新 index.html title: 甲辰收藏 v${APP_VERSION}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('更新 dist/index.html 失败:', e.message)
|
console.error('更新 index.html 失败:', e.message)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构建前执行
|
||||||
|
updateHtmlTitle()
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), updateHtmlTitle()],
|
plugins: [react()],
|
||||||
define: {
|
define: {
|
||||||
'import.meta.env.APP_VERSION': JSON.stringify(APP_VERSION)
|
'import.meta.env.APP_VERSION': JSON.stringify(APP_VERSION)
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
entryFileNames: 'assets/[name]-[hash]-[name].js',
|
entryFileNames: `assets/[name]-[hash]-[name].js`,
|
||||||
chunkFileNames: 'assets/[name]-[hash].js',
|
chunkFileNames: `assets/[name]-[hash].js`,
|
||||||
assetFileNames: 'assets/[name]-[hash].[ext]'
|
assetFileNames: `assets/[name]-[hash].[ext]`
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
// 禁用缓存
|
||||||
|
manifest: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
# 版本一致性检查脚本
|
|
||||||
# 用法: ./scripts/check_version.sh
|
|
||||||
|
|
||||||
echo "========================================="
|
|
||||||
echo " 版本一致性检查"
|
|
||||||
echo "========================================="
|
|
||||||
|
|
||||||
CONFIG_FILE="/root/.openclaw/workspace/jiachenlong/config/VERSION.json"
|
|
||||||
VERSION=$(grep -o '"version": "[^"]*"' $CONFIG_FILE | cut -d'"' -f4)
|
|
||||||
UPDATED=$(grep -o '"updated": "[^"]*"' $CONFIG_FILE | cut -d'"' -f4)
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "当前版本: $VERSION"
|
|
||||||
echo "更新时间: $UPDATED"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# 检查前端页面版本
|
|
||||||
echo "--- 前端页面 (最后修改时间) ---"
|
|
||||||
for file in /root/.openclaw/workspace/jiachenlong/frontend/src/pages/*.jsx; do
|
|
||||||
name=$(basename $file .jsx)
|
|
||||||
mtime=$(date -r "$file" "+%Y-%m-%d %H:%M")
|
|
||||||
echo "$name: $mtime"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "--- 后端路由 (最后修改时间) ---"
|
|
||||||
for file in /root/.openclaw/workspace/jiachenlong/backend/app/routers/*.py; do
|
|
||||||
name=$(basename $file .py)
|
|
||||||
if [ "$name" != "__init__" ]; then
|
|
||||||
mtime=$(date -r "$file" "+%Y-%m-%d %H:%M")
|
|
||||||
echo "$name: $mtime"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "========================================="
|
|
||||||
echo " 检查完成"
|
|
||||||
echo "========================================="
|
|
||||||
13
start.sh
|
|
@ -1,13 +0,0 @@
|
||||||
#!/bin/bash
|
|
||||||
cd /root/jiachenlong/backend
|
|
||||||
export DATABASE_URL='postgresql://jiachenlong:Passwd1%403@pgm-bp1t5w248t7s1pvr.pg.rds.aliyuncs.com:5432/jiachenlong'
|
|
||||||
export SECRET_KEY=production-secret-key-b-env-20260401
|
|
||||||
export OSS_ACCESS_KEY_ID=LTAI5t6HUnpFBLEK9194kPVG
|
|
||||||
export OSS_ACCESS_KEY_SECRET=LEr4Q8yRxb8D5b24cKfCwlt4MMoke1
|
|
||||||
export SMS_ACCESS_KEY_ID=LTAI5t86bc1nNKVNyYv4Af6x
|
|
||||||
export SMS_ACCESS_KEY_SECRET=92EVAIE3GECr214c9UaSF6TSYJvDLY
|
|
||||||
export SMS_SIGN_NAME=苏州双人旁
|
|
||||||
export SMS_TEMPLATE_CODE=SMS_505015231
|
|
||||||
export DASHSCOPE_API_KEY=sk-9389024a37da4f7bb455ac9a6b28776f
|
|
||||||
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 3000 --workers 1 > /tmp/uvicorn.log 2>&1 &
|
|
||||||
echo 'B后端服务已启动'
|
|
||||||
|
Before Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 133 KiB |