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:
@@ -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
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
@@ -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
|
||||||
|
]
|
||||||
|
|||||||
@@ -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 Key(OPENAI_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` 与实际前端域名一致。
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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 "✅ 迁移完成"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 Page(Product Hunt 用)
|
||||||
|
- [ ] FAQ 文档(10 条常见问题)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、注意事项
|
||||||
|
|
||||||
|
1. **内容 > 广告** — 外贸圈是信任驱动型社群,一篇干货的价值远超竞价广告
|
||||||
|
2. **先服务后转化** — 先免费帮用户解决具体问题(分析询盘、写跟进等),再引导使用工具
|
||||||
|
3. **不要群发** — 微信群发链接会被踢,一对一私聊更有效
|
||||||
|
4. **收集反馈** — 每 10 个用户做一次电话/语音回访,了解真实使用场景
|
||||||
|
5. **关注留存而非注册** — 100 个注册但次日留存 10% 不如 30 个注册留存 60%
|
||||||
@@ -2,100 +2,439 @@
|
|||||||
<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="stats-grid">
|
<view class="tabs">
|
||||||
<view class="stat-card">
|
<view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">概览</view>
|
||||||
<text class="stat-value">{{ dashboard.users?.total || 0 }}</text>
|
<view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户</view>
|
||||||
<text class="stat-label">用户总数</text>
|
<view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">统计</view>
|
||||||
</view>
|
<view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">日志</view>
|
||||||
<view class="stat-card">
|
<view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">配置</view>
|
||||||
<text class="stat-value">{{ dashboard.teams?.total || 0 }}</text>
|
|
||||||
<text class="stat-label">团队数</text>
|
|
||||||
</view>
|
|
||||||
<view class="stat-card">
|
|
||||||
<text class="stat-value">{{ dashboard.customers?.total || 0 }}</text>
|
|
||||||
<text class="stat-label">客户总数</text>
|
|
||||||
</view>
|
|
||||||
<view class="stat-card">
|
|
||||||
<text class="stat-value">{{ dashboard.usage?.today || 0 }}</text>
|
|
||||||
<text class="stat-label">今日请求</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="section">
|
<!-- 概览 -->
|
||||||
<view class="section-header">
|
<view v-if="tab === 'overview'">
|
||||||
<text class="section-title">最近注册用户</text>
|
<view class="stats-grid">
|
||||||
|
<view class="stat-card" @click="tab='users'">
|
||||||
|
<text class="stat-value">{{ dashboard.users?.total || 0 }}</text>
|
||||||
|
<text class="stat-label">用户总数</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-card">
|
||||||
|
<text class="stat-value">{{ dashboard.teams?.total || 0 }}</text>
|
||||||
|
<text class="stat-label">团队数</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-card">
|
||||||
|
<text class="stat-value">{{ dashboard.customers?.total || 0 }}</text>
|
||||||
|
<text class="stat-label">客户总数</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-card">
|
||||||
|
<text class="stat-value">{{ dashboard.usage?.today || 0 }}</text>
|
||||||
|
<text class="stat-label">今日请求</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="user-list" v-if="dashboard.recent_users?.length">
|
<view class="section">
|
||||||
<view class="user-item" v-for="u in dashboard.recent_users" :key="u.id">
|
<view class="section-header">
|
||||||
<view class="user-info">
|
<text class="section-title">最近注册用户</text>
|
||||||
<text class="user-name">{{ u.username }}</text>
|
</view>
|
||||||
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
|
<view class="user-list" v-if="dashboard.recent_users?.length">
|
||||||
|
<view class="user-item" v-for="u in dashboard.recent_users" :key="u.id" @click="showUserDetail(u.id)">
|
||||||
|
<view class="user-info">
|
||||||
|
<text class="user-name">{{ u.username }}</text>
|
||||||
|
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
|
||||||
|
<text class="user-role" :class="u.role">{{ u.role }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="user-date">{{ formatTime(u.created_at) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="user-date">{{ formatTime(u.created_at) }}</text>
|
</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 class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">所有用户</text>
|
||||||
|
<text class="section-count">共 {{ userTotal }} 人</text>
|
||||||
|
</view>
|
||||||
|
<view class="user-list" v-if="users.length">
|
||||||
|
<view class="user-item" v-for="u in users" :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>
|
||||||
|
<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>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="section">
|
<!-- 使用统计 -->
|
||||||
<view class="section-header">
|
<view v-if="tab === 'stats'">
|
||||||
<text class="section-title">用户管理</text>
|
<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>
|
||||||
<view class="user-list" v-if="users.length">
|
<view class="section">
|
||||||
<view class="user-item" v-for="u in users" :key="u.id">
|
<view class="section-header">
|
||||||
<view class="user-info">
|
<text class="section-title">今日各功能调用</text>
|
||||||
<text class="user-name">{{ u.username || u.phone }}</text>
|
</view>
|
||||||
<text class="user-tier" :class="u.tier">{{ u.tier }}</text>
|
<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>
|
||||||
<view class="user-actions">
|
</view>
|
||||||
<text class="action-btn" @click="changeTier(u, 'free')">免费</text>
|
<text v-else class="empty-text">暂无数据</text>
|
||||||
<text class="action-btn pro-btn" @click="changeTier(u, 'pro')">Pro</text>
|
</view>
|
||||||
<text class="action-btn enterprise-btn" @click="changeTier(u, 'enterprise')">企业</text>
|
<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>
|
||||||
</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>
|
</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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user