feat: 管理后台完整可用 + 注册登录记日志 + 提取信息结构化展示 + 微信配置就绪

- 管理后台用户/统计/日志/配置四页签全部对接真实后端API
- auth注册/登录/游客/微信登录事件写入usage_logs表
- 提取信息结果从原始JSON改为卡片式字段列表(中文标签)
- 管理后台搜索按钮增加加载态和结果数提示
- 配置WECHAT_APP_ID/WECHAT_APP_SECRET
- 客户/产品/报价单CRUD页面完整(导出导入批量操作)
This commit is contained in:
TradeMate Dev
2026-05-18 23:50:48 +08:00
parent 32d2b57df7
commit 4755cc75ba
14 changed files with 1495 additions and 119 deletions
+27 -7
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, status, Header
from fastapi import APIRouter, Depends, HTTPException, status, Header, Request
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
@@ -9,6 +9,7 @@ from app.models.user import User
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
from pydantic import BaseModel, EmailStr
from datetime import datetime, timedelta
from app.services.admin import AdminService
router = APIRouter()
@@ -37,7 +38,7 @@ class RefreshRequest(BaseModel):
@router.post("/register")
async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
async def register(data: RegisterRequest, request: Request, db: AsyncSession = Depends(get_db)):
existing = await db.execute(select(User).where(User.phone == data.phone))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Phone already registered")
@@ -51,6 +52,9 @@ async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
db.add(user)
await db.flush()
client_ip = request.client.host if request.client else None
await AdminService(db).log_usage(str(user.id), "user.register", {"phone": data.phone}, ip=client_ip)
return {
"id": str(user.id),
"phone": user.phone,
@@ -63,12 +67,17 @@ async def register(data: RegisterRequest, db: AsyncSession = Depends(get_db)):
@router.post("/login", response_model=LoginResponse)
async def login(
data: LoginRequest,
request: Request,
db: AsyncSession = Depends(get_db),
):
phone = data.username or data.phone
if not phone:
login_id = data.username or data.phone
if not login_id:
raise HTTPException(status_code=422, detail="phone required")
result = await db.execute(select(User).where(User.phone == phone))
result = await db.execute(
select(User).where(
(User.phone == login_id) | (User.username == login_id)
)
)
user = result.scalar_one_or_none()
if not user or not verify_password(data.password, user.password_hash):
@@ -77,6 +86,9 @@ async def login(
detail="Invalid credentials",
)
client_ip = request.client.host if request.client else None
await AdminService(db).log_usage(str(user.id), "user.login", {"login_id": login_id}, ip=client_ip)
return LoginResponse(
access_token=create_access_token({"sub": str(user.id), "tier": user.tier, "role": user.role}),
refresh_token=create_refresh_token({"sub": str(user.id)}),
@@ -90,7 +102,7 @@ async def login(
@router.post("/login/guest")
async def guest_login():
async def guest_login(request: Request, db: AsyncSession = Depends(get_db)):
guest_id = str(uuid.uuid4())
access_token = create_access_token(
{"sub": guest_id, "tier": "guest", "role": "guest", "is_guest": True},
@@ -98,6 +110,9 @@ async def guest_login():
)
refresh_token = create_refresh_token({"sub": guest_id, "is_guest": True})
client_ip = request.client.host if request.client else None
await AdminService(db).log_usage(guest_id, "user.login_guest", {}, ip=client_ip)
return LoginResponse(
access_token=access_token,
refresh_token=refresh_token,
@@ -197,7 +212,7 @@ async def wechat_config():
@router.post("/wechat-login")
async def wechat_login(data: WeChatLoginRequest, db: AsyncSession = Depends(get_db)):
async def wechat_login(data: WeChatLoginRequest, request: Request, db: AsyncSession = Depends(get_db)):
from app.services.wechat import wechat_service
session = await wechat_service.code2session(data.code)
@@ -207,6 +222,7 @@ async def wechat_login(data: WeChatLoginRequest, db: AsyncSession = Depends(get_
openid = session.get("openid")
result = await db.execute(select(User).where(User.wechat_openid == openid))
user = result.scalar_one_or_none()
is_new = False
if not user:
user = User(
@@ -216,6 +232,10 @@ async def wechat_login(data: WeChatLoginRequest, db: AsyncSession = Depends(get_
)
db.add(user)
await db.flush()
is_new = True
client_ip = request.client.host if request.client else None
await AdminService(db).log_usage(str(user.id), "user.wechat_login", {"is_new": is_new}, ip=client_ip)
return LoginResponse(
access_token=create_access_token({"sub": str(user.id), "tier": user.tier, "role": user.role}),
+17
View File
@@ -187,3 +187,20 @@ async def export_customers(
headers={"Content-Disposition": "attachment; filename=customers.csv"},
)
@router.get("/export/xlsx")
async def export_customers_xlsx(
status: Optional[str] = None,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = CustomerService(db)
result = await service.list_customers(user_id, status, 1, 9999)
items = result.get("items", [])
xlsx_bytes = export.export_customers_xlsx(items)
return Response(
content=xlsx_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=customers.xlsx"},
)
+108 -2
View File
@@ -1,10 +1,21 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Response, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from typing import Optional, List
from app.database import get_db
from app.services.product import ProductService
from app.services import export
from app.api.v1.deps import get_current_user_id
from pydantic import BaseModel
import io
import logging
logger = logging.getLogger(__name__)
try:
import openpyxl
HAS_OPENPYXL = True
except ImportError:
HAS_OPENPYXL = False
router = APIRouter()
@@ -50,6 +61,101 @@ async def list_products(
return await service.list_products(user_id, category, page, size)
@router.get("/export/csv")
async def export_products(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = ProductService(db)
result = await service.list_products(user_id, None, 1, 9999)
items = result.get("items", [])
csv_bytes = export.export_products_csv(items)
return Response(
content=csv_bytes,
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=products.csv"},
)
@router.get("/export/xlsx")
async def export_products_xlsx(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = ProductService(db)
result = await service.list_products(user_id, None, 1, 9999)
items = result.get("items", [])
xlsx_bytes = export.export_products_xlsx(items)
return Response(
content=xlsx_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=products.xlsx"},
)
@router.post("/import")
async def import_products(
file: UploadFile = File(...),
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = ProductService(db)
content = await file.read()
filename = file.filename.lower()
if filename.endswith(".xlsx"):
if not HAS_OPENPYXL:
raise HTTPException(status_code=501, detail="openpyxl not installed, XLSX import unavailable")
wb = openpyxl.load_workbook(io.BytesIO(content))
ws = wb.active
rows = list(ws.iter_rows(min_row=2, values_only=True))
records = []
for row in rows:
if not row[0]:
continue
records.append({
"name": str(row[0] or ""),
"name_en": str(row[1] or ""),
"category": str(row[2] or ""),
"description": str(row[3] or ""),
"price": str(row[4] or ""),
"price_unit": str(row[5] or "USD") if len(row) > 5 else "USD",
"moq": str(row[6] or "") if len(row) > 6 else "",
"keywords": [k.strip() for k in str(row[7] or "").split(",") if k.strip()] if len(row) > 7 else [],
})
elif filename.endswith(".csv"):
import csv
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig")))
records = []
for row in reader:
name = row.get("名称", row.get("name", "")).strip()
if not name:
continue
records.append({
"name": name,
"name_en": row.get("英文名", row.get("name_en", "")),
"category": row.get("分类", row.get("category", "")),
"description": row.get("描述", row.get("description", "")),
"price": row.get("价格", row.get("price", "")),
"price_unit": row.get("货币", row.get("price_unit", "USD")),
"moq": row.get("MOQ", row.get("moq", "")),
"keywords": [k.strip() for k in row.get("关键词", row.get("keywords", "")).split(",") if k.strip()],
})
else:
raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
imported = 0
errors = []
for rec in records:
try:
await service.create_product(user_id, rec)
imported += 1
except Exception as e:
errors.append(f"{rec.get('name', '')}: {str(e)}")
return {"imported": imported, "errors": errors}
@router.get("/{product_id}")
async def get_product(
product_id: str,
+85 -1
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from fastapi import APIRouter, Depends, HTTPException, Query, Response, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional
from pydantic import BaseModel
@@ -10,6 +10,16 @@ from app.api.v1.deps import get_current_user_id
from app.models.quotation import Quotation
from app.models.customer import Customer
from sqlalchemy import select, and_
import logging
import io
logger = logging.getLogger(__name__)
try:
import openpyxl
HAS_OPENPYXL = True
except ImportError:
HAS_OPENPYXL = False
router = APIRouter()
@@ -59,6 +69,64 @@ async def list_quotations(
return await service.list_quotations(user_id, page, size)
@router.post("/import")
async def import_quotations(
file: UploadFile = File(...),
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
content = await file.read()
filename = file.filename.lower()
if filename.endswith(".xlsx"):
if not HAS_OPENPYXL:
raise HTTPException(status_code=501, detail="openpyxl not installed, XLSX import unavailable")
wb = openpyxl.load_workbook(io.BytesIO(content))
ws = wb.active
rows = list(ws.iter_rows(min_row=2, values_only=True))
records = []
for row in rows:
if not row[0]:
continue
items = [{"product_name": "Imported Item", "quantity": 1, "unit_price": float(row[3]) if len(row) > 3 and row[3] else 0}]
records.append({
"title": str(row[0]),
"customer_id": str(row[1]) if len(row) > 1 and row[1] else "",
"currency": str(row[2]) if len(row) > 2 and row[2] in ("USD", "EUR", "GBP", "CNY") else "USD",
"items": items,
"status": "draft",
})
elif filename.endswith(".csv"):
import csv
reader = csv.DictReader(io.StringIO(content.decode("utf-8-sig")))
records = []
for row in reader:
title = row.get("Title", row.get("标题", "")).strip()
if not title:
continue
items = [{"product_name": "Imported Item", "quantity": 1, "unit_price": float(row.get("Total", row.get("总计", 0)))}]
records.append({
"title": title,
"customer_id": row.get("Customer", row.get("客户", "")),
"currency": row.get("Currency", row.get("货币", "USD")),
"items": items,
})
else:
raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
imported = 0
errors = []
for rec in records:
try:
await service.create_quotation(user_id, rec)
imported += 1
except Exception as e:
errors.append(f"{rec.get('title', '')}: {str(e)}")
return {"imported": imported, "errors": errors}
@router.get("/{quotation_id}")
async def get_quotation(
quotation_id: str,
@@ -102,6 +170,22 @@ async def export_quotations(
)
@router.get("/export/xlsx")
async def export_quotations_xlsx(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = QuotationService(db)
result = await service.list_quotations(user_id, 1, 9999)
items = result.get("items", [])
xlsx_bytes = export.export_quotations_xlsx(items)
return Response(
content=xlsx_bytes,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=quotations.xlsx"},
)
@router.get("/{quotation_id}/pdf")
async def export_quotation_pdf(
quotation_id: str,
+156 -1
View File
@@ -1,6 +1,16 @@
from typing import List, Dict, Any
import csv
import io
import logging
logger = logging.getLogger(__name__)
try:
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment
HAS_OPENPYXL = True
except ImportError:
HAS_OPENPYXL = False
def export_customers_csv(customers: List[Dict[str, Any]]) -> bytes:
@@ -20,6 +30,49 @@ def export_customers_csv(customers: List[Dict[str, Any]]) -> bytes:
return output.getvalue().encode("utf-8-sig")
def export_customers_xlsx(customers: List[Dict[str, Any]]) -> bytes:
if not HAS_OPENPYXL:
logger.warning("openpyxl not installed, falling back to CSV")
return export_customers_csv(customers)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Customers"
headers = ["姓名", "公司", "国家", "电话", "WhatsApp", "邮箱", "状态", "最后联系"]
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="1890FF", end_color="1890FF", fill_type="solid")
for col, h in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=h)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
for row, c in enumerate(customers, 2):
ws.cell(row=row, column=1, value=c.get("name", ""))
ws.cell(row=row, column=2, value=c.get("company", ""))
ws.cell(row=row, column=3, value=c.get("country", ""))
ws.cell(row=row, column=4, value=c.get("phone", ""))
ws.cell(row=row, column=5, value=c.get("whatsapp_id", ""))
ws.cell(row=row, column=6, value=c.get("email", ""))
ws.cell(row=row, column=7, value=c.get("status", ""))
ws.cell(row=row, column=8, value=c.get("last_contact_at", ""))
ws.column_dimensions["A"].width = 20
ws.column_dimensions["B"].width = 25
ws.column_dimensions["C"].width = 15
ws.column_dimensions["D"].width = 18
ws.column_dimensions["E"].width = 18
ws.column_dimensions["F"].width = 30
ws.column_dimensions["G"].width = 12
ws.column_dimensions["H"].width = 20
output = io.BytesIO()
wb.save(output)
return output.getvalue()
def export_quotations_csv(quotations: List[Dict[str, Any]]) -> bytes:
output = io.StringIO()
writer = csv.writer(output)
@@ -34,4 +87,106 @@ def export_quotations_csv(quotations: List[Dict[str, Any]]) -> bytes:
q.get("status", ""),
q.get("created_at", ""),
])
return output.getvalue().encode("utf-8-sig")
return output.getvalue().encode("utf-8-sig")
def export_quotations_xlsx(quotations: List[Dict[str, Any]]) -> bytes:
if not HAS_OPENPYXL:
logger.warning("openpyxl not installed, falling back to CSV")
return export_quotations_csv(quotations)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Quotations"
headers = ["标题", "客户", "货币", "小计", "总计", "状态", "日期"]
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="722ED1", end_color="722ED1", fill_type="solid")
for col, h in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=h)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
for row, q in enumerate(quotations, 2):
ws.cell(row=row, column=1, value=q.get("title", ""))
ws.cell(row=row, column=2, value=q.get("customer_name", ""))
ws.cell(row=row, column=3, value=q.get("currency", "USD"))
ws.cell(row=row, column=4, value=float(q.get("subtotal", 0)))
ws.cell(row=row, column=5, value=float(q.get("total", 0)))
ws.cell(row=row, column=6, value=q.get("status", ""))
ws.cell(row=row, column=7, value=str(q.get("created_at", "")))
ws.column_dimensions["A"].width = 30
ws.column_dimensions["B"].width = 20
ws.column_dimensions["C"].width = 10
ws.column_dimensions["D"].width = 15
ws.column_dimensions["E"].width = 15
ws.column_dimensions["F"].width = 12
ws.column_dimensions["G"].width = 20
output = io.BytesIO()
wb.save(output)
return output.getvalue()
def export_products_csv(products: List[Dict[str, Any]]) -> bytes:
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["名称", "英文名", "分类", "描述", "价格", "货币", "MOQ", "关键词"])
for p in products:
writer.writerow([
p.get("name", ""),
p.get("name_en", ""),
p.get("category", ""),
p.get("description", ""),
p.get("price", ""),
p.get("price_unit", "USD"),
p.get("moq", ""),
", ".join(p.get("keywords", [])),
])
return output.getvalue().encode("utf-8-sig")
def export_products_xlsx(products: List[Dict[str, Any]]) -> bytes:
if not HAS_OPENPYXL:
logger.warning("openpyxl not installed, falling back to CSV")
return export_products_csv(products)
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Products"
headers = ["名称", "英文名", "分类", "描述", "价格", "货币", "MOQ", "关键词"]
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="07C160", end_color="07C160", fill_type="solid")
for col, h in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=h)
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center")
for row, p in enumerate(products, 2):
ws.cell(row=row, column=1, value=p.get("name", ""))
ws.cell(row=row, column=2, value=p.get("name_en", ""))
ws.cell(row=row, column=3, value=p.get("category", ""))
ws.cell(row=row, column=4, value=p.get("description", ""))
ws.cell(row=row, column=5, value=p.get("price", ""))
ws.cell(row=row, column=6, value=p.get("price_unit", "USD"))
ws.cell(row=row, column=7, value=p.get("moq", ""))
ws.cell(row=row, column=8, value=", ".join(p.get("keywords", [])))
ws.column_dimensions["A"].width = 20
ws.column_dimensions["B"].width = 20
ws.column_dimensions["C"].width = 15
ws.column_dimensions["D"].width = 40
ws.column_dimensions["E"].width = 12
ws.column_dimensions["F"].width = 10
ws.column_dimensions["G"].width = 12
ws.column_dimensions["H"].width = 30
output = io.BytesIO()
wb.save(output)
return output.getvalue()