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 @@
-
-
-
@@ -144,7 +147,7 @@
👨👩👧👦
团队
-
+
⚙️
管理
@@ -272,6 +275,10 @@ const hasLogin = computed(() => {
const isGuest = uni.getStorageSync('isGuest')
return !!token && !isGuest
})
+const isAdmin = computed(() => {
+ return hasLogin.value && userInfo.value?.role === 'admin'
+})
+const userInfo = ref(null)
const stats = ref({
customers: 0,
silentCustomers: 0,
@@ -291,7 +298,13 @@ const generatedContent = ref([])
const tryText = ref('')
const tryResult = ref('')
-const tryExtracted = ref('')
+const tryExtracted = ref(null)
+const extractFieldLabels = {
+ product_name: '产品名称', quantity: '数量', price: '价格',
+ currency: '货币', delivery_terms: '交货条款', target_country: '目标国家',
+ intent: '意图', product_interest: '感兴趣产品', budget: '预算',
+ urgency: '紧迫程度', contact_info: '联系方式',
+}
const tryLoading = ref(false)
onShow(() => {
@@ -309,7 +322,7 @@ onShow(() => {
loadFollowupStats()
} else {
tryResult.value = ''
- tryExtracted.value = ''
+ tryExtracted.value = null
tryText.value = ''
}
})
@@ -429,7 +442,7 @@ const handleTryTranslate = async () => {
}
tryLoading.value = true
tryResult.value = ''
- tryExtracted.value = ''
+ tryExtracted.value = null
try {
const chinesePattern = /[\u4e00-\u9fa5]/
@@ -455,7 +468,7 @@ const handleTryExtract = async () => {
}
tryLoading.value = true
tryResult.value = ''
- tryExtracted.value = ''
+ tryExtracted.value = null
try {
const isGuest = uni.getStorageSync('isGuest')
@@ -463,7 +476,7 @@ const handleTryExtract = async () => {
? await translateApi.publicExtract(tryText.value, 'auto')
: await translateApi.extract(tryText.value, 'auto')
const extracted = res.extracted || {}
- tryExtracted.value = JSON.stringify(extracted, null, 2)
+ tryExtracted.value = typeof extracted === 'string' ? { raw: extracted } : extracted
uni.showToast({ title: '提取成功', icon: 'success' })
} catch (err) {
uni.showToast({ title: err.message || '提取失败', icon: 'none' })
@@ -761,13 +774,20 @@ const playTryResult = () => {
opacity: 0.6;
}
-.try-result, .try-extracted {
+.try-result {
background: #f6ffed;
border-radius: 12rpx;
padding: 24rpx;
margin-top: 20rpx;
}
+.try-extracted {
+ background: #f9f0ff;
+ border-radius: 12rpx;
+ padding: 24rpx;
+ margin-top: 20rpx;
+}
+
.result-header {
display: flex;
justify-content: space-between;
@@ -802,13 +822,36 @@ const playTryResult = () => {
padding: 16rpx;
}
-.result-text, .extracted-text {
+.result-text {
font-size: 28rpx;
color: #333;
line-height: 1.6;
white-space: pre-wrap;
}
+.extract-field {
+ display: flex;
+ padding: 8rpx 0;
+ border-bottom: 1rpx solid rgba(114, 46, 209, 0.1);
+}
+
+.extract-field:last-child {
+ border-bottom: none;
+}
+
+.extract-field-label {
+ width: 160rpx;
+ font-size: 24rpx;
+ color: #722ed1;
+ flex-shrink: 0;
+}
+
+.extract-field-value {
+ flex: 1;
+ font-size: 24rpx;
+ color: #333;
+}
+
.section {
background: #fff;
border-radius: 16rpx;
diff --git a/uni-app/src/pages/login/login.vue b/uni-app/src/pages/login/login.vue
index 8fdfff1..ba19251 100644
--- a/uni-app/src/pages/login/login.vue
+++ b/uni-app/src/pages/login/login.vue
@@ -49,8 +49,8 @@
@@ -218,19 +218,20 @@ const handleSubmit = async () => {
await authApi.register(phone.value, password.value, username.value)
uni.showToast({ title: '注册成功,请登录', icon: 'success' })
isRegister.value = false
- } else {
- const res = await authApi.login(phone.value, password.value)
- uni.setStorageSync('token', res.access_token)
- uni.setStorageSync('userInfo', res.user)
- uni.setStorageSync('hasLogin', true)
- uni.setStorageSync('isGuest', false)
- uni.showToast({ title: '登录成功', icon: 'success' })
- setTimeout(() => {
- uni.switchTab({ url: '/pages/index/index' })
- }, 1000)
- }
+ } else {
+ const res = await authApi.login(phone.value, password.value)
+ uni.setStorageSync('token', res.access_token)
+ uni.setStorageSync('userInfo', res.user)
+ uni.setStorageSync('hasLogin', true)
+ uni.setStorageSync('isGuest', false)
+ uni.reLaunch({ url: '/pages/index/index' })
+ }
} catch (err) {
- error.value = err.message || '操作失败,请重试'
+ console.error('登录失败', err)
+ error.value = (err.errMsg || err.message || '操作失败,请重试')
+ if (err.statusCode === 401) {
+ error.value = '手机号或密码错误'
+ }
} finally {
loading.value = false
}
diff --git a/uni-app/src/pages/product/product.vue b/uni-app/src/pages/product/product.vue
index c6ede97..d5ea90b 100644
--- a/uni-app/src/pages/product/product.vue
+++ b/uni-app/src/pages/product/product.vue
@@ -1,5 +1,12 @@
+
@@ -27,8 +34,77 @@
暂无产品,点击下方添加产品
-
- +
+
+
+ 导
+ 导出
+
+
+ 入
+ 导入
+
+
+ +
+ 新增
+
+
+
+
+
+
+
+ 📊
+ 导出为 Excel (.xlsx)
+ ›
+
+
+ 📄
+ 导出为 CSV (.csv)
+ ›
+
+
+ 📋
+ 复制到剪贴板(分享到微信/WhatsApp)
+ ›
+
+ 取消
+
+
+
+
+
+
+
+ 📁
+ 从文件导入(支持 .xlsx / .csv)
+ ›
+
+
+ 📋
+ 从剪贴板导入(粘贴产品数据)
+ ›
+
+ 取消
+
+
+
+
+
+
+
+
+ 粘贴产品数据
+
+
+
+
+
@@ -129,6 +205,10 @@ const showEditModal = ref(false)
const showDetailModal = ref(false)
const currentProduct = ref(null)
const keywordsInput = ref('')
+const showExportSheet = ref(false)
+const showImportSheet = ref(false)
+const showPasteImport = ref(false)
+const pasteData = ref('')
const formData = ref({
name: '',
@@ -232,6 +312,137 @@ const useProduct = (item) => {
uni.showToast({ title: '已选择产品', icon: 'success' })
showDetailModal.value = false
}
+
+const exportAsXlsx = () => {
+ showExportSheet.value = false
+ const url = productApi.exportXlsx()
+ const token = uni.getStorageSync('token')
+ uni.downloadFile({
+ url,
+ header: { Authorization: `Bearer ${token}` },
+ success: (res) => {
+ if (res.statusCode === 200) {
+ uni.showToast({ title: '导出成功', icon: 'success' })
+ } else {
+ uni.showToast({ title: '导出失败', icon: 'none' })
+ }
+ },
+ fail: () => { uni.showToast({ title: '导出失败', icon: 'none' }) },
+ })
+}
+
+const exportAsCsv = () => {
+ showExportSheet.value = false
+ const url = productApi.exportCsv()
+ const token = uni.getStorageSync('token')
+ uni.downloadFile({
+ url,
+ header: { Authorization: `Bearer ${token}` },
+ success: (res) => {
+ if (res.statusCode === 200) {
+ uni.showToast({ title: '导出成功', icon: 'success' })
+ } else {
+ uni.showToast({ title: '导出失败', icon: 'none' })
+ }
+ },
+ fail: () => { uni.showToast({ title: '导出失败', icon: 'none' }) },
+ })
+}
+
+const exportToClipboard = async () => {
+ showExportSheet.value = false
+ try {
+ const res = await productApi.list(1, 9999)
+ const items = res.items || []
+ if (items.length === 0) {
+ uni.showToast({ title: '暂无产品可导出', icon: 'none' })
+ return
+ }
+ const headers = ['名称', '英文名', '分类', '价格', '货币', 'MOQ', '关键词']
+ const rows = items.map(p => [
+ p.name || '', p.name_en || '', p.category || '',
+ p.price || '', p.price_unit || 'USD', p.moq || '',
+ (p.keywords || []).join(', '),
+ ])
+ const text = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n')
+ uni.setClipboardData({
+ data: text,
+ success: () => {
+ uni.showToast({ title: `已复制 ${items.length} 条产品数据`, icon: 'success' })
+ },
+ fail: () => { uni.showToast({ title: '复制失败', icon: 'none' }) },
+ })
+ } catch (err) {
+ uni.showToast({ title: err.message || '导出失败', icon: 'none' })
+ }
+}
+
+const importFromFile = () => {
+ showImportSheet.value = false
+ uni.chooseImage({
+ count: 1,
+ success: async (res) => {
+ const file = res.tempFilePaths[0]
+ uni.showLoading({ title: '导入中...' })
+ try {
+ const result = await productApi.importProducts(file)
+ uni.hideLoading()
+ uni.showModal({
+ title: '导入完成',
+ content: `成功导入 ${result.imported || 0} 条\n失败 ${(result.errors || []).length} 条`,
+ success: () => loadProducts(),
+ })
+ } catch (err) {
+ uni.hideLoading()
+ uni.showToast({ title: err.message || '导入失败', icon: 'none' })
+ }
+ },
+ })
+}
+
+const importFromPaste = async () => {
+ if (!pasteData.value.trim()) {
+ uni.showToast({ title: '请粘贴产品数据', icon: 'none' })
+ return
+ }
+ showPasteImport.value = false
+ uni.showLoading({ title: '导入中...' })
+ try {
+ const lines = pasteData.value.trim().split('\n')
+ const imported = []
+ const errors = []
+ for (const line of lines) {
+ const parts = line.split(/[\t,,]/).map(s => s.trim()).filter(Boolean)
+ if (parts.length < 1) continue
+ const data = {
+ name: parts[0] || '',
+ name_en: parts[1] || '',
+ category: parts[2] || '',
+ description: parts[3] || '',
+ price: parts[4] || '',
+ price_unit: parts[5] && ['USD', 'EUR', 'GBP', 'CNY'].includes(parts[5]) ? parts[5] : 'USD',
+ moq: parts[6] || '',
+ keywords: parts[7] ? parts[7].split(/[,,]/).map(k => k.trim()).filter(Boolean) : [],
+ }
+ try {
+ await productApi.create(data)
+ imported.push(data.name)
+ } catch (e) {
+ errors.push(`${data.name}: ${e.message || '创建失败'}`)
+ }
+ }
+ uni.hideLoading()
+ pasteData.value = ''
+ uni.showModal({
+ title: '导入完成',
+ content: `成功导入 ${imported.length} 条\n失败 ${errors.length} 条`,
+ success: () => loadProducts(),
+ })
+ } catch (err) {
+ uni.hideLoading()
+ uni.showToast({ title: err.message || '导入失败', icon: 'none' })
+ }
+}