feat: 管理后台完整可用 + 注册登录记日志 + 提取信息结构化展示 + 微信配置就绪
- 管理后台用户/统计/日志/配置四页签全部对接真实后端API - auth注册/登录/游客/微信登录事件写入usage_logs表 - 提取信息结果从原始JSON改为卡片式字段列表(中文标签) - 管理后台搜索按钮增加加载态和结果数提示 - 配置WECHAT_APP_ID/WECHAT_APP_SECRET - 客户/产品/报价单CRUD页面完整(导出导入批量操作)
This commit is contained in:
+30
-2
@@ -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 | 管理后台完整可用(用户/统计/日志/配置)+ 注册登录记日志 + 提取信息结构化展示 + 微信登录配置就绪 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -59,7 +59,7 @@
|
||||
</view>
|
||||
<view class="search-bar">
|
||||
<input class="search-input" v-model="searchQuery" placeholder="用户名/手机号" @confirm="doSearch" />
|
||||
<text class="search-btn" @click="doSearch">搜索</text>
|
||||
<text class="search-btn" :class="{ loading: searching }" @click="doSearch">{{ searching ? '搜索中...' : '搜索' }}</text>
|
||||
</view>
|
||||
<view class="user-list" v-if="searchResults.length">
|
||||
<view class="user-item" v-for="u in searchResults" :key="u.id" @click="showUserDetail(u.id)">
|
||||
@@ -201,13 +201,30 @@
|
||||
|
||||
<!-- 系统配置 -->
|
||||
<view v-if="tab === 'config'">
|
||||
<view class="section" v-for="cfg in configList" :key="cfg.key">
|
||||
<view class="section-header">
|
||||
<text class="section-title">{{ cfg.key }}</text>
|
||||
<text class="config-desc">{{ cfg.description }}</text>
|
||||
<view class="card" v-for="cfg in configList" :key="cfg.key">
|
||||
<view class="card-header">
|
||||
<text class="card-title">{{ configLabels[cfg.key] || cfg.key }}</text>
|
||||
<text class="card-subtitle">{{ cfg.description }}</text>
|
||||
</view>
|
||||
<view class="config-editor">
|
||||
<textarea class="config-textarea" :value="formatConfigValue(cfg.value)" @input="e => onConfigEdit(cfg.key, e)" />
|
||||
<view class="card-body" v-if="typeof cfg.value === 'object' && !Array.isArray(cfg.value)">
|
||||
<view class="cfg-field" v-for="(v, k) in cfg.value" :key="k">
|
||||
<text class="cfg-label">{{ fieldLabels(cfg.key, k) }}</text>
|
||||
<input v-if="typeof v === 'string'" class="cfg-input" :value="v" @input="e => onFieldEdit(cfg.key, k, 'string', e)" />
|
||||
<input v-else-if="typeof v === 'number'" class="cfg-input" type="number" :value="String(v)" @input="e => onFieldEdit(cfg.key, k, 'number', e)" />
|
||||
<switch v-else-if="typeof v === 'boolean'" :checked="v" @change="e => onFieldEdit(cfg.key, k, 'boolean', e)" />
|
||||
<textarea v-else class="cfg-textarea" :value="JSON.stringify(v)" @input="e => onFieldEdit(cfg.key, k, 'json', e)" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-body" v-else-if="typeof cfg.value === 'boolean'">
|
||||
<view class="cfg-field">
|
||||
<text class="cfg-label">启用</text>
|
||||
<switch :checked="cfg.value" @change="e => onSimpleEdit(cfg.key, 'boolean', e)" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-body" v-else>
|
||||
<textarea class="cfg-textarea" :value="String(cfg.value)" @input="e => onSimpleEdit(cfg.key, 'string', e)" />
|
||||
</view>
|
||||
<view class="card-footer">
|
||||
<text class="save-btn" @click="saveConfig(cfg.key)">保存</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -268,6 +285,35 @@ const logFilter = ref({ action: '', user_id: '', date_from: '', date_to: '' })
|
||||
|
||||
const configList = ref([])
|
||||
const configEdits = ref({})
|
||||
|
||||
const configLabels = {
|
||||
ai_provider_translate: '翻译 AI 模型',
|
||||
ai_provider_reply: '回复建议 AI 模型',
|
||||
ai_provider_marketing: '营销文案 AI 模型',
|
||||
ai_provider_extract: '信息提取 AI 模型',
|
||||
ai_provider_quotation: '报价单 AI 模型',
|
||||
feature_guest_mode: '游客模式',
|
||||
feature_wechat_login: '微信登录',
|
||||
feature_registration: '新用户注册',
|
||||
free_daily_limits: '免费版每日配额',
|
||||
pro_daily_limits: 'Pro 版每日配额',
|
||||
}
|
||||
|
||||
const fieldLabels = (configKey, fieldKey) => {
|
||||
const map = {
|
||||
ai_provider_translate: { primary: '主模型', fallback: '备用模型' },
|
||||
ai_provider_reply: { primary: '主模型', fallback: '备用模型' },
|
||||
ai_provider_marketing: { primary: '主模型', fallback: '备用模型' },
|
||||
ai_provider_extract: { primary: '主模型', fallback: '备用模型' },
|
||||
ai_provider_quotation: { primary: '主模型', fallback: '备用模型' },
|
||||
feature_guest_mode: { enabled: '允许游客模式' },
|
||||
feature_wechat_login: { enabled: '允许微信登录' },
|
||||
feature_registration: { enabled: '开放注册' },
|
||||
free_daily_limits: { translate_chars: '翻译字符数', replies: '智能回复次数', marketing: '营销文案生成', customers: '客户数', products: '产品数', quotations: '报价单数' },
|
||||
pro_daily_limits: { translate_chars: '翻译字符数', replies: '智能回复次数', marketing: '营销文案生成', customers: '客户数', products: '产品数', quotations: '报价单数' },
|
||||
}
|
||||
return map[configKey]?.[fieldKey] || fieldKey
|
||||
}
|
||||
const userDetail = ref(null)
|
||||
|
||||
const userTotalPages = computed(() => Math.ceil(userTotal.value / userPageSize) || 1)
|
||||
@@ -297,10 +343,19 @@ const changeUserPage = (page) => {
|
||||
|
||||
const doSearch = async () => {
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q) return
|
||||
if (!q) {
|
||||
uni.showToast({ title: '请输入用户名或手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
searching.value = true
|
||||
try {
|
||||
searchResults.value = await adminApi.searchUsers(q)
|
||||
const results = await adminApi.searchUsers(q)
|
||||
searchResults.value = results
|
||||
if (results.length) {
|
||||
uni.showToast({ title: `找到 ${results.length} 个用户`, icon: 'success' })
|
||||
} else {
|
||||
uni.showToast({ title: '无匹配用户', icon: 'none' })
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '搜索失败', icon: 'none' })
|
||||
} finally {
|
||||
@@ -348,7 +403,7 @@ const showUserDetail = async (userId) => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (t) => t ? t.split('T')[0] : ''
|
||||
const formatTime = (t) => t ? t.slice(0, 16).replace('T', ' ') : ''
|
||||
const formatShortDate = (d) => d ? d.slice(5) : ''
|
||||
|
||||
const maxActionCount = ref(1)
|
||||
@@ -404,29 +459,30 @@ const loadConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatConfigValue = (val) => {
|
||||
if (typeof val === 'object') return JSON.stringify(val, null, 2)
|
||||
return String(val)
|
||||
const onFieldEdit = (configKey, fieldKey, type, e) => {
|
||||
if (!configEdits.value[configKey]) configEdits.value[configKey] = {}
|
||||
const val = e.detail ? e.detail.value : e
|
||||
configEdits.value[configKey][fieldKey] = type === 'number' ? Number(val) : type === 'boolean' ? val : val
|
||||
}
|
||||
|
||||
const onConfigEdit = (key, e) => {
|
||||
configEdits.value[key] = e.detail.value
|
||||
const onSimpleEdit = (configKey, type, e) => {
|
||||
const val = e.detail ? e.detail.value : e
|
||||
configEdits.value[configKey] = type === 'number' ? Number(val) : type === 'boolean' ? val : val
|
||||
}
|
||||
|
||||
const saveConfig = async (key) => {
|
||||
const raw = configEdits.value[key]
|
||||
if (!raw) {
|
||||
const edit = configEdits.value[key]
|
||||
if (!edit) {
|
||||
uni.showToast({ title: '无改动', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(raw)
|
||||
await adminApi.updateConfig(key, value)
|
||||
await adminApi.updateConfig(key, edit)
|
||||
delete configEdits.value[key]
|
||||
uni.showToast({ title: '已保存', icon: 'success' })
|
||||
loadConfig()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: 'JSON 格式错误或保存失败', icon: 'none' })
|
||||
uni.showToast({ title: err.message || '保存失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +532,7 @@ watch(tab, (val) => {
|
||||
.search-bar { display: flex; gap: 16rpx; margin-bottom: 20rpx; }
|
||||
.search-input { flex: 1; height: 70rpx; background: #f5f5f5; border-radius: 12rpx; padding: 0 20rpx; font-size: 26rpx; }
|
||||
.search-btn { height: 70rpx; line-height: 70rpx; padding: 0 30rpx; background: #667eea; color: #fff; border-radius: 12rpx; font-size: 26rpx; flex-shrink: 0; }
|
||||
.search-btn.loading { opacity: 0.6; }
|
||||
.search-btn.small { height: 56rpx; line-height: 56rpx; padding: 0 20rpx; font-size: 24rpx; }
|
||||
.search-btn.gray { background: #999; }
|
||||
.filter-row { display: flex; gap: 12rpx; margin-bottom: 12rpx; }
|
||||
@@ -494,10 +551,18 @@ watch(tab, (val) => {
|
||||
.log-time { font-size: 22rpx; color: #999; }
|
||||
.log-user, .log-ip { font-size: 22rpx; color: #999; display: block; }
|
||||
.log-detail { font-size: 22rpx; color: #666; margin-top: 6rpx; display: block; word-break: break-all; }
|
||||
.config-editor { display: flex; gap: 12rpx; align-items: flex-start; }
|
||||
.config-textarea { flex: 1; height: 160rpx; background: #f5f5f5; border-radius: 12rpx; padding: 16rpx; font-size: 24rpx; font-family: monospace; }
|
||||
.config-desc { font-size: 22rpx; color: #999; }
|
||||
.save-btn { height: 70rpx; line-height: 70rpx; padding: 0 30rpx; background: #52c41a; color: #fff; border-radius: 12rpx; font-size: 26rpx; flex-shrink: 0; }
|
||||
.card { background: #fff; border-radius: 16rpx; margin-bottom: 24rpx; overflow: hidden; }
|
||||
.card-header { padding: 24rpx 30rpx 0; }
|
||||
.card-title { font-size: 28rpx; font-weight: 600; color: #333; display: block; }
|
||||
.card-subtitle { font-size: 22rpx; color: #999; display: block; margin-top: 4rpx; }
|
||||
.card-body { padding: 20rpx 30rpx; }
|
||||
.card-footer { padding: 0 30rpx 24rpx; display: flex; justify-content: flex-end; }
|
||||
.cfg-field { display: flex; align-items: center; padding: 12rpx 0; border-bottom: 1rpx solid #f5f5f5; }
|
||||
.cfg-field:last-child { border-bottom: none; }
|
||||
.cfg-label { width: 180rpx; font-size: 24rpx; color: #666; flex-shrink: 0; }
|
||||
.cfg-input { flex: 1; height: 64rpx; background: #f5f5f5; border-radius: 8rpx; padding: 0 16rpx; font-size: 26rpx; }
|
||||
.cfg-textarea { flex: 1; min-height: 80rpx; background: #f5f5f5; border-radius: 8rpx; padding: 12rpx 16rpx; font-size: 24rpx; font-family: monospace; }
|
||||
.save-btn { height: 60rpx; line-height: 60rpx; padding: 0 28rpx; background: #52c41a; color: #fff; border-radius: 8rpx; font-size: 24rpx; flex-shrink: 0; }
|
||||
.pagination { display: flex; justify-content: center; align-items: center; gap: 20rpx; margin-top: 20rpx; padding: 20rpx 0; }
|
||||
.page-btn { font-size: 26rpx; padding: 8rpx 24rpx; background: #667eea; color: #fff; border-radius: 8rpx; }
|
||||
.page-btn.disabled { opacity: 0.4; }
|
||||
|
||||
@@ -101,12 +101,12 @@
|
||||
</view>
|
||||
|
||||
<view class="bottom-actions">
|
||||
<view class="action-btn export-btn" @click="exportCsv">
|
||||
<text class="btn-icon">CSV</text>
|
||||
<view class="action-btn export-btn" @click="showExportSheet = true">
|
||||
<text class="btn-icon">导</text>
|
||||
<text class="btn-text">导出</text>
|
||||
</view>
|
||||
<view class="action-btn import-btn" @click="importCustomers">
|
||||
<text class="btn-icon">导</text>
|
||||
<view class="action-btn import-btn" @click="showImportSheet = true">
|
||||
<text class="btn-icon">入</text>
|
||||
<text class="btn-text">导入</text>
|
||||
</view>
|
||||
<view class="action-btn add-btn" @click="showAddModal = true">
|
||||
@@ -280,6 +280,64 @@
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-sheet-overlay" v-if="showExportSheet" @click="showExportSheet = false">
|
||||
<view class="action-sheet" @click.stop>
|
||||
<view class="action-sheet-header">导出客户</view>
|
||||
<view class="action-sheet-item" @click="exportAsXlsx">
|
||||
<text class="action-sheet-icon">📊</text>
|
||||
<text class="action-sheet-text">导出为 Excel (.xlsx)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="exportAsCsv">
|
||||
<text class="action-sheet-icon">📄</text>
|
||||
<text class="action-sheet-text">导出为 CSV (.csv)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="exportToClipboard">
|
||||
<text class="action-sheet-icon">📋</text>
|
||||
<text class="action-sheet-text">复制到剪贴板(分享到微信/WhatsApp)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-cancel" @click="showExportSheet = false">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-sheet-overlay" v-if="showImportSheet" @click="showImportSheet = false">
|
||||
<view class="action-sheet" @click.stop>
|
||||
<view class="action-sheet-header">导入客户</view>
|
||||
<view class="action-sheet-item" @click="importFromFile">
|
||||
<text class="action-sheet-icon">📁</text>
|
||||
<text class="action-sheet-text">从文件导入(支持 .xlsx / .csv)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="showPasteImport = true; showImportSheet = false">
|
||||
<text class="action-sheet-icon">📋</text>
|
||||
<text class="action-sheet-text">从剪贴板导入(粘贴客户数据)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-cancel" @click="showImportSheet = false">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal" v-if="showPasteImport" @click="showPasteImport = false">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">从剪贴板导入</text>
|
||||
<text class="modal-close" @click="showPasteImport = false">×</text>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="form-label">粘贴客户数据</text>
|
||||
<textarea class="form-textarea" v-model="pasteData" placeholder="每行一个客户,用逗号或Tab分隔 格式: 姓名,公司,国家,电话,邮箱,状态" rows="6" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<button class="cancel-btn" @click="showPasteImport = false">取消</button>
|
||||
<button class="submit-btn" @click="importFromPaste">导入</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -311,6 +369,11 @@ const formData = ref({
|
||||
status: 'lead',
|
||||
})
|
||||
|
||||
const showExportSheet = ref(false)
|
||||
const showImportSheet = ref(false)
|
||||
const showPasteImport = ref(false)
|
||||
const pasteData = ref('')
|
||||
|
||||
const statusOptions = ['lead', 'negotiating', 'customer', 'lost']
|
||||
|
||||
onShow(() => {
|
||||
@@ -392,7 +455,7 @@ const getStatusText = (status) => {
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return ''
|
||||
return time.split('T')[0]
|
||||
return time.slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
const showCustomerDetail = async (item) => {
|
||||
@@ -463,7 +526,26 @@ const submitCustomer = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const exportCsv = () => {
|
||||
const exportAsXlsx = () => {
|
||||
showExportSheet.value = false
|
||||
const url = customerApi.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 = customerApi.exportCsv()
|
||||
const token = uni.getStorageSync('token')
|
||||
uni.downloadFile({
|
||||
@@ -480,7 +562,35 @@ const exportCsv = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const importCustomers = () => {
|
||||
const exportToClipboard = async () => {
|
||||
showExportSheet.value = false
|
||||
try {
|
||||
const res = await customerApi.list(1, 9999)
|
||||
const items = res.items || []
|
||||
if (items.length === 0) {
|
||||
uni.showToast({ title: '暂无客户可导出', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const headers = ['姓名', '公司', '国家', '电话', '邮箱', '状态', '最后联系']
|
||||
const rows = items.map(c => [
|
||||
c.name || '', c.company || '', c.country || '', c.phone || '',
|
||||
c.email || '', c.status || '', c.last_contact_at ? c.last_contact_at.slice(0, 16).replace('T', ' ') : '',
|
||||
])
|
||||
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) => {
|
||||
@@ -502,6 +612,48 @@ const importCustomers = () => {
|
||||
})
|
||||
}
|
||||
|
||||
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] || '',
|
||||
company: parts[1] || '',
|
||||
country: parts[2] || '',
|
||||
phone: parts[3] || '',
|
||||
email: parts[4] || '',
|
||||
status: parts[5] && ['lead', 'negotiating', 'customer', 'lost'].includes(parts[5]) ? parts[5] : 'lead',
|
||||
}
|
||||
try {
|
||||
await customerApi.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: () => loadCustomers(),
|
||||
})
|
||||
} catch (err) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: err.message || '导入失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const generateWhatsAppReply = async () => {
|
||||
if (!currentCustomer.value?.whatsapp_id) return
|
||||
const lastMsg = conversation.value.length > 0 ? conversation.value[conversation.value.length - 1].content : ''
|
||||
@@ -1211,4 +1363,73 @@ const deleteCustomer = async (id) => {
|
||||
background: #25d366;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-sheet-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.action-sheet {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 0 0 60rpx;
|
||||
}
|
||||
|
||||
.action-sheet-header {
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
padding: 30rpx;
|
||||
border-bottom: 2rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.action-sheet-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.action-sheet-icon {
|
||||
font-size: 36rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.action-sheet-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.action-sheet-arrow {
|
||||
font-size: 32rpx;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.action-sheet-cancel {
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
padding: 24rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
height: 240rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
padding: 20rpx;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -71,12 +71,15 @@
|
||||
<text class="result-text">{{ tryResult }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="try-extracted" v-if="tryExtracted">
|
||||
<view class="try-extracted" v-if="tryExtracted && Object.keys(tryExtracted).length">
|
||||
<view class="result-header">
|
||||
<text class="result-label">提取结果</text>
|
||||
</view>
|
||||
<view class="extracted-content">
|
||||
<text class="extracted-text">{{ tryExtracted }}</text>
|
||||
<view class="extract-field" v-for="(val, key) in tryExtracted" :key="key">
|
||||
<text class="extract-field-label">{{ extractFieldLabels[key] || key }}</text>
|
||||
<text class="extract-field-value">{{ val || '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -144,7 +147,7 @@
|
||||
<text class="more-icon">👨👩👧👦</text>
|
||||
<text class="more-text">团队</text>
|
||||
</view>
|
||||
<view class="more-item" @click="hasLogin ? goToPage('/pages/admin/admin') : goToLogin()">
|
||||
<view class="more-item" v-if="isAdmin" @click="goToPage('/pages/admin/admin')">
|
||||
<text class="more-icon">⚙️</text>
|
||||
<text class="more-text">管理</text>
|
||||
</view>
|
||||
@@ -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;
|
||||
|
||||
@@ -49,8 +49,8 @@
|
||||
<view class="input-group">
|
||||
<input
|
||||
class="input"
|
||||
type="number"
|
||||
placeholder="手机号"
|
||||
type="text"
|
||||
placeholder="手机号 / 用户名"
|
||||
v-model="phone"
|
||||
/>
|
||||
</view>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<template>
|
||||
<view class="product-container">
|
||||
<view class="product-list-header">
|
||||
<text class="header-col col-name">名称</text>
|
||||
<text class="header-col col-category">分类</text>
|
||||
<text class="header-col col-price">价格</text>
|
||||
<text class="header-col col-moq">MOQ</text>
|
||||
<text class="header-col col-actions">操作</text>
|
||||
</view>
|
||||
<view class="product-list" v-if="products.length > 0">
|
||||
<view class="product-item" v-for="item in products" :key="item.id" @click="showDetail(item)">
|
||||
<view class="product-info">
|
||||
@@ -27,8 +34,77 @@
|
||||
<text>暂无产品,点击下方添加产品</text>
|
||||
</view>
|
||||
|
||||
<view class="add-btn" @click="showAddModal = true">
|
||||
<text class="add-icon">+</text>
|
||||
<view class="bottom-actions">
|
||||
<view class="action-btn export-btn" @click="showExportSheet = true">
|
||||
<text class="btn-icon">导</text>
|
||||
<text class="btn-text">导出</text>
|
||||
</view>
|
||||
<view class="action-btn import-btn" @click="showImportSheet = true">
|
||||
<text class="btn-icon">入</text>
|
||||
<text class="btn-text">导入</text>
|
||||
</view>
|
||||
<view class="action-btn add-btn" @click="showAddModal = true">
|
||||
<text class="btn-icon">+</text>
|
||||
<text class="btn-text">新增</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-sheet-overlay" v-if="showExportSheet" @click="showExportSheet = false">
|
||||
<view class="action-sheet" @click.stop>
|
||||
<view class="action-sheet-header">导出产品</view>
|
||||
<view class="action-sheet-item" @click="exportAsXlsx">
|
||||
<text class="action-sheet-icon">📊</text>
|
||||
<text class="action-sheet-text">导出为 Excel (.xlsx)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="exportAsCsv">
|
||||
<text class="action-sheet-icon">📄</text>
|
||||
<text class="action-sheet-text">导出为 CSV (.csv)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="exportToClipboard">
|
||||
<text class="action-sheet-icon">📋</text>
|
||||
<text class="action-sheet-text">复制到剪贴板(分享到微信/WhatsApp)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-cancel" @click="showExportSheet = false">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-sheet-overlay" v-if="showImportSheet" @click="showImportSheet = false">
|
||||
<view class="action-sheet" @click.stop>
|
||||
<view class="action-sheet-header">导入产品</view>
|
||||
<view class="action-sheet-item" @click="importFromFile">
|
||||
<text class="action-sheet-icon">📁</text>
|
||||
<text class="action-sheet-text">从文件导入(支持 .xlsx / .csv)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="showPasteImport = true; showImportSheet = false">
|
||||
<text class="action-sheet-icon">📋</text>
|
||||
<text class="action-sheet-text">从剪贴板导入(粘贴产品数据)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-cancel" @click="showImportSheet = false">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal" v-if="showPasteImport" @click="showPasteImport = false">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">从剪贴板导入</text>
|
||||
<text class="modal-close" @click="showPasteImport = false">×</text>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="form-label">粘贴产品数据</text>
|
||||
<textarea class="form-textarea" v-model="pasteData" placeholder="每行一个产品,用逗号或Tab分隔 格式: 名称,英文名,分类,描述,价格,货币,MOQ,关键词" rows="6" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<button class="cancel-btn" @click="showPasteImport = false">取消</button>
|
||||
<button class="submit-btn" @click="importFromPaste">导入</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal" v-if="showAddModal || showEditModal" @click="closeModal">
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
@@ -252,6 +463,28 @@ export default {
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.product-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx 24rpx;
|
||||
background: #fff;
|
||||
border-radius: 12rpx 12rpx 0 0;
|
||||
margin-bottom: 2rpx;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.header-col {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.col-name { flex: 1; }
|
||||
.col-category { width: 120rpx; }
|
||||
.col-price { width: 150rpx; }
|
||||
.col-moq { width: 120rpx; }
|
||||
.col-actions { width: 120rpx; text-align: center; }
|
||||
|
||||
.product-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -353,23 +586,107 @@ export default {
|
||||
padding: 100rpx;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: 40rpx;
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
background: #1890ff;
|
||||
border-radius: 50%;
|
||||
bottom: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.4);
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 60rpx;
|
||||
.action-btn {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.import-btn {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background: #07c160;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 32rpx;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 18rpx;
|
||||
color: #fff;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.action-sheet-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.action-sheet {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 0 0 60rpx;
|
||||
}
|
||||
|
||||
.action-sheet-header {
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
padding: 30rpx;
|
||||
border-bottom: 2rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.action-sheet-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.action-sheet-icon {
|
||||
font-size: 36rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.action-sheet-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.action-sheet-arrow {
|
||||
font-size: 32rpx;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.action-sheet-cancel {
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
padding: 24rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.modal {
|
||||
|
||||
@@ -54,11 +54,77 @@
|
||||
<text>暂无报价单</text>
|
||||
</view>
|
||||
|
||||
<view class="export-csv-btn" @click="exportCsv">
|
||||
<text class="export-icon">CSV</text>
|
||||
<view class="bottom-actions">
|
||||
<view class="action-btn export-btn" @click="showExportSheet = true">
|
||||
<text class="btn-icon">导</text>
|
||||
<text class="btn-text">导出</text>
|
||||
</view>
|
||||
<view class="action-btn import-btn" @click="showImportSheet = true">
|
||||
<text class="btn-icon">入</text>
|
||||
<text class="btn-text">导入</text>
|
||||
</view>
|
||||
<view class="action-btn add-btn" @click="showCreateModal = true">
|
||||
<text class="btn-icon">+</text>
|
||||
<text class="btn-text">新增</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="add-btn" @click="showCreateModal = true">
|
||||
<text class="add-icon">+</text>
|
||||
|
||||
<view class="action-sheet-overlay" v-if="showExportSheet" @click="showExportSheet = false">
|
||||
<view class="action-sheet" @click.stop>
|
||||
<view class="action-sheet-header">导出报价单</view>
|
||||
<view class="action-sheet-item" @click="exportAsXlsx">
|
||||
<text class="action-sheet-icon">📊</text>
|
||||
<text class="action-sheet-text">导出为 Excel (.xlsx)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="exportAsCsv">
|
||||
<text class="action-sheet-icon">📄</text>
|
||||
<text class="action-sheet-text">导出为 CSV (.csv)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="exportToClipboard">
|
||||
<text class="action-sheet-icon">📋</text>
|
||||
<text class="action-sheet-text">复制到剪贴板(分享到微信/WhatsApp)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-cancel" @click="showExportSheet = false">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="action-sheet-overlay" v-if="showImportSheet" @click="showImportSheet = false">
|
||||
<view class="action-sheet" @click.stop>
|
||||
<view class="action-sheet-header">导入报价单</view>
|
||||
<view class="action-sheet-item" @click="importFromFile">
|
||||
<text class="action-sheet-icon">📁</text>
|
||||
<text class="action-sheet-text">从文件导入(支持 .xlsx / .csv)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-item" @click="showPasteImport = true; showImportSheet = false">
|
||||
<text class="action-sheet-icon">📋</text>
|
||||
<text class="action-sheet-text">从剪贴板导入(粘贴报价数据)</text>
|
||||
<text class="action-sheet-arrow">›</text>
|
||||
</view>
|
||||
<view class="action-sheet-cancel" @click="showImportSheet = false">取消</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal" v-if="showPasteImport" @click="showPasteImport = false">
|
||||
<view class="modal-content" @click.stop>
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">从剪贴板导入</text>
|
||||
<text class="modal-close" @click="showPasteImport = false">×</text>
|
||||
</view>
|
||||
<view class="modal-body">
|
||||
<view class="form-item">
|
||||
<text class="form-label">粘贴报价数据</text>
|
||||
<textarea class="form-textarea" v-model="pasteData" placeholder="每行一个报价单,用逗号或Tab分隔 格式: 标题,客户ID,货币,金额,状态" rows="6" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-footer">
|
||||
<button class="cancel-btn" @click="showPasteImport = false">取消</button>
|
||||
<button class="submit-btn" @click="importFromPaste">导入</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="modal" v-if="showCreateModal" @click="closeModal">
|
||||
@@ -211,6 +277,11 @@ const formData = ref({
|
||||
|
||||
const customerOptions = ref([])
|
||||
|
||||
const showExportSheet = ref(false)
|
||||
const showImportSheet = ref(false)
|
||||
const showPasteImport = ref(false)
|
||||
const pasteData = ref('')
|
||||
|
||||
onShow(() => {
|
||||
loadQuotations()
|
||||
loadCustomers()
|
||||
@@ -221,7 +292,7 @@ const loadQuotations = async () => {
|
||||
const res = await quotationApi.list()
|
||||
quotations.value = res.items || []
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
|
||||
console.error('加载报价单失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +313,7 @@ const getStatusText = (status) => {
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return ''
|
||||
return time.split('T')[0]
|
||||
return time.slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
|
||||
const getCustomerName = (id) => {
|
||||
@@ -355,7 +426,26 @@ const generateSmartQuote = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const exportCsv = () => {
|
||||
const exportAsXlsx = () => {
|
||||
showExportSheet.value = false
|
||||
const url = quotationApi.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 = quotationApi.exportCsv()
|
||||
const token = uni.getStorageSync('token')
|
||||
uni.downloadFile({
|
||||
@@ -372,6 +462,97 @@ const exportCsv = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const exportToClipboard = async () => {
|
||||
showExportSheet.value = false
|
||||
try {
|
||||
const res = await quotationApi.list(1, 9999)
|
||||
const items = res.items || []
|
||||
if (items.length === 0) {
|
||||
uni.showToast({ title: '暂无报价单可导出', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const headers = ['标题', '客户', '货币', '总计', '状态', '日期']
|
||||
const rows = items.map(q => [
|
||||
q.title || '', q.customer_name || '', q.currency || 'USD',
|
||||
q.total || 0, q.status || '', q.created_at ? q.created_at.slice(0, 16).replace('T', ' ') : '',
|
||||
])
|
||||
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 quotationApi.importQuotations(file)
|
||||
uni.hideLoading()
|
||||
uni.showModal({
|
||||
title: '导入完成',
|
||||
content: `成功导入 ${result.imported || 0} 条\n失败 ${(result.errors || []).length} 条`,
|
||||
success: () => loadQuotations(),
|
||||
})
|
||||
} 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 = {
|
||||
title: parts[0] || '',
|
||||
customer_id: parts[1] || '',
|
||||
currency: parts[2] && ['USD', 'EUR', 'GBP', 'CNY'].includes(parts[2]) ? parts[2] : 'USD',
|
||||
items: [{ product_name: 'Imported Item', quantity: 1, unit_price: parseFloat(parts[3]) || 0 }],
|
||||
status: parts[4] && ['draft', 'sent', 'accepted', 'rejected'].includes(parts[4]) ? parts[4] : 'draft',
|
||||
}
|
||||
try {
|
||||
await quotationApi.create(data)
|
||||
imported.push(data.title)
|
||||
} catch (e) {
|
||||
errors.push(`${data.title}: ${e.message || '创建失败'}`)
|
||||
}
|
||||
}
|
||||
uni.hideLoading()
|
||||
pasteData.value = ''
|
||||
uni.showModal({
|
||||
title: '导入完成',
|
||||
content: `成功导入 ${imported.length} 条\n失败 ${errors.length} 条`,
|
||||
success: () => loadQuotations(),
|
||||
})
|
||||
} catch (err) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: err.message || '导入失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const exportPdf = (item) => {
|
||||
const url = quotationApi.exportPdf(item.id)
|
||||
uni.downloadFile({
|
||||
@@ -500,38 +681,107 @@ const exportPdf = (item) => {
|
||||
padding: 100rpx;
|
||||
}
|
||||
|
||||
.export-csv-btn {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: calc(100px + 100rpx + 24rpx);
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
background: #722ed1;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(114, 46, 209, 0.4);
|
||||
}
|
||||
|
||||
.export-icon {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
.bottom-actions {
|
||||
position: fixed;
|
||||
right: 40rpx;
|
||||
bottom: 100px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 100rpx;
|
||||
height: 100rpx;
|
||||
background: #1890ff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.4);
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #1890ff;
|
||||
}
|
||||
|
||||
.import-btn {
|
||||
background: #52c41a;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
background: #722ed1;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
font-size: 32rpx;
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 18rpx;
|
||||
color: #fff;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
|
||||
.action-sheet-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.action-sheet {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 0 0 60rpx;
|
||||
}
|
||||
|
||||
.action-sheet-header {
|
||||
text-align: center;
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
padding: 30rpx;
|
||||
border-bottom: 2rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.action-sheet-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 28rpx 32rpx;
|
||||
border-bottom: 2rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.action-sheet-icon {
|
||||
font-size: 36rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.action-sheet-text {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.action-sheet-arrow {
|
||||
font-size: 32rpx;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.action-sheet-cancel {
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
padding: 24rpx;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
|
||||
@@ -64,13 +64,16 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="extract-section" v-if="extractedInfo">
|
||||
<view class="extract-section" v-if="extractedInfo && Object.keys(extractedInfo).length">
|
||||
<view class="extract-header">
|
||||
<text class="extract-label">抽取信息</text>
|
||||
<text class="extract-close" @click="extractedInfo = null">×</text>
|
||||
</view>
|
||||
<view class="extract-content">
|
||||
<text class="extract-text">{{ extractedInfo }}</text>
|
||||
<view class="extract-field" v-for="(val, key) in extractedInfo" :key="key">
|
||||
<text class="extract-field-label">{{ extractFieldLabels[key] || key }}</text>
|
||||
<text class="extract-field-value">{{ val || '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -242,11 +245,18 @@ const playTts = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const extractFieldLabels = {
|
||||
product_name: '产品名称', quantity: '数量', price: '价格',
|
||||
currency: '货币', delivery_terms: '交货条款', target_country: '目标国家',
|
||||
intent: '意图', product_interest: '感兴趣产品', budget: '预算',
|
||||
urgency: '紧迫程度', contact_info: '联系方式',
|
||||
}
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!result.value) return
|
||||
try {
|
||||
const res = await translateApi.extract(result.value)
|
||||
extractedInfo.value = typeof res.extracted === 'string' ? res.extracted : JSON.stringify(res.extracted, null, 2)
|
||||
extractedInfo.value = typeof res.extracted === 'string' ? { raw: res.extracted } : res.extracted
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '抽取失败', icon: 'none' })
|
||||
}
|
||||
@@ -476,15 +486,32 @@ const selectSuggestion = (index) => {
|
||||
}
|
||||
|
||||
.extract-content {
|
||||
padding: 16rpx;
|
||||
padding: 12rpx 16rpx;
|
||||
background: #f9f0ff;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.extract-text {
|
||||
font-size: 26rpx;
|
||||
line-height: 1.5;
|
||||
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;
|
||||
}
|
||||
|
||||
.suggestions-section {
|
||||
|
||||
@@ -98,8 +98,28 @@ export const quotationApi = {
|
||||
updateStatus: (id, status) => request(`/quotations/${id}/status`, 'PATCH', { status }),
|
||||
exportPdf: (id) => `${BASE_URL}/quotations/${id}/pdf`,
|
||||
exportCsv: () => `${BASE_URL}/quotations/export/csv`,
|
||||
exportXlsx: () => `${BASE_URL}/quotations/export/xlsx`,
|
||||
generateFromInquiry: (inquiryText, customerId = null) =>
|
||||
request('/quotations/generate-from-inquiry', 'POST', { inquiry_text: inquiryText, customer_id: customerId }),
|
||||
importQuotations: (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('token')
|
||||
uni.uploadFile({
|
||||
url: `${BASE_URL}/quotations/import`,
|
||||
filePath: file,
|
||||
name: 'file',
|
||||
header: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
success: (res) => {
|
||||
try {
|
||||
resolve(JSON.parse(res.data))
|
||||
} catch (e) {
|
||||
resolve(res.data)
|
||||
}
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const productApi = {
|
||||
@@ -108,6 +128,27 @@ export const productApi = {
|
||||
create: (data) => request('/products', 'POST', data),
|
||||
update: (id, data) => request(`/products/${id}`, 'PATCH', data),
|
||||
delete: (id) => request(`/products/${id}`, 'DELETE'),
|
||||
exportCsv: () => `${BASE_URL}/products/export/csv`,
|
||||
exportXlsx: () => `${BASE_URL}/products/export/xlsx`,
|
||||
importProducts: (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('token')
|
||||
uni.uploadFile({
|
||||
url: `${BASE_URL}/products/import`,
|
||||
filePath: file,
|
||||
name: 'file',
|
||||
header: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
success: (res) => {
|
||||
try {
|
||||
resolve(JSON.parse(res.data))
|
||||
} catch (e) {
|
||||
resolve(res.data)
|
||||
}
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
@@ -261,6 +302,7 @@ export const customerApi = {
|
||||
getConversation: (id, page = 1, size = 50) =>
|
||||
request(`/customers/${id}/conversation?page=${page}&size=${size}`),
|
||||
exportCsv: () => `${BASE_URL}/customers/export/csv`,
|
||||
exportXlsx: () => `${BASE_URL}/customers/export/xlsx`,
|
||||
importCustomers: (file) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const token = uni.getStorageSync('token')
|
||||
|
||||
Reference in New Issue
Block a user