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

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