From 74589883b64a14a4f098e9256e264dce4ec3c71d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B2=E8=BE=B0=E7=94=9F=E4=BA=A7?= Date: Sat, 11 Apr 2026 16:30:18 +0800 Subject: [PATCH] =?UTF-8?q?v1.2.82=20-=20=E8=A1=8C=E6=83=85=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E4=BC=98=E5=8C=96=EF=BC=9A=E6=89=B9=E9=87=8F=E5=BD=95?= =?UTF-8?q?=E5=85=A5=E5=A2=9E=E5=BC=BA=E3=80=81=E6=88=91=E7=9A=84=E8=A1=8C?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E9=9D=A2=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 批量录入增加默认日期、成交平台、默认包装类型选项 - 我的行情页面优化显示:冠字号金色、编号白色、大小号显示 - 编辑界面增加平台、出售者、购买者字段 - 导航栏优化:我的、行情 - 修复批量解析大小号计算问题 - 自动生成行情编号(日期+5位自然数) --- VERSION | 2 +- backend/app/models/models.py | 10 + backend/app/routers/information.py | 339 ++++++++++++++++++++- frontend/src/App.jsx | 4 +- frontend/src/pages/Add.jsx | 466 ++++++++++++++++++++++------- frontend/src/pages/List.jsx | 304 ++++++++++++++++++- 6 files changed, 1004 insertions(+), 121 deletions(-) diff --git a/VERSION b/VERSION index 8a27477..aea1186 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -VERSION=1.2.79 +1.2.82 diff --git a/backend/app/models/models.py b/backend/app/models/models.py index f218c60..6ea0838 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -180,6 +180,16 @@ class Information(Base): 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) diff --git a/backend/app/routers/information.py b/backend/app/routers/information.py index 9d14f85..e620732 100644 --- a/backend/app/routers/information.py +++ b/backend/app/routers/information.py @@ -1,10 +1,11 @@ # 资讯API路由 -from fastapi import APIRouter, Depends, HTTPException, Query, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Response, Body from sqlalchemy.orm import Session, joinedload from sqlalchemy import text from typing import List, Optional from pydantic import BaseModel from datetime import datetime, date +import os from app.core.database import get_db from app.core.auth import get_current_user @@ -28,6 +29,12 @@ class InformationCreate(BaseModel): expect_price_max: Optional[float] = None deal_price: Optional[float] = None deal_date: Optional[date] = None + packaging: Optional[str] = None + is_graded: Optional[bool] = False + grading_company: Optional[str] = None + grading_score: Optional[str] = None + category: Optional[str] = None + deal_no: Optional[str] = None class InformationUpdate(BaseModel): @@ -42,6 +49,10 @@ class InformationUpdate(BaseModel): expect_price_max: Optional[float] = None deal_price: Optional[float] = None deal_date: Optional[date] = None + packaging: Optional[str] = None + is_graded: Optional[bool] = None + grading_company: Optional[str] = None + grading_score: Optional[str] = None class InformationResponse(BaseModel): @@ -66,6 +77,13 @@ class InformationResponse(BaseModel): view_count: int contact_count: int created_at: datetime + # 评级相关字段 + packaging: Optional[str] = None + is_graded: Optional[bool] = False + grading_company: Optional[str] = None + grading_score: Optional[str] = None + category: Optional[str] = None + deal_no: Optional[str] = None # 用户信息 user_name: Optional[str] = None user_avatar: Optional[str] = None @@ -89,7 +107,7 @@ def get_information_list( info_type: Optional[str] = Query(None, description="类型: seek/deal/publish"), status: str = Query("active", description="状态: active/closed/expired"), page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), + page_size: int = Query(20, ge=1, le=500), current_user: Optional[User] = Depends(get_current_user), db: Session = Depends(get_db), response: Response = None @@ -146,6 +164,12 @@ def get_information_list( collection_category=item.collection.f01_03_category if item.collection else None, collection_version=item.collection.f02_11_version if item.collection else None, collection_number=item.collection.f02_10_prefix_serial if item.collection else None, + packaging=item.packaging, + is_graded=item.is_graded or False, + grading_company=item.grading_company, + grading_score=item.grading_score, + category=item.category, + deal_no=item.deal_no, matched_count=matched_count, network_matched_count=match_collections_count_from_coolbot(item.expect_number) if item.info_type == 'seek' and item.expect_number else 0, )) @@ -394,6 +418,11 @@ def get_information( view_count=item.view_count, contact_count=item.contact_count, created_at=item.created_at, + packaging=item.packaging, + is_graded=item.is_graded or False, + grading_company=item.grading_company, + grading_score=item.grading_score, + category=item.category, user_name=item.user.f01_01_name if item.user else None, user_avatar=item.user.avatar if item.user else None, collection_name=item.collection.f01_01_name if item.collection else None, @@ -411,6 +440,20 @@ def create_information( db: Session = Depends(get_db) ): """发布资讯""" + # 生成行情编号:日期 + 5位自然数(从00001开始) + deal_no = None + if data.info_type == 'deal': + today = datetime.now().strftime('%Y%m%d') + # 查询当天已有行情数量 + from app.models.models import Information + count_today = db.query(Information).filter( + Information.info_type == 'deal', + Information.deal_no.like(f'DJ{today}%') + ).count() + # 编号 = 日期 + 5位自然数(如 DJ2026041100001) + seq = count_today + 1 + deal_no = f'DJ{today}{seq:05d}' + info = Information( user_id=current_user.f99_90_id, info_type=data.info_type, @@ -425,6 +468,12 @@ def create_information( expect_price_max=data.expect_price_max, deal_price=data.deal_price, deal_date=data.deal_date, + packaging=data.packaging, + is_graded=data.is_graded or False, + grading_company=data.grading_company, + grading_score=data.grading_score, + category=data.category, + deal_no=deal_no, status="active" ) db.add(info) @@ -499,6 +548,16 @@ def update_information( info.deal_price = data.deal_price if data.deal_date is not None: info.deal_date = data.deal_date + if data.packaging is not None: + info.packaging = data.packaging + if data.is_graded is not None: + info.is_graded = data.is_graded + if data.grading_company is not None: + info.grading_company = data.grading_company + if data.grading_score is not None: + info.grading_score = data.grading_score + if data.category is not None: + info.category = data.category db.commit() db.refresh(info) @@ -522,6 +581,11 @@ def update_information( view_count=info.view_count, contact_count=info.contact_count, created_at=info.created_at, + packaging=info.packaging, + is_graded=info.is_graded or False, + grading_company=info.grading_company, + grading_score=info.grading_score, + category=info.category, user_name=current_user.f01_01_name, user_avatar=current_user.avatar, collection_name=info.collection.f01_01_name if info.collection else None, @@ -796,7 +860,7 @@ def get_deal_stats( @router.get("/my/list", response_model=List[InformationResponse]) def get_my_information_list( page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), + page_size: int = Query(20, ge=1, le=500), current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -1124,3 +1188,272 @@ def get_seek_stats( "userMatchedCount": user_matched_count, "totalMatchedCount": total_matched_count } + +# 批量解析行情数据API +@router.post("/batch-parse") +async def batch_parse_deals(text: str = Body(..., embed=True)): + """使用AI智能解析批量行情文本""" + import httpx + import json + import re + + # 使用阿里云百炼Coding Plan API + api_key = "sk-sp-d5ce68bb203e48ca857c2aea25255b26" + base_url = "https://coding.dashscope.aliyuncs.com/v1" + + # 更详细的解析提示词 + prompt = f"""你是一个专业的龙钞行情数据提取助手。请从以下文本中提取所有龙钞行情记录。 + +【解析规则】 +1. 每条记录格式:冠字号 价格 评级/包装 出售者 +2. 冠字号:J0开头的9位数字(如J0298810101) +3. 价格:¥xxx,xxx 格式,去掉逗号转为数字 +4. 评级/包装:PC69/PMG68/爱藏67+/爱藏67 标十 标百 单张 +5. 出售者:人名 +6. 号码分类:根据冠字号数字特征判断(圆圆号/倒置号/金马王/金马号/金山王/天马王/金山号/天马号/朦胧王/朦胧号/如意号/钻石/永恒/无4/通货) + +【输出格式】 +返回JSON数组,每条记录包含: +- serial: 冠字号(完整9位,如J0298810101) +- price: 价格(数字) +- grade: 评级(如PC69, PMG68, 爱藏67+, 爱藏67) +- packaging: 包装类型(标十/标百/单张) +- category: 号码分类 +- seller: 出售者 +- date: 交易日(从文本中提取日期,如2026-03-29) + +只返回JSON数组,不要其他内容。 + +文本: +{text}""" + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + f"{base_url}/chat/completions", + json={ + "model": "qwen3.6-plus", + "messages": [ + {"role": "system", "content": "你是一个专业的收藏品行情数据提取助手,擅长从文本中提取结构化的交易数据。只返回JSON数组。"}, + {"role": "user", "content": prompt} + ], + "temperature": 0.1 + }, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + ) + + if response.status_code != 200: + return {"success": False, "error": f"API错误: {response.status_code}, {response.text[:200]}"} + + result = response.json() + # 阿里云百炼OpenAI兼容格式 + choices = result.get("choices", []) + content = "" + if choices and len(choices) > 0: + content = choices[0].get("message", {}).get("content", "") + + # 解析JSON + try: + # 尝试提取JSON + if "```json" in content: + content = content.split("```json")[1].split("```")[0] + elif "```" in content: + content = content.split("```")[1].split("```")[0] + + # 尝试直接解析 + data = json.loads(content.strip()) + return {"success": True, "data": data} + except json.JSONDecodeError: + # 尝试用正则提取 + match = re.search(r'\[.*\]', content, re.DOTALL) + if match: + try: + data = json.loads(match.group()) + return {"success": True, "data": data} + except: + pass + return {"success": False, "error": "解析失败", "raw": content[:500]} + + except Exception as e: + return {"success": False, "error": str(e)} + +# 本地正则解析函数 +def parse_deals_locally(text: str, default_packaging: str = '', default_date: str = '', default_platform: str = ''): + """本地正则解析批量行情文本""" + import re + from datetime import datetime + results = [] + + # 尝试从文本中提取日期(可能出现在标题或时间戳中) + # 格式如: 3月29日, 2026年3月29日, 2026-03-29 + date_patterns = [ + r'(\d{1,2})月(\d{1,2})日', + r'(\d{4})年(\d{1,2})月(\d{1,2})日', + r'(\d{4})-(\d{1,2})-(\d{1,2})' + ] + + extracted_date = None + for pattern in date_patterns: + match = re.search(pattern, text) + if match: + try: + if len(match.groups()) == 2: + # 3月29日 - 使用当前年份 + extracted_date = f"2026-{int(match.group(1)):02d}-{int(match.group(2)):02d}" + elif len(match.groups()) == 3: + if int(match.group(1)) > 2000: + # 2026年3月29日 + extracted_date = f"{match.group(1)}-{int(match.group(2)):02d}-{int(match.group(3)):02d}" + else: + # 3月29日格式 + extracted_date = f"2026-{int(match.group(1)):02d}-{int(match.group(2)):02d}" + break + except: + pass + + # 默认使用今天 + default_date = datetime.now().strftime('%Y-%m-%d') + deal_date = extracted_date or default_date + + lines = text.strip().split('\n') + + for line in lines: + line = line.strip() + if not line or 'J0' not in line: + continue + + # 提取冠字号 J0 + 8-9位数字 + serial_match = re.search(r'J0(\d{8,9})', line) + if not serial_match: + continue + + serial_num = serial_match.group(1) + if len(serial_num) == 9: + serial_num = serial_num[:8] + serial = 'J0' + serial_num + + # 提取价格 ¥xxx,xxx 或 xxx,xxx(必须在J0之后) + serial_pos = line.find(serial) + after_serial = line[serial_pos + len(serial):] + price_match = re.search(r'[¥¥]?\s*([1-9][\d,]{2,})', after_serial) + if not price_match: + continue + price = int(price_match.group(1).replace(',', '')) + + # 提取卖家(价格后面的中文字符) + after_price_pos = after_serial.find(price_match.group(0)) + len(price_match.group(0)) + after_price = after_serial[after_price_pos:] + seller_match = re.search(r'([^\d\s¥¥]+?)(?:\s*$|\s*\n)', after_price) + seller = seller_match.group(1).strip() if seller_match else '' + + # 分类判断 + digits = serial_num + d = digits + category = '通货' + if not any(c in d for c in ['1','2','3','4','5','7']): category = '圆圆号' + elif not any(c in d for c in ['2','3','4','5','7']): category = '倒置号' + elif not any(c in d for c in ['1','2','3','4','7']): category = '金马王' + elif not any(c in d for c in ['2','3','4','7']): category = '金马号' + elif not any(c in d for c in ['1','2','4','5','7']): category = '金山王' + elif not any(c in d for c in ['1','2','4','7']): category = '天马王' + elif not any(c in d for c in ['2','4','5','7']): category = '金山号' + elif not any(c in d for c in ['2','4','7']): category = '天马号' + elif not any(c in d for c in ['1','3','4','5','7']): category = '朦胧王' + elif not any(c in d for c in ['3','4','5','7']): category = '朦胧号' + elif not any(c in d for c in ['1','3','4','7']): category = '如意号' + elif not any(c in d for c in ['3','4','7']): category = '钻石' + elif not any(c in d for c in ['4','7']): category = '永恒' + elif '4' not in d: category = '无4' + + # 提取评级机构 PCGS/PMG/ACG/爱藏 + grade = '' + grading_company = '' + packaging = '单张' + + if 'PC69' in line or 'PC68' in line or 'PC67' in line: + grade_match = re.search(r'PC(6[789]|5\d?)', line) + grade = 'PC' + grade_match.group(1) if grade_match else '' + grading_company = 'PCGS' + elif 'PMG68' in line or 'PMG67' in line: + grade_match = re.search(r'PMG(6[789]|5\d?)', line) + grade = 'PMG' + grade_match.group(1) if grade_match else '' + grading_company = 'PMG' + elif 'ACG' in line: + grade_match = re.search(r'ACG(6[789]|5\d?)', line) + grade = 'ACG' + grade_match.group(1) if grade_match else '' + grading_company = 'ACG' + elif '爱藏67+' in line: + grade = '67+' + grading_company = '爱藏' + elif '爱藏67' in line: + grade = '67' + grading_company = '爱藏' + + # 判断包装类型 + packaging = '单张' + + # 如果传入了默认包装类型,先使用默认 + if default_packaging: + packaging = default_packaging + + # 简化识别:带"刀"字=标百,带"标"字=标十 + if '刀' in line: + packaging = '标百' + elif '标' in line: + packaging = '标十' + + # 尾号判断:如果冠字号尾号是01/11/21/31/41/51/61/71/81/91,且有刀/标字样,基本确认是标百 + if len(serial_num) >= 2: + tail = serial_num[-2:] + if tail in ['01', '11', '21', '31', '41', '51', '61', '71', '81', '91']: + if '刀' in line or ('标' in line and packaging == '单张'): + packaging = '标百' + packaging = '标百' + + # 如果没有刀/标字样,但尾号是01且没有其他特征,可能是标百 + if packaging == '单张' and len(serial_num) >= 2: + tail = serial_num[-2:] + if tail == '01': + # 检查是否在特定语境下 + packaging = '标百' + + # 计算尾号和大小号 + tail_number = '' + size_type = '' + if packaging == '标十' and len(serial_num) >= 2: + tail_number = serial_num[-2:] + size_type = tail_number in ['01','11','21','31','41','51'] and '小号' or '大号' + elif packaging == '标百' and len(serial_num) >= 3: + tail_number = serial_num[-3:] + size_type = tail_number in ['101','201','301','401','501'] and '小号' or '大号' + + results.append({ + 'serial': serial, + 'price': price, + 'category': category, + 'seller': seller, + 'packaging': packaging, + 'grade': grade, + 'grading_company': grading_company, + 'deal_date': deal_date or default_date, # 成交时间 + 'entry_date': default_date, # 录入时间 + 'is_graded': bool(grade), + 'tail_number': tail_number, # 尾号 + 'size_type': size_type, # 大小号 + 'platform': default_platform # 平台 + }) + + return results + +@router.post("/batch-parse-local") +async def batch_parse_deals_local(request: dict = Body(...)): + """本地正则解析批量行情文本(无需AI)""" + text = request.get('text', '') + default_packaging = request.get('defaultPackaging', '') + default_date = request.get('defaultDate', '') + default_platform = request.get('defaultPlatform', '') + results = parse_deals_locally(text, default_packaging, default_date, default_platform) + return {"success": True, "data": results} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a123fa5..34e17ce 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -85,9 +85,9 @@ export default function App() { }}> {[ { path: '/', icon: '🏠', label: '首页' }, - { path: '/news', icon: '📰', label: '资讯' }, + { path: '/news', icon: '📰', label: '行情' }, { path: '/stats', icon: '📊', label: '统计' }, - { path: '/list', icon: '📚', label: '藏品' }, + { path: '/list', icon: '📚', label: '我的' }, { path: '/add', icon: '🎯', label: '录入' }, ...(isAdmin ? [{ path: '/admin', icon: '⚙️', label: '管理' }] : []) ].map(tab => ( diff --git a/frontend/src/pages/Add.jsx b/frontend/src/pages/Add.jsx index a4bc066..9ccd98f 100644 --- a/frontend/src/pages/Add.jsx +++ b/frontend/src/pages/Add.jsx @@ -81,8 +81,18 @@ export default function Add() { platform: '淘宝', seller: '', buyer: '', - date: new Date().toISOString().split('T')[0] + date: new Date().toISOString().split('T')[0], + isGraded: false, + gradingCompany: '', + gradingScore: '' }) + 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) // 号码分类函数 @@ -703,123 +713,355 @@ export default function Add() {
成交行情录入
-
-
冠字号 *
- 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' }} /> + {/* 单条/批量切换 */} +
+ +
- {dealForm.category && ( -
-
号码分类
-
{dealForm.category}
+ {/* 单条录入 */} + {dealMode === 'single' && ( +
+
+
冠字号 *
+ 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' }} /> +
+ + {dealForm.category && ( +
+
号码分类
+
{dealForm.category}
+
+ )} + +
+
包装类型
+
+ {['单张', '标十', '标百'].map(p => ( + + ))} +
+
+ +
+
成交价格 *
+ 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' }} /> +
+ +
+
成交平台 *
+ +
+ +
+
+
出售者
+ 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' }} /> +
+
+
购买者
+ 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' }} /> +
+
+ +
+
成交日期 *
+ 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' }} /> +
+ + {/* 评级字段 */} +
+
评级(可选)
+
+ + 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' }} /> +
+
+ +
)} -
-
包装类型
-
- {['单张', '标十', '标百'].map(p => ( - - ))} + {/* 批量录入 */} + {dealMode === 'batch' && ( +
+ {/* 日期和平台 */} +
+
+
默认日期(可选)
+ 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' }} /> +
+
+
成交平台(可选)
+ +
+
+ + {/* 包装类型选择 */} +
+
批量默认包装类型(可选)
+
+ + + +
+
+ +