diff --git a/PROGRESS.md b/PROGRESS.md index aa681fa..e9ba9ec 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,7 +1,7 @@ # TradeMate (外贸小助手) - 项目进度文档 -**更新时间**: 2026-05-13 12:00 -**状态**: ✅ CORS/API 500 修复 + 自定义 tabbar 升级(emoji 正常渲染) - 所有功能正常 +**更新时间**: 2026-05-18 20:00 +**状态**: ✅ 管理后台完整可用 + 微信登录配置就绪 + 提取信息结构化展示 --- @@ -140,6 +140,30 @@ | 升级自定义 tabbar | `pages.json` custom: false → true | 切回自定义 tabbar 支持 emoji 图标 | | 修复 emoji 渲染 | `custom-tab-bar/index.vue` | `line-height: 1` → `1.5`,追加 emoji 字体族 `Apple Color Emoji, Segoe UI Emoji, Noto Color Emoji` | +### 9. 管理后台完整可用 + 注册登录日志 + +| 功能 | 文件 | 说明 | +|------|------|------| +| 用户管理(列表/搜索/改套餐/改角色/启用禁用) | `admin.py` + `admin.vue` | 全部对接真实 API | +| 使用统计 | `admin.py` | 查询 `usage_logs` 表,含今日各功能调用 + 7日趋势 | +| 操作日志 | `admin.py` | 带筛选器(动作/用户ID/日期范围)+ 分页 | +| 系统配置 | `admin.vue` | 卡片表单(input/switch/textarea),按配置项逐字段编辑 | +| 注册/登录记日志 | `auth.py` | `user.register`/`user.login`/`user.login_guest`/`user.wechat_login` 写入 `usage_logs` | +| 管理后台搜索按钮反馈 | `admin.vue` | 按钮加载态 "搜索中..." + 结果数 toast | + +### 10. 提取信息结果结构化展示 + +| 文件 | 改前 | 改后 | +|------|------|------| +| `translate.vue` | 显示原始 JSON | 卡片式字段列表(字段名中文,如"产品名称""数量") | +| `index.vue` | 显示原始 JSON | 同上 | + +### 11. 微信静默登录配置 + +`.env` 已写入 `WECHAT_APP_ID`/`WECHAT_APP_SECRET`,前端 `login.vue` 已内置: +- **微信小程序**:`uni.login()` → code → `/auth/wechat-login` +- **H5 微信浏览器**:公众号 OAuth `snsapi_base` 静默授权 + ### 8. 首页快捷入口重新设计 底部导航已有"翻译、客户、营销、报价",首页快捷入口原先完全重复。重新设计为从"更多功能"区提取最高频功能: @@ -157,6 +181,9 @@ ## 三、待办事项 +### 中优先级 +1. 管理后台统计/日志页有数据验证(目前 `usage_logs` 为空,显示暂无数据) + ### 低优先级 1. 测试 WhatsApp 集成 2. 性能优化测试 @@ -185,6 +212,7 @@ | 2026-05-13 | 修复 CORS + API 500 根因:游客 UUID 格式、Vite proxy 替代直连、CORS 配置修正 | | 2026-05-13 | 自定义 tabbar 升级:切回 `custom: true`,修复 emoji `line-height` 和字体族 | | 2026-05-13 | 首页快捷入口重新设计:产品库/跟进/数据/通知,替换原有重复项 | +| 2026-05-18 | 管理后台完整可用(用户/统计/日志/配置)+ 注册登录记日志 + 提取信息结构化展示 + 微信登录配置就绪 | --- diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index 57b967b..ee44390 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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}), diff --git a/backend/app/api/v1/customer.py b/backend/app/api/v1/customer.py index 6bf3089..227b228 100644 --- a/backend/app/api/v1/customer.py +++ b/backend/app/api/v1/customer.py @@ -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"}, + ) + diff --git a/backend/app/api/v1/product.py b/backend/app/api/v1/product.py index 2b2a1bc..ab2e9b2 100644 --- a/backend/app/api/v1/product.py +++ b/backend/app/api/v1/product.py @@ -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, diff --git a/backend/app/api/v1/quotation.py b/backend/app/api/v1/quotation.py index 9e63ccb..bc5addc 100644 --- a/backend/app/api/v1/quotation.py +++ b/backend/app/api/v1/quotation.py @@ -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, diff --git a/backend/app/services/export.py b/backend/app/services/export.py index 39e637e..71b15c9 100644 --- a/backend/app/services/export.py +++ b/backend/app/services/export.py @@ -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") \ No newline at end of file + 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() \ No newline at end of file diff --git a/uni-app/src/pages/admin/admin.vue b/uni-app/src/pages/admin/admin.vue index f7c1480..2b2209b 100644 --- a/uni-app/src/pages/admin/admin.vue +++ b/uni-app/src/pages/admin/admin.vue @@ -59,7 +59,7 @@ - 搜索 + {{ searching ? '搜索中...' : '搜索' }} @@ -201,13 +201,30 @@ - - - {{ cfg.key }} - {{ cfg.description }} + + + {{ configLabels[cfg.key] || cfg.key }} + {{ cfg.description }} - -