feat: production branch with deploy config for baota panel

- Add deploy/ directory with production env, supervisor, nginx, migration configs
- Include all latest features: admin management, feedback, footer with ICP/beian
- Database: foreign_trade (PostgreSQL), user: foreign_trade
- Frontend: trade.yuzhiran.com, backend proxy via Nginx
This commit is contained in:
TradeMate Dev
2026-05-14 09:19:30 +08:00
parent 23a31f7c00
commit 5a1af9f82f
15 changed files with 1377 additions and 71 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
from app.database import Base from app.database import Base
from app.models import User, Product, Customer, Conversation, Message, Quotation, QuotationItem, CorpusEntry, Team, TeamMember, UsageLog, Notification, Feedback, Subscription, PreferenceAnalysis, MarketingEffect, Device, FollowupStrategy, FollowupLog from app.models import User, Product, Customer, Conversation, Message, Quotation, QuotationItem, CorpusEntry, Team, TeamMember, UsageLog, Notification, Feedback, Subscription, PreferenceAnalysis, MarketingEffect, Device, FollowupStrategy, FollowupLog, SystemConfig
target_metadata = Base.metadata target_metadata = Base.metadata
+105 -1
View File
@@ -1,3 +1,6 @@
import uuid
from typing import Optional
from datetime import date, datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
@@ -26,11 +29,19 @@ async def get_dashboard(
async def list_users( async def list_users(
page: int = Query(1, ge=1), page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100), size: int = Query(20, ge=1, le=100),
role: Optional[str] = Query(None),
_: dict = Depends(require_admin), _: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
service = AdminService(db) service = AdminService(db)
return await service.list_users(page, size) return await service.list_users(page, size, role)
def _validate_uuid(user_id: str):
try:
uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID format")
@router.patch("/users/{target_user_id}/tier") @router.patch("/users/{target_user_id}/tier")
@@ -40,6 +51,7 @@ async def update_user_tier(
_: dict = Depends(require_admin), _: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_validate_uuid(target_user_id)
service = AdminService(db) service = AdminService(db)
tier = data.get("tier") tier = data.get("tier")
if tier not in ("free", "pro", "enterprise"): if tier not in ("free", "pro", "enterprise"):
@@ -56,6 +68,7 @@ async def toggle_user_active(
_: dict = Depends(require_admin), _: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
_validate_uuid(target_user_id)
service = AdminService(db) service = AdminService(db)
success = await service.toggle_user_active(target_user_id) success = await service.toggle_user_active(target_user_id)
if not success: if not success:
@@ -63,6 +76,97 @@ async def toggle_user_active(
return {"message": "User active status toggled"} return {"message": "User active status toggled"}
@router.patch("/users/{target_user_id}/role")
async def update_user_role(
target_user_id: str,
data: dict,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
_validate_uuid(target_user_id)
service = AdminService(db)
role = data.get("role")
if role not in ("user", "admin"):
raise HTTPException(status_code=400, detail="Invalid role. Must be 'user' or 'admin'")
result = await service.update_user_role(target_user_id, role)
if not result:
raise HTTPException(status_code=404, detail="User not found")
return result
@router.get("/users/search")
async def search_users(
q: str = Query(..., min_length=1),
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
service = AdminService(db)
return await service.search_users(q)
@router.get("/users/{target_user_id}")
async def get_user_detail(
target_user_id: str,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
_validate_uuid(target_user_id)
service = AdminService(db)
result = await service.get_user_detail(target_user_id)
if not result:
raise HTTPException(status_code=404, detail="User not found")
return result
@router.get("/usage-stats")
async def get_usage_stats(
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
service = AdminService(db)
return await service.get_usage_stats()
@router.get("/logs")
async def get_logs(
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=200),
action: Optional[str] = Query(None),
user_id: Optional[str] = Query(None),
date_from: Optional[date] = Query(None),
date_to: Optional[date] = Query(None),
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
service = AdminService(db)
dt_from = datetime.combine(date_from, datetime.min.time()) if date_from else None
dt_to = datetime.combine(date_to, datetime.max.time()) if date_to else None
return await service.get_logs(page, size, action, user_id, dt_from, dt_to)
@router.get("/config")
async def list_config(
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
service = AdminService(db)
return await service.list_config()
@router.put("/config/{key}")
async def update_config(
key: str,
data: dict,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
service = AdminService(db)
item = await service.update_config(key, data.get("value"))
if not item:
raise HTTPException(status_code=404, detail="Config not found")
return item
@router.get("/health") @router.get("/health")
async def system_health( async def system_health(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
+2
View File
@@ -10,6 +10,7 @@ from .subscription import Subscription
from .preference import PreferenceAnalysis, MarketingEffect from .preference import PreferenceAnalysis, MarketingEffect
from .device import Device from .device import Device
from .followup import FollowupStrategy, FollowupLog from .followup import FollowupStrategy, FollowupLog
from .system_config import SystemConfig
__all__ = [ __all__ = [
"User", "Product", "User", "Product",
@@ -24,4 +25,5 @@ __all__ = [
"PreferenceAnalysis", "MarketingEffect", "PreferenceAnalysis", "MarketingEffect",
"Device", "Device",
"FollowupStrategy", "FollowupLog", "FollowupStrategy", "FollowupLog",
"SystemConfig",
] ]
+15
View File
@@ -0,0 +1,15 @@
from sqlalchemy import Column, String, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from datetime import datetime
from app.database import Base
import uuid
class SystemConfig(Base):
__tablename__ = "system_configs"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
key = Column(String(100), unique=True, nullable=False, index=True)
value = Column(JSONB, nullable=False, default={})
description = Column(Text, default="")
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+3
View File
@@ -17,6 +17,9 @@ class User(Base):
tier = Column(String(50), default="free") tier = Column(String(50), default="free")
role = Column(String(20), default="user") role = Column(String(20), default="user")
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
email = Column(String(255))
last_login_at = Column(DateTime, nullable=True)
login_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.utcnow) created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
settings = Column(JSONB, default={ settings = Column(JSONB, default={
+253 -16
View File
@@ -1,11 +1,12 @@
from typing import Dict, Any, List from typing import Dict, Any, List, Optional
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_ from sqlalchemy import select, func, text, and_
from app.models.user import User from app.models.user import User, Product
from app.models.team import Team, TeamMember from app.models.team import Team, TeamMember
from app.models.analytics import UsageLog from app.models.analytics import UsageLog
from app.models.customer import Customer from app.models.customer import Customer
from app.models.quotation import Quotation from app.models.quotation import Quotation
from app.models.system_config import SystemConfig
from datetime import datetime, timedelta from datetime import datetime, timedelta
import logging import logging
@@ -64,26 +65,36 @@ class AdminService:
], ],
} }
async def list_users(self, page: int = 1, size: int = 20) -> Dict[str, Any]: def _user_to_dict(self, u: User) -> Dict[str, Any]:
query = select(User).order_by(User.created_at.desc()).offset((page - 1) * size).limit(size) return {
"id": str(u.id),
"username": u.username,
"phone": u.phone,
"email": u.email,
"tier": u.tier,
"role": u.role,
"is_active": u.is_active,
"last_login_at": u.last_login_at.isoformat() if u.last_login_at else None,
"login_count": u.login_count or 0,
"created_at": u.created_at.isoformat() if u.created_at else None,
"settings": u.settings,
}
async def list_users(self, page: int = 1, size: int = 20, role: Optional[str] = None) -> Dict[str, Any]:
base_query = select(User)
count_query = select(func.count(User.id)) count_query = select(func.count(User.id))
if role:
base_query = base_query.where(User.role == role)
count_query = count_query.where(User.role == role)
query = base_query.order_by(User.created_at.desc()).offset((page - 1) * size).limit(size)
total = await self.db.execute(count_query) total = await self.db.execute(count_query)
result = await self.db.execute(query) result = await self.db.execute(query)
users = result.scalars().all() users = result.scalars().all()
return { return {
"items": [ "items": [self._user_to_dict(u) for u in users],
{
"id": str(u.id),
"username": u.username,
"phone": u.phone,
"tier": u.tier,
"is_active": u.is_active,
"created_at": u.created_at.isoformat() if u.created_at else None,
}
for u in users
],
"total": total.scalar(), "total": total.scalar(),
"page": page, "page": page,
"size": size, "size": size,
@@ -107,6 +118,58 @@ class AdminService:
await self.db.flush() await self.db.flush()
return True return True
async def update_user_role(self, user_id: str, role: str) -> Optional[Dict[str, Any]]:
if role not in ("user", "admin"):
raise ValueError("Invalid role")
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return None
user.role = role
await self.db.flush()
return self._user_to_dict(user)
async def get_user_detail(self, user_id: str) -> Optional[Dict[str, Any]]:
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return None
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_start_date = today_start.date()
product_count = await self.db.execute(select(func.count()).select_from(User.__table__).where(User.id == user_id))
prod_count = await self.db.execute(
select(func.count(Product.id)).where(Product.user_id == user_id)
)
cust_count = await self.db.execute(
select(func.count(Customer.id)).where(Customer.user_id == user_id)
)
quot_count = await self.db.execute(
select(func.count(Quotation.id)).where(Quotation.user_id == user_id)
)
usage_today = await self.db.execute(
select(func.count(UsageLog.id)).where(
UsageLog.user_id == user_id, UsageLog.created_at >= today_start
)
)
usage_total = await self.db.execute(
select(func.count(UsageLog.id)).where(UsageLog.user_id == user_id)
)
return {
**self._user_to_dict(user),
"stats": {
"products": prod_count.scalar() or 0,
"customers": cust_count.scalar() or 0,
"quotations": quot_count.scalar() or 0,
"usage_today": usage_today.scalar() or 0,
"usage_total": usage_total.scalar() or 0,
},
}
async def get_system_health(self) -> Dict[str, Any]: async def get_system_health(self) -> Dict[str, Any]:
return { return {
"status": "healthy", "status": "healthy",
@@ -127,3 +190,177 @@ class AdminService:
await self.db.flush() await self.db.flush()
except Exception as e: except Exception as e:
logger.warning(f"Failed to log usage: {e}") logger.warning(f"Failed to log usage: {e}")
async def get_usage_stats(self) -> Dict[str, Any]:
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_ago = now - timedelta(days=7)
by_action = await self.db.execute(
select(UsageLog.action, func.count(UsageLog.id))
.where(UsageLog.created_at >= today_start)
.group_by(UsageLog.action)
.order_by(func.count(UsageLog.id).desc())
)
daily_result = await self.db.execute(
text(
"SELECT date_trunc('day', created_at) AS day, count(id) "
"FROM usage_logs WHERE created_at >= :week_ago "
"GROUP BY date_trunc('day', created_at) "
"ORDER BY date_trunc('day', created_at)"
),
{"week_ago": week_ago},
)
today_logs = await self.db.execute(
select(func.count(UsageLog.id)).where(UsageLog.created_at >= today_start)
)
dau = await self.db.execute(
select(func.count(func.distinct(UsageLog.user_id)))
.where(UsageLog.created_at >= today_start)
)
total_users = await self.db.execute(select(func.count(User.id)))
return {
"today_total": today_logs.scalar() or 0,
"dau": dau.scalar() or 0,
"total_users": total_users.scalar() or 0,
"by_action": {row[0]: row[1] for row in by_action.all()},
"daily_trend": [
{"date": str(row[0].date()), "count": row[1]}
for row in daily_result.all()
],
}
async def get_logs(
self,
page: int = 1,
size: int = 50,
action: Optional[str] = None,
user_id: Optional[str] = None,
date_from: Optional[datetime] = None,
date_to: Optional[datetime] = None,
) -> Dict[str, Any]:
filters = []
if action:
filters.append(UsageLog.action == action)
if user_id:
filters.append(UsageLog.user_id == user_id)
if date_from:
filters.append(UsageLog.created_at >= date_from)
if date_to:
filters.append(UsageLog.created_at <= date_to)
count_query = select(func.count(UsageLog.id))
data_query = select(UsageLog).order_by(UsageLog.created_at.desc())
if filters:
where_clause = and_(*filters)
count_query = count_query.where(where_clause)
data_query = data_query.where(where_clause)
total = await self.db.execute(count_query)
result = await self.db.execute(
data_query.offset((page - 1) * size).limit(size)
)
logs = result.scalars().all()
return {
"items": [
{
"id": str(log.id),
"user_id": str(log.user_id),
"action": log.action,
"detail": log.detail,
"ip_address": log.ip_address,
"user_agent": log.user_agent,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
],
"total": total.scalar(),
"page": page,
"size": size,
}
async def _seed_default_configs(self):
defaults = [
SystemConfig(key="ai_provider_translate", value={"primary": "sensenova", "fallback": ["openai", "local"]}, description="翻译任务 AI 模型选择"),
SystemConfig(key="ai_provider_reply", value={"primary": "sensenova", "fallback": ["anthropic", "local"]}, description="回复建议 AI 模型选择"),
SystemConfig(key="ai_provider_marketing", value={"primary": "sensenova", "fallback": ["openai", "local"]}, description="营销文案 AI 模型选择"),
SystemConfig(key="ai_provider_extract", value={"primary": "sensenova", "fallback": ["openai"]}, description="信息提取 AI 模型选择"),
SystemConfig(key="ai_provider_quotation", value={"primary": "sensenova", "fallback": ["openai"]}, description="报价单 AI 模型选择"),
SystemConfig(key="feature_guest_mode", value={"enabled": True}, description="游客模式开关"),
SystemConfig(key="feature_wechat_login", value={"enabled": False}, description="微信登录开关"),
SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"),
SystemConfig(key="free_daily_limits", value={"translate_chars": 5000, "replies": 20, "marketing": 5, "customers": 5, "products": 1, "quotations": 3}, description="免费版每日配额"),
SystemConfig(key="pro_daily_limits", value={"translate_chars": 50000, "replies": 200, "marketing": 50, "customers": 100, "products": 20, "quotations": 30}, description="Pro 版每日配额"),
]
for cfg in defaults:
self.db.add(cfg)
await self.db.flush()
async def list_config(self) -> List[Dict[str, Any]]:
result = await self.db.execute(
select(func.count(SystemConfig.id))
)
if result.scalar() == 0:
await self._seed_default_configs()
result = await self.db.execute(
select(SystemConfig).order_by(SystemConfig.key)
)
configs = result.scalars().all()
return [
{
"key": c.key,
"value": c.value,
"description": c.description,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
for c in configs
]
async def update_config(self, key: str, value: Any) -> Optional[Dict[str, Any]]:
result = await self.db.execute(
select(SystemConfig).where(SystemConfig.key == key)
)
config = result.scalar_one_or_none()
if not config:
return None
config.value = value
config.updated_at = datetime.utcnow()
await self.db.flush()
return {
"key": config.key,
"value": config.value,
"description": config.description,
"updated_at": config.updated_at.isoformat() if config.updated_at else None,
}
async def search_users(self, query: str) -> List[Dict[str, Any]]:
result = await self.db.execute(
select(User)
.where(
(User.username.ilike(f"%{query}%")) | (User.phone.ilike(f"%{query}%"))
)
.order_by(User.created_at.desc())
.limit(20)
)
users = result.scalars().all()
return [
{
"id": str(u.id),
"username": u.username,
"phone": u.phone,
"tier": u.tier,
"role": u.role,
"is_active": u.is_active,
"created_at": u.created_at.isoformat() if u.created_at else None,
"settings": u.settings,
}
for u in users
]
+105
View File
@@ -0,0 +1,105 @@
# 生产部署文档(宝塔面板)
## 前提条件
宝塔面板已安装:
- Nginx
- PostgreSQL(已有数据库 `foreign_trade`
- Redis
- Python 3.11+
## 目录结构(宝塔站点根目录)
```
/www/wwwroot/trade.yuzhiran.com/
├── backend/ # FastAPI 后端代码
│ ├── .env # 生产环境变量
│ ├── app/
│ ├── alembic/
│ └── ...
├── frontend/ # 前端静态文件
│ └── dist/ # uni-app 构建产物
└── deploy/ # 部署配置(此目录)
```
## 部署步骤
### 1. 上传代码到服务器
将项目代码(production 分支)上传到 `/www/wwwroot/trade.yuzhiran.com/`
### 2. 配置后端环境变量
```bash
cp deploy/backend/.env.production backend/.env
# 编辑 backend/.env,填入:
# - SECRET_KEY(随机字符串,用于 JWT 签名)
# - AI API KeyOPENAI_API_KEY 或 SENSENOVA_API_KEY 等)
vim backend/.env
```
### 3. 安装后端依赖 & 运行迁移
```bash
cd backend
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
alembic upgrade head
```
### 4. 构建前端
```bash
cd uni-app
npm install
npm run build:h5
# 产物在 dist/build/h5/ 目录
# 将 dist/build/h5/ 下的内容复制到 /www/wwwroot/trade.yuzhiran.com/frontend/dist/
```
### 5. 配置 Nginx(宝塔面板操作)
在宝塔面板中:
1. 添加站点 `trade.yuzhiran.com`
2. 站点根目录设为 `/www/wwwroot/trade.yuzhiran.com/frontend/dist`
3. 修改 Nginx 配置,参考 `deploy/frontend/nginx.conf`
关键配置点:
- 根目录指向前端 dist
- `/api/` 反向代理到 `http://127.0.0.1:8000`
- SPA fallback: `try_files $uri $uri/ /index.html`
### 6. 启动后端(宝塔 Python 项目管理器)
在宝塔面板中使用 **Python项目管理器**
- 项目路径:`/www/wwwroot/trade.yuzhiran.com/backend`
- Python 版本:3.11
- 启动命令:`uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2`
- 项目名称:`trademate-backend`
或者使用 Supervisor 命令行部署:
```bash
pip install supervisor
supervisord -c deploy/backend/supervisord.conf
```
### 7. 验证
- 访问 https://trade.yuzhiran.com → 前端正常加载
- 访问 https://trade.yuzhiran.com/api/health → 返回 `{"status": "ok"}`
## 常见问题
**Q: 数据库迁移失败?**
A: 确认 PostgreSQL 中 `foreign_trade` 数据库已创建,用户有权限。
```sql
CREATE DATABASE foreign_trade;
GRANT ALL PRIVILEGES ON DATABASE foreign_trade TO foreign_trade;
```
**Q: 前端 API 请求 502**
A: 检查后端是否启动,以及 Nginx 中 `/api/` 的 proxy_pass 地址是否正确。
**Q: CORS 报错?**
A: 确认 `backend/.env` 中的 `FRONTEND_URL` 与实际前端域名一致。
+59
View File
@@ -0,0 +1,59 @@
# 生产环境配置(基于宝塔面板 PostgreSQL foreign_trade 数据库)
APP_NAME=TradeMate
SECRET_KEY=change-this-to-a-random-secret-key
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=30
# 数据库(foreign_trade
DATABASE_URL=postgresql+asyncpg://foreign_trade:dWFNi67nHNbPbjmP@localhost:5432/foreign_trade
# Redis(宝塔自带,默认端口)
REDIS_URL=redis://localhost:6379/0
# Celery
CELERY_BROKER_URL=redis://localhost:6379/1
CELERY_RESULT_BACKEND=redis://localhost:6379/2
# AI 提供商(按需填入)
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
DEEPL_API_KEY=
SENSENOVA_API_KEY=
SENSENOVA_BASE_URL=https://token.sensenova.cn/v1
SENSENOVA_MODEL=sensenova-6.7-flash-lite
IFLYTEK_API_KEY=
IFLYTEK_API_BASE=https://maas-api.cn-huabei-1.xf-yun.com/v2
IFLYTEK_MODEL=astron-code-latest
LOCAL_MODEL_ENABLED=false
LOCAL_MODEL_URL=http://localhost:8001
# WhatsApp Cloud API
WHATSAPP_API_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_WEBHOOK_VERIFY_TOKEN=
# 微信
WECHAT_APP_ID=
WECHAT_APP_SECRET=
# 汇率 API
EXCHANGE_RATE_API_KEY=
# 文件存储
UPLOAD_DIR=./uploads
MAX_UPLOAD_SIZE=10485760
# 错误监控 (Sentry)
SENTRY_DSN=
DEBUG=false
# URL(以宝塔实际域名/端口为准)
FRONTEND_URL=https://trade.yuzhiran.com
BACKEND_URL=https://api.trade.yuzhiran.com
# 数据库调试关闭
DB_ECHO=false
+13
View File
@@ -0,0 +1,13 @@
[program:trademate-backend]
command=/www/server/panel/pyenv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 --proxy-headers --forwarded-allow-ips='*'
directory=/www/wwwroot/trade.yuzhiran.com/backend
user=www
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
stdout_logfile=/www/wwwlogs/trademate-backend.log
stderr_logfile=/www/wwwlogs/trademate-backend.error.log
stdout_logfile_maxbytes=50MB
stderr_logfile_maxbytes=50MB
environment=PATH="/www/server/panel/pyenv/bin:/usr/local/bin:/usr/bin:/bin",HOME="/home/www"
+29
View File
@@ -0,0 +1,29 @@
#!/bin/bash
# 生产数据库迁移脚本
# 使用方式: bash deploy/database/migrate.sh
# 注意:需先在 backend/ 目录下配置好 .env 或设置好环境变量
set -e
cd "$(dirname "$0")/../../backend"
# 检查 .env 是否存在
if [ ! -f ".env" ]; then
echo "❌ 未找到 .env 文件,请先复制 deploy/backend/.env.production 到 backend/.env"
echo " cp deploy/backend/.env.production backend/.env"
echo " 然后编辑 .env 填入 SECRET_KEY 和 AI API Key"
exit 1
fi
echo "🔧 激活虚拟环境..."
if [ -d "venv" ]; then
source venv/bin/activate
fi
echo "📦 安装/更新依赖..."
pip install -r requirements.txt -q
echo "🗄️ 运行数据库迁移..."
alembic upgrade head
echo "✅ 迁移完成"
+70
View File
@@ -0,0 +1,70 @@
# 宝塔面板 Nginx 配置
# 注意:请勿直接覆盖宝塔生成的配置文件!
# 将此配置中的 server 块复制到宝塔对应站点的配置中
# 宝塔路径: /www/server/panel/vhost/nginx/trade.yuzhiran.com.conf
server {
listen 80;
server_name trade.yuzhiran.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name trade.yuzhiran.com;
# SSL 证书(宝塔面板中配置,或取消注释以下行)
# ssl_certificate /www/server/panel/vhost/cert/trade.yuzhiran.com/fullchain.pem;
# ssl_certificate_key /www/server/panel/vhost/cert/trade.yuzhiran.com/privkey.pem;
# ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
# ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
# ssl_prefer_server_ciphers on;
# 前端静态文件(uni-app build:h5 产物)
root /www/wwwroot/trade.yuzhiran.com/frontend/dist;
index index.html;
# gzip
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css text/javascript application/json application/javascript image/svg+xml;
# API 反向代理到后端
location /api/ {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
# WebSocket 支持(如有需要)
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
# 上传文件(如有需要可配置单独的路径)
# location /uploads/ {
# alias /www/wwwroot/trade.yuzhiran.com/backend/uploads/;
# expires 7d;
# }
# SPA 路由 fallback
location / {
try_files $uri $uri/ /index.html;
}
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
# 禁止访问隐藏文件
location ~ /\. {
deny all;
}
}
+135
View File
@@ -0,0 +1,135 @@
# TradeMate 外贸小助手 — 营销推广方案
> 版本: v1.0
> 更新日期: 2026-05-14
> 状态: 待上线后执行
---
## 一、核心策略
**定位**: 外贸 SOHO / 小微外贸公司的 AI 工作台
**差异化**: 不是翻译工具,是"从询盘到成交"的全流程 AI 辅助
**初期目标**: 获取 200 个活跃用户,验证付费转化模型
---
## 二、阶段一:冷启动(第 1-3 周)
### 2.1 福步外贸论坛 — 3 篇深度帖
| 序号 | 标题方向 | 目的 | 排期 |
|------|----------|------|------|
| 1 | 做外贸 5 年,我整理了一套客户跟进 SOP(附模板) | 引流+建立专业形象 | 第 1 天 |
| 2 | 报完价客户就不回了,这 3 种跟进策略我用了最有效 | 展示工具价值 | 第 5 天 |
| 3 | 我用 AI 写开发信,回复率从 8% 提高到 23%(经验分享) | 转化付费 | 第 10 天 |
**操作要点**:
- 每个帖子都要有真实案例/数据支撑
- 帖子里自然植入工具功能(不要硬广)
- 回复区用"有人问类似问题"的方式引导到工具
- 发帖后每天回复 3-5 个评论
### 2.2 知乎 — 回答引流
**回答方向**(搜索这些关键词去答):
- 外贸新人如何开发客户?
- 外贸报价格式怎么写?
- 有没有好用的外贸工具推荐?
- 做外贸用哪个翻译软件好?
**策略**: 每个回答开头给干货,末尾"我们团队做了一个小工具,可以免费体验……"
### 2.3 公众号/知识星球
- 开设公众号,发 5-10 篇行业干货建立内容库
- 免费提供"询盘分析报告"(用户发截图,帮分析客户意图)
- 通过私域沉淀种子用户
---
## 三、阶段二:裂变增长(第 4-6 周)
### 3.1 推荐码 — 邀请得 Pro 体验
**规则**:
```
现有用户 → 分享邀请链接 → 新用户注册 + 完成首次翻译
→ 邀请人获赠 7 天 Pro
→ 被邀请人获赠 7 天 Pro
```
**实现**: 在 User 表加 `referrer_id` 字段,User 表加 `pro_expires_at` 字段(到期时间),由推荐逻辑自动设置。
### 3.2 案例征集
- 征集"用 TradeMate 成交的第一个客户"故事
- 入选者赠送 3 个月 Pro
- 故事加工成帖子二次传播
---
## 四、阶段三:付费转化(第 7 周起)
### 4.1 免费 → 付费的转化点设计
```
免费用户 → 用完免费配额 → 弹窗:
方案 A: 邀请朋友 → 重置配额
方案 B: 升级 Pro → ¥xx/月
方案 C: 填问卷反馈 → 赠送 50 次翻译
```
### 4.2 定价策略建议
| Tier | 价格 | 定位 |
|------|------|------|
| Free | ¥0 | 体验门槛,5 个客户/日上限 |
| Pro | ¥39/月 | 单人使用,无限客户 + 优先响应 |
| Enterprise | ¥99/月 | 团队协作 + 专属模型 |
---
## 五、内容排期表
| 周期 | 渠道 | 内容类型 | 频率 |
|------|------|----------|------|
| 第 1 周 | 福步论坛 | 干货长帖 | 2 篇 |
| 第 1 周 | 知乎 | 回答 | 5 个/天 |
| 第 2 周 | 公众号 | 行业文章 | 3 篇 |
| 第 2 周 | 外贸微信群 | 答疑+引导 | 每日 |
| 第 3 周 | Product Hunt | 英文发布 | 1 次 |
| 第 4 周 | 行业导航站 | 提交收录 | 5-8 个站 |
---
## 六、效果指标
| 指标 | 第 1 月目标 | 第 3 月目标 |
|------|:---------:|:---------:|
| 注册用户 | 500 | 3000 |
| DAU | 50 | 300 |
| 付费用户 | 10 | 100 |
| 月收入 | ¥390 | ¥3900+ |
---
## 七、内容素材准备清单
- [ ] 产品截图(首页、翻译、客户管理、报价单)
- [ ] 15s 演示视频(录屏 + 配音)
- [ ] 3 个用户案例故事
- [ ] 免费 vs Pro 功能对比图
- [ ] 询盘分析报告模板(用于免费引流)
- [ ] 英文版 Landing PageProduct Hunt 用)
- [ ] FAQ 文档(10 条常见问题)
---
## 八、注意事项
1. **内容 > 广告** — 外贸圈是信任驱动型社群,一篇干货的价值远超竞价广告
2. **先服务后转化** — 先免费帮用户解决具体问题(分析询盘、写跟进等),再引导使用工具
3. **不要群发** — 微信群发链接会被踢,一对一私聊更有效
4. **收集反馈** — 每 10 个用户做一次电话/语音回访,了解真实使用场景
5. **关注留存而非注册** — 100 个注册但次日留存 10% 不如 30 个注册留存 60%
+413 -22
View File
@@ -2,11 +2,21 @@
<view class="admin-container"> <view class="admin-container">
<view class="header-card"> <view class="header-card">
<text class="title">管理后台</text> <text class="title">管理后台</text>
<text class="subtitle">系统概览</text> <text class="subtitle">系统管理与监控</text>
</view> </view>
<view class="tabs">
<view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">概览</view>
<view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户</view>
<view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">统计</view>
<view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">日志</view>
<view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">配置</view>
</view>
<!-- 概览 -->
<view v-if="tab === 'overview'">
<view class="stats-grid"> <view class="stats-grid">
<view class="stat-card"> <view class="stat-card" @click="tab='users'">
<text class="stat-value">{{ dashboard.users?.total || 0 }}</text> <text class="stat-value">{{ dashboard.users?.total || 0 }}</text>
<text class="stat-label">用户总数</text> <text class="stat-label">用户总数</text>
</view> </view>
@@ -23,79 +33,408 @@
<text class="stat-label">今日请求</text> <text class="stat-label">今日请求</text>
</view> </view>
</view> </view>
<view class="section"> <view class="section">
<view class="section-header"> <view class="section-header">
<text class="section-title">最近注册用户</text> <text class="section-title">最近注册用户</text>
</view> </view>
<view class="user-list" v-if="dashboard.recent_users?.length"> <view class="user-list" v-if="dashboard.recent_users?.length">
<view class="user-item" v-for="u in dashboard.recent_users" :key="u.id"> <view class="user-item" v-for="u in dashboard.recent_users" :key="u.id" @click="showUserDetail(u.id)">
<view class="user-info"> <view class="user-info">
<text class="user-name">{{ u.username }}</text> <text class="user-name">{{ u.username }}</text>
<text class="user-tier" :class="u.tier">{{ u.tier }}</text> <text class="user-tier" :class="u.tier">{{ u.tier }}</text>
<text class="user-role" :class="u.role">{{ u.role }}</text>
</view> </view>
<text class="user-date">{{ formatTime(u.created_at) }}</text> <text class="user-date">{{ formatTime(u.created_at) }}</text>
</view> </view>
</view> </view>
<text v-else class="empty-text">暂无数据</text>
</view>
</view>
<!-- 用户管理 -->
<view v-if="tab === 'users'">
<view class="section">
<view class="section-header">
<text class="section-title">搜索用户</text>
</view>
<view class="search-bar">
<input class="search-input" v-model="searchQuery" placeholder="用户名/手机号" @confirm="doSearch" />
<text class="search-btn" @click="doSearch">搜索</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)">
<view class="user-info">
<text class="user-name">{{ u.username || u.phone }}</text>
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
<text class="user-role" :class="u.role">{{ u.role }}</text>
</view>
<view class="user-actions">
<text class="action-btn" @click.stop="changeTier(u, 'free')">免费</text>
<text class="action-btn pro-btn" @click.stop="changeTier(u, 'pro')">Pro</text>
<text class="action-btn enterprise-btn" @click.stop="changeTier(u, 'enterprise')">企业</text>
<text class="action-btn admin-btn" :class="u.role === 'admin' ? 'warn' : ''" @click.stop="toggleRole(u)">{{ u.role === 'admin' ? '撤销管理' : '设为管理' }}</text>
<text class="action-btn toggle-btn" :class="u.is_active ? 'warn' : 'success'" @click.stop="toggleActive(u)">{{ u.is_active ? '禁用' : '启用' }}</text>
</view>
</view>
</view>
<text v-else-if="searchQuery && !searching" class="empty-text">无匹配用户</text>
</view> </view>
<view class="section"> <view class="section">
<view class="section-header"> <view class="section-header">
<text class="section-title">用户管理</text> <text class="section-title">所有用户</text>
<text class="section-count"> {{ userTotal }} </text>
</view> </view>
<view class="user-list" v-if="users.length"> <view class="user-list" v-if="users.length">
<view class="user-item" v-for="u in users" :key="u.id"> <view class="user-item" v-for="u in users" :key="u.id" @click="showUserDetail(u.id)">
<view class="user-info"> <view class="user-info">
<text class="user-name">{{ u.username || u.phone }}</text> <text class="user-name">{{ u.username || u.phone }}</text>
<text class="user-tier" :class="u.tier">{{ u.tier }}</text> <text class="user-tier" :class="u.tier">{{ u.tier }}</text>
<text class="user-role" :class="u.role">{{ u.role }}</text>
</view> </view>
<view class="user-actions"> <view class="user-actions">
<text class="action-btn" @click="changeTier(u, 'free')">免费</text> <text class="action-btn" @click.stop="changeTier(u, 'free')">免费</text>
<text class="action-btn pro-btn" @click="changeTier(u, 'pro')">Pro</text> <text class="action-btn pro-btn" @click.stop="changeTier(u, 'pro')">Pro</text>
<text class="action-btn enterprise-btn" @click="changeTier(u, 'enterprise')">企业</text> <text class="action-btn enterprise-btn" @click.stop="changeTier(u, 'enterprise')">企业</text>
<text class="action-btn admin-btn" :class="u.role === 'admin' ? 'warn' : ''" @click.stop="toggleRole(u)">{{ u.role === 'admin' ? '撤销管理' : '设为管理' }}</text>
<text class="action-btn toggle-btn" :class="u.is_active ? 'warn' : 'success'" @click.stop="toggleActive(u)">{{ u.is_active ? '禁用' : '启用' }}</text>
</view> </view>
</view> </view>
</view> </view>
<view class="pagination" v-if="userTotal > userPageSize">
<text class="page-btn" :class="{ disabled: userPage <= 1 }" @click="changeUserPage(userPage - 1)">上一页</text>
<text class="page-info">{{ userPage }} / {{ userTotalPages }}</text>
<text class="page-btn" :class="{ disabled: userPage >= userTotalPages }" @click="changeUserPage(userPage + 1)">下一页</text>
</view>
</view>
</view>
<!-- 使用统计 -->
<view v-if="tab === 'stats'">
<view class="stats-grid">
<view class="stat-card">
<text class="stat-value">{{ usageStats.today_total || 0 }}</text>
<text class="stat-label">今日请求</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ usageStats.dau || 0 }}</text>
<text class="stat-label">日活跃用户</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ usageStats.total_users || 0 }}</text>
<text class="stat-label">总用户</text>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">今日各功能调用</text>
</view>
<view class="bar-chart" v-if="Object.keys(usageStats.by_action || {}).length">
<view class="bar-row" v-for="(count, action) in usageStats.by_action" :key="action">
<text class="bar-label">{{ action }}</text>
<view class="bar-track">
<view class="bar-fill" :style="{ width: barWidth(count) }"></view>
</view>
<text class="bar-value">{{ count }}</text>
</view>
</view>
<text v-else class="empty-text">暂无数据</text>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">近7日趋势</text>
</view>
<view class="bar-chart" v-if="(usageStats.daily_trend || []).length">
<view class="bar-row" v-for="d in usageStats.daily_trend" :key="d.date">
<text class="bar-label">{{ formatShortDate(d.date) }}</text>
<view class="bar-track">
<view class="bar-fill trend" :style="{ width: trendBarWidth(d.count) }"></view>
</view>
<text class="bar-value">{{ d.count }}</text>
</view>
</view>
<text v-else class="empty-text">暂无数据</text>
</view>
</view>
<!-- 操作日志 -->
<view v-if="tab === 'logs'">
<view class="section">
<view class="section-header">
<text class="section-title">筛选条件</text>
</view>
<view class="filter-row">
<input class="filter-input" v-model="logFilter.action" placeholder="动作类型" />
<input class="filter-input" v-model="logFilter.user_id" placeholder="用户ID" />
</view>
<view class="filter-row">
<input class="filter-input" v-model="logFilter.date_from" placeholder="开始日期 2026-05-01" />
<input class="filter-input" v-model="logFilter.date_to" placeholder="结束日期 2026-05-14" />
<text class="search-btn small" @click="applyLogFilter">筛选</text>
<text class="search-btn small gray" @click="resetLogFilter">重置</text>
</view>
</view>
<view class="section">
<view class="section-header">
<text class="section-title">操作日志</text>
<text class="section-count"> {{ logs.total || 0 }} </text>
</view>
<view class="log-list" v-if="logs.items?.length">
<view class="log-item" v-for="log in logs.items" :key="log.id">
<view class="log-header">
<text class="log-action">{{ log.action }}</text>
<text class="log-time">{{ formatTime(log.created_at) }}</text>
</view>
<text class="log-user">用户: {{ log.user_id?.slice(0, 8) }}...</text>
<text class="log-ip" v-if="log.ip_address">IP: {{ log.ip_address }}</text>
<text class="log-detail" v-if="log.detail && Object.keys(log.detail).length">{{ JSON.stringify(log.detail) }}</text>
</view>
</view>
<text v-else class="empty-text">暂无日志</text>
<view class="pagination" v-if="logs.total > logPageSize">
<text class="page-btn" :class="{ disabled: logPage <= 1 }" @click="changeLogPage(logPage - 1)">上一页</text>
<text class="page-info">{{ logPage }} / {{ logTotalPages }}</text>
<text class="page-btn" :class="{ disabled: logPage >= logTotalPages }" @click="changeLogPage(logPage + 1)">下一页</text>
</view>
</view>
</view>
<!-- 系统配置 -->
<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>
<view class="config-editor">
<textarea class="config-textarea" :value="formatConfigValue(cfg.value)" @input="e => onConfigEdit(cfg.key, e)" />
<text class="save-btn" @click="saveConfig(cfg.key)">保存</text>
</view>
</view>
<text v-if="!configList.length" class="empty-text">暂无配置</text>
</view>
<!-- 用户详情弹窗 -->
<view class="modal-mask" v-if="userDetail" @click="userDetail = null">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">用户详情</text>
<text class="modal-close" @click="userDetail = null"></text>
</view>
<view class="modal-body">
<view class="detail-row"><text class="detail-label">用户名</text><text>{{ userDetail.username || '-' }}</text></view>
<view class="detail-row"><text class="detail-label">手机号</text><text>{{ userDetail.phone || '-' }}</text></view>
<view class="detail-row"><text class="detail-label">邮箱</text><text>{{ userDetail.email || '-' }}</text></view>
<view class="detail-row"><text class="detail-label">套餐</text><text class="user-tier" :class="userDetail.tier">{{ userDetail.tier }}</text></view>
<view class="detail-row"><text class="detail-label">角色</text><text class="user-role" :class="userDetail.role">{{ userDetail.role }}</text></view>
<view class="detail-row"><text class="detail-label">状态</text><text :class="userDetail.is_active ? 'text-green' : 'text-red'">{{ userDetail.is_active ? '正常' : '已禁用' }}</text></view>
<view class="detail-row"><text class="detail-label">最后登录</text><text>{{ userDetail.last_login_at ? formatTime(userDetail.last_login_at) : '从未' }}</text></view>
<view class="detail-row"><text class="detail-label">登录次数</text><text>{{ userDetail.login_count }}</text></view>
<view class="detail-row"><text class="detail-label">注册时间</text><text>{{ formatTime(userDetail.created_at) }}</text></view>
<view class="divider"></view>
<text class="detail-section-title">资源统计</text>
<view class="detail-row" v-if="userDetail.stats"><text class="detail-label">产品数</text><text>{{ userDetail.stats.products }}</text></view>
<view class="detail-row" v-if="userDetail.stats"><text class="detail-label">客户数</text><text>{{ userDetail.stats.customers }}</text></view>
<view class="detail-row" v-if="userDetail.stats"><text class="detail-label">报价单</text><text>{{ userDetail.stats.quotations }}</text></view>
<view class="detail-row" v-if="userDetail.stats"><text class="detail-label">今日请求</text><text>{{ userDetail.stats.usage_today }}</text></view>
<view class="detail-row" v-if="userDetail.stats"><text class="detail-label">总请求</text><text>{{ userDetail.stats.usage_total }}</text></view>
</view>
</view>
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, watch, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { adminApi } from '@/utils/api.js' import { adminApi } from '@/utils/api.js'
const tab = ref('overview')
const dashboard = ref({}) const dashboard = ref({})
const users = ref([]) const users = ref([])
const userPage = ref(1)
const userPageSize = 20
const userTotal = ref(0)
onShow(() => { const searchQuery = ref('')
loadData() const searchResults = ref([])
}) const searching = ref(false)
const loadData = async () => { const usageStats = ref({})
const logs = ref({})
const logPage = ref(1)
const logPageSize = 50
const logFilter = ref({ action: '', user_id: '', date_from: '', date_to: '' })
const configList = ref([])
const configEdits = ref({})
const userDetail = ref(null)
const userTotalPages = computed(() => Math.ceil(userTotal.value / userPageSize) || 1)
const logTotalPages = computed(() => Math.ceil((logs.value.total || 0) / logPageSize) || 1)
onShow(() => { loadOverview() })
const loadOverview = async () => {
try { try {
const [dash, userList] = await Promise.all([ const [dash, userList] = await Promise.all([
adminApi.getDashboard(), adminApi.getDashboard(),
adminApi.listUsers(), adminApi.listUsers(userPage.value, userPageSize),
]) ])
dashboard.value = dash dashboard.value = dash
users.value = userList.items || [] users.value = userList.items || []
userTotal.value = userList.total || 0
} catch (err) { } catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' }) uni.showToast({ title: err.message || '加载失败', icon: 'none' })
} }
} }
const changeUserPage = (page) => {
if (page < 1 || page > userTotalPages.value) return
userPage.value = page
loadOverview()
}
const doSearch = async () => {
const q = searchQuery.value.trim()
if (!q) return
searching.value = true
try {
searchResults.value = await adminApi.searchUsers(q)
} catch (err) {
uni.showToast({ title: err.message || '搜索失败', icon: 'none' })
} finally {
searching.value = false
}
}
const changeTier = async (user, tier) => { const changeTier = async (user, tier) => {
try { try {
await adminApi.updateUserTier(user.id, tier) await adminApi.updateUserTier(user.id, tier)
uni.showToast({ title: '已更新', icon: 'success' }) uni.showToast({ title: '已更新', icon: 'success' })
loadData() loadOverview()
} catch (err) { } catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' }) uni.showToast({ title: err.message || '操作失败', icon: 'none' })
} }
} }
const toggleRole = async (user) => {
const newRole = user.role === 'admin' ? 'user' : 'admin'
try {
await adminApi.updateUserRole(user.id, newRole)
uni.showToast({ title: newRole === 'admin' ? '已设为管理员' : '已撤销管理员', icon: 'success' })
loadOverview()
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
}
}
const toggleActive = async (user) => {
try {
await adminApi.toggleUserActive(user.id)
uni.showToast({ title: '已切换', icon: 'success' })
loadOverview()
} catch (err) {
uni.showToast({ title: err.message || '操作失败', icon: 'none' })
}
}
const showUserDetail = async (userId) => {
try {
const detail = await adminApi.getUserDetail(userId)
userDetail.value = detail
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const formatTime = (t) => t ? t.split('T')[0] : '' const formatTime = (t) => t ? t.split('T')[0] : ''
const formatShortDate = (d) => d ? d.slice(5) : ''
const maxActionCount = ref(1)
const maxTrendCount = ref(1)
const barWidth = (count) => Math.max((count / (maxActionCount.value || 1)) * 100, 5) + '%'
const trendBarWidth = (count) => Math.max((count / (maxTrendCount.value || 1)) * 100, 5) + '%'
const loadUsageStats = async () => {
try {
const data = await adminApi.getUsageStats()
usageStats.value = data
const byAction = data.by_action || {}
const counts = Object.values(byAction)
maxActionCount.value = counts.length ? Math.max(...counts) : 1
const trend = data.daily_trend || []
const trendCounts = trend.map(d => d.count)
maxTrendCount.value = trendCounts.length ? Math.max(...trendCounts) : 1
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const loadLogs = async () => {
try {
const filters = {}
if (logFilter.value.action) filters.action = logFilter.value.action
if (logFilter.value.user_id) filters.user_id = logFilter.value.user_id
if (logFilter.value.date_from) filters.date_from = logFilter.value.date_from
if (logFilter.value.date_to) filters.date_to = logFilter.value.date_to
logs.value = await adminApi.getLogs(logPage.value, logPageSize, filters)
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const applyLogFilter = () => { logPage.value = 1; loadLogs() }
const resetLogFilter = () => {
logFilter.value = { action: '', user_id: '', date_from: '', date_to: '' }
logPage.value = 1
loadLogs()
}
const changeLogPage = (page) => {
if (page < 1 || page > logTotalPages.value) return
logPage.value = page
loadLogs()
}
const loadConfig = async () => {
try {
configList.value = await adminApi.getConfig()
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const formatConfigValue = (val) => {
if (typeof val === 'object') return JSON.stringify(val, null, 2)
return String(val)
}
const onConfigEdit = (key, e) => {
configEdits.value[key] = e.detail.value
}
const saveConfig = async (key) => {
const raw = configEdits.value[key]
if (!raw) {
uni.showToast({ title: '无改动', icon: 'none' })
return
}
try {
const value = JSON.parse(raw)
await adminApi.updateConfig(key, value)
delete configEdits.value[key]
uni.showToast({ title: '已保存', icon: 'success' })
loadConfig()
} catch (err) {
uni.showToast({ title: 'JSON 格式错误或保存失败', icon: 'none' })
}
}
watch(tab, (val) => {
if (val === 'stats') loadUsageStats()
else if (val === 'logs') { logPage.value = 1; loadLogs() }
else if (val === 'config') loadConfig()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -103,24 +442,76 @@ const formatTime = (t) => t ? t.split('T')[0] : ''
.header-card { background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 16rpx; padding: 40rpx; margin-bottom: 30rpx; } .header-card { background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 16rpx; padding: 40rpx; margin-bottom: 30rpx; }
.header-card .title { font-size: 40rpx; font-weight: 700; color: #fff; display: block; } .header-card .title { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
.header-card .subtitle { font-size: 26rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; } .header-card .subtitle { font-size: 26rpx; color: rgba(255,255,255,0.8); margin-top: 8rpx; }
.stats-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20rpx; margin-bottom: 30rpx; } .tabs { display: flex; background: #fff; border-radius: 16rpx; overflow: hidden; margin-bottom: 30rpx; }
.tab { flex: 1; text-align: center; padding: 20rpx 0; font-size: 26rpx; color: #666; font-weight: 500; }
.tab.active { color: #667eea; border-bottom: 4rpx solid #667eea; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20rpx; margin-bottom: 30rpx; }
.stat-card { background: #fff; border-radius: 16rpx; padding: 30rpx; text-align: center; } .stat-card { background: #fff; border-radius: 16rpx; padding: 30rpx; text-align: center; }
.stat-value { font-size: 48rpx; font-weight: bold; color: #667eea; display: block; } .stat-value { font-size: 48rpx; font-weight: bold; color: #667eea; display: block; }
.stat-label { font-size: 24rpx; color: #999; margin-top: 8rpx; } .stat-label { font-size: 24rpx; color: #999; margin-top: 8rpx; }
.section { background: #fff; border-radius: 16rpx; padding: 30rpx; margin-bottom: 30rpx; } .section { background: #fff; border-radius: 16rpx; padding: 30rpx; margin-bottom: 30rpx; }
.section-header { margin-bottom: 20rpx; } .section-header { margin-bottom: 20rpx; display: flex; justify-content: space-between; align-items: center; }
.section-title { font-size: 30rpx; font-weight: 600; } .section-title { font-size: 30rpx; font-weight: 600; }
.section-count { font-size: 24rpx; color: #999; }
.empty-text { text-align: center; color: #ccc; font-size: 26rpx; padding: 40rpx 0; display: block; }
.user-list { display: flex; flex-direction: column; gap: 16rpx; } .user-list { display: flex; flex-direction: column; gap: 16rpx; }
.user-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx; background: #f9f9f9; border-radius: 12rpx; } .user-item { display: flex; justify-content: space-between; align-items: center; padding: 20rpx; background: #f9f9f9; border-radius: 12rpx; flex-wrap: wrap; gap: 12rpx; }
.user-info { display: flex; align-items: center; gap: 12rpx; } .user-info { display: flex; align-items: center; gap: 12rpx; flex-wrap: wrap; }
.user-name { font-size: 28rpx; } .user-name { font-size: 28rpx; font-weight: 500; }
.user-tier { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 6rpx; } .user-tier { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 6rpx; }
.user-tier.free { background: #fff7e6; color: #fa8c16; } .user-tier.free { background: #fff7e6; color: #fa8c16; }
.user-tier.pro { background: #e6f7ff; color: #1890ff; } .user-tier.pro { background: #e6f7ff; color: #1890ff; }
.user-tier.enterprise { background: #f6ffed; color: #52c41a; } .user-tier.enterprise { background: #f6ffed; color: #52c41a; }
.user-role { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 6rpx; background: #f0f0f0; color: #666; }
.user-role.admin { background: #fff1f0; color: #f5222d; }
.user-date { font-size: 22rpx; color: #999; } .user-date { font-size: 22rpx; color: #999; }
.user-actions { display: flex; gap: 8rpx; } .user-actions { display: flex; gap: 8rpx; flex-wrap: wrap; }
.action-btn { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; background: #f0f0f0; color: #666; } .action-btn { font-size: 20rpx; padding: 4rpx 12rpx; border-radius: 6rpx; background: #f0f0f0; color: #666; }
.pro-btn { background: #e6f7ff; color: #1890ff; } .pro-btn { background: #e6f7ff; color: #1890ff; }
.enterprise-btn { background: #f6ffed; color: #52c41a; } .enterprise-btn { background: #f6ffed; color: #52c41a; }
.admin-btn { background: #f0f0f0; color: #666; }
.admin-btn.warn { background: #fff1f0; color: #f5222d; }
.toggle-btn.warn { background: #fff7e6; color: #fa8c16; }
.toggle-btn.success { background: #f6ffed; color: #52c41a; }
.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.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; }
.filter-input { flex: 1; height: 56rpx; background: #f5f5f5; border-radius: 8rpx; padding: 0 16rpx; font-size: 24rpx; }
.bar-chart { display: flex; flex-direction: column; gap: 12rpx; }
.bar-row { display: flex; align-items: center; gap: 12rpx; }
.bar-label { width: 120rpx; font-size: 22rpx; color: #666; text-align: right; flex-shrink: 0; }
.bar-track { flex: 1; height: 30rpx; background: #f0f0f0; border-radius: 15rpx; overflow: hidden; }
.bar-fill { height: 100%; background: linear-gradient(90deg, #667eea, #764ba2); border-radius: 15rpx; transition: width 0.3s; }
.bar-fill.trend { background: linear-gradient(90deg, #52c41a, #73d13d); }
.bar-value { width: 80rpx; font-size: 22rpx; color: #333; text-align: right; flex-shrink: 0; }
.log-list { display: flex; flex-direction: column; gap: 12rpx; }
.log-item { padding: 20rpx; background: #f9f9f9; border-radius: 12rpx; }
.log-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8rpx; }
.log-action { font-size: 26rpx; font-weight: 500; color: #333; }
.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; }
.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; }
.page-info { font-size: 24rpx; color: #666; }
.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 999; display: flex; align-items: center; justify-content: center; }
.modal-content { background: #fff; border-radius: 20rpx; width: 85%; max-height: 80vh; overflow-y: auto; }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 30rpx; border-bottom: 1rpx solid #f0f0f0; }
.modal-title { font-size: 32rpx; font-weight: 600; }
.modal-close { font-size: 36rpx; color: #999; padding: 0 10rpx; }
.modal-body { padding: 30rpx; }
.detail-row { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; font-size: 26rpx; }
.detail-label { color: #999; }
.detail-section-title { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin: 12rpx 0; }
.divider { height: 1rpx; background: #f0f0f0; margin: 16rpx 0; }
.text-green { color: #52c41a; }
.text-red { color: #f5222d; }
</style> </style>
+124
View File
@@ -148,6 +148,10 @@
<text class="more-icon"></text> <text class="more-icon"></text>
<text class="more-text">管理</text> <text class="more-text">管理</text>
</view> </view>
<view class="more-item" @click="showWechatModal = true">
<text class="more-icon">💁</text>
<text class="more-text">联系客服</text>
</view>
</view> </view>
</view> </view>
@@ -196,6 +200,28 @@
</view> </view>
</view> </view>
<view class="modal-overlay" v-if="showWechatModal" @click="showWechatModal = false">
<view class="contact-modal" @click.stop>
<text class="contact-title">📞 联系我们</text>
<view class="contact-body">
<view class="contact-item">
<text class="contact-label">客服微信</text>
<text class="contact-value selectable" selectable>TradeMate_Support</text>
</view>
<view class="contact-item">
<text class="contact-label">用户交流群</text>
<text class="contact-value">添加客服微信后拉你入群</text>
</view>
<view class="contact-qr-placeholder">
<text class="qr-icon">📷</text>
<text class="qr-hint">客服微信二维码</text>
</view>
<text class="contact-tip">添加好友时备注"外贸小助手"</text>
</view>
<button class="announcement-btn" @click="showWechatModal = false">知道了</button>
</view>
</view>
<view class="modal-overlay" v-if="showAnnouncement" @click="showAnnouncement = false"> <view class="modal-overlay" v-if="showAnnouncement" @click="showAnnouncement = false">
<view class="announcement-modal" @click.stop> <view class="announcement-modal" @click.stop>
<text class="announcement-title">📢 系统公告</text> <text class="announcement-title">📢 系统公告</text>
@@ -211,6 +237,19 @@
<button class="announcement-btn" @click="showAnnouncement = false; goToLogin()">去登录</button> <button class="announcement-btn" @click="showAnnouncement = false; goToLogin()">去登录</button>
</view> </view>
</view> </view>
<view class="footer">
<view class="footer-links">
<text class="footer-link" @click="goToPage('/pages/agreement/privacy')">隐私政策</text>
<text class="footer-divider">|</text>
<text class="footer-link" @click="goToPage('/pages/agreement/terms')">用户协议</text>
</view>
<view class="footer-beian">
<a class="footer-beian-link" href="https://beian.miit.gov.cn" target="_blank">京ICP备2026007249号-1</a>
<a class="footer-beian-link" href="https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545" target="_blank">京公网安备11011502039545号</a>
</view>
<text class="footer-copyright">© 2026 北京宇之然科技中心. 保留所有权利.</text>
</view>
</view> </view>
</template> </template>
@@ -242,6 +281,7 @@ const stats = ref({
const silentCustomers = ref([]) const silentCustomers = ref([])
const unreadCount = ref(0) const unreadCount = ref(0)
const followupStats = ref({ pending: 0, sent: 0, replied: 0 }) const followupStats = ref({ pending: 0, sent: 0, replied: 0 })
const showWechatModal = ref(false)
const showOnboarding = ref(false) const showOnboarding = ref(false)
const onboardingStep = ref(1) const onboardingStep = ref(1)
const productName = ref('') const productName = ref('')
@@ -913,6 +953,46 @@ const playTryResult = () => {
z-index: 999; padding: 40rpx; z-index: 999; padding: 40rpx;
} }
.contact-modal {
background: #fff;
border-radius: 20rpx;
width: 85%;
max-width: 560rpx;
max-height: 80vh;
overflow-y: auto;
padding: 40rpx;
}
.contact-title {
font-size: 32rpx;
font-weight: 600;
text-align: center;
display: block;
margin-bottom: 30rpx;
}
.contact-body { margin-bottom: 30rpx; }
.contact-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.contact-label { font-size: 26rpx; color: #666; }
.contact-value { font-size: 28rpx; color: #333; font-weight: 500; }
.contact-qr-placeholder {
display: flex;
flex-direction: column;
align-items: center;
padding: 40rpx 0;
background: #f9f9f9;
border-radius: 12rpx;
margin: 20rpx 0;
}
.qr-icon { font-size: 64rpx; margin-bottom: 16rpx; }
.qr-hint { font-size: 24rpx; color: #999; }
.qr-path { font-size: 20rpx; color: #ccc; margin-top: 8rpx; }
.contact-tip { font-size: 22rpx; color: #999; text-align: center; display: block; }
.announcement-modal { .announcement-modal {
background: #fff; background: #fff;
border-radius: 24rpx; border-radius: 24rpx;
@@ -961,4 +1041,48 @@ const playTryResult = () => {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
.footer {
margin-top: 40rpx;
padding: 40rpx 20rpx 30rpx;
text-align: center;
border-top: 2rpx solid #e8e8e8;
}
.footer-links {
display: flex;
justify-content: center;
align-items: center;
gap: 16rpx;
margin-bottom: 20rpx;
}
.footer-link {
font-size: 24rpx;
color: #1890ff;
}
.footer-divider {
font-size: 24rpx;
color: #d9d9d9;
}
.footer-beian {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: 12rpx;
}
.footer-beian-link {
font-size: 22rpx;
color: #999;
}
.footer-copyright {
font-size: 22rpx;
color: #bbb;
display: block;
}
</style> </style>
+20 -1
View File
@@ -112,8 +112,27 @@ export const productApi = {
export const adminApi = { export const adminApi = {
getDashboard: () => request('/admin/dashboard'), getDashboard: () => request('/admin/dashboard'),
listUsers: (page = 1, size = 20) => request(`/admin/users?page=${page}&size=${size}`), listUsers: (page = 1, size = 20, role) => {
let url = `/admin/users?page=${page}&size=${size}`
if (role) url += `&role=${role}`
return request(url)
},
updateUserTier: (userId, tier) => request(`/admin/users/${userId}/tier`, 'PATCH', { tier }), updateUserTier: (userId, tier) => request(`/admin/users/${userId}/tier`, 'PATCH', { tier }),
updateUserRole: (userId, role) => request(`/admin/users/${userId}/role`, 'PATCH', { role }),
toggleUserActive: (userId) => request(`/admin/users/${userId}/toggle-active`, 'POST'),
getUserDetail: (userId) => request(`/admin/users/${userId}`),
searchUsers: (q) => request(`/admin/users/search?q=${encodeURIComponent(q)}`),
getUsageStats: () => request('/admin/usage-stats'),
getLogs: (page = 1, size = 50, filters = {}) => {
let url = `/admin/logs?page=${page}&size=${size}`
if (filters.action) url += `&action=${encodeURIComponent(filters.action)}`
if (filters.user_id) url += `&user_id=${encodeURIComponent(filters.user_id)}`
if (filters.date_from) url += `&date_from=${filters.date_from}`
if (filters.date_to) url += `&date_to=${filters.date_to}`
return request(url)
},
getConfig: () => request('/admin/config'),
updateConfig: (key, value) => request(`/admin/config/${key}`, 'PUT', { value }),
} }
export const analyticsApi = { export const analyticsApi = {