Add landing page, referral system, usage quotas, search API management, and yearly pricing

- Separate workspace landing from login for better UX
- Referral system rewards both parties with Pro days
- Quota enforcement prevents abuse without breaking endpoints
- 7-day free trial with auto-downgrade on expiry
- Admin-managed search provider config (SearXNG, Bing)
- 15% discount on annual subscriptions
- MCP search server wrapping opencode search
- Fix discovery module field name mismatch causing 422
This commit is contained in:
TradeMate Dev
2026-05-26 11:40:13 +08:00
parent 52dba37f22
commit bed5c7abef
39 changed files with 1988 additions and 152 deletions
+13 -3
View File
@@ -2,8 +2,8 @@
<el-container class="layout-container">
<el-aside :width="collapsed ? '64px' : '220px'" class="sidebar">
<div class="sidebar-header">
<span v-show="!collapsed" class="logo-text">TradeMate</span>
<span v-show="collapsed" class="logo-text logo-sm">TM</span>
<router-link v-show="!collapsed" to="/" class="logo-text">TradeMate</router-link>
<router-link v-show="collapsed" to="/" class="logo-text logo-sm">TM</router-link>
</div>
<el-menu
:default-active="route.path"
@@ -42,6 +42,16 @@
<el-icon><List /></el-icon>
<span>发票管理</span>
</el-menu-item>
<el-sub-menu index="/system">
<template #title>
<el-icon><Tools /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item index="/system/search-config">
<el-icon><Search /></el-icon>
<span>搜索配置</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
@@ -99,7 +109,7 @@ const collapsed = ref(false)
.layout-container { height: 100vh; }
.sidebar { background: #fff; border-right: 1px solid #e8e8e8; transition: width 0.3s; overflow: hidden; }
.sidebar-header { height: 60px; display: flex; align-items: center; justify-content: center; border-bottom: 1px solid #f0f0f0; }
.logo-text { color: #1890ff; font-size: 18px; font-weight: 700; white-space: nowrap; }
.logo-text { color: #1890ff; font-size: 18px; font-weight: 700; white-space: nowrap; text-decoration: none; }
.logo-sm { font-size: 16px; }
.sidebar :deep(.el-menu) { border-right: none; }
.sidebar :deep(.el-menu-item) { margin: 2px 8px; border-radius: 8px; }
+8
View File
@@ -68,6 +68,14 @@ const routes = [
{ path: '', name: 'Invoices', component: () => import('@/views/Invoices.vue'), meta: { title: '发票管理' } },
]
},
{
path: '/system/search-config',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'SearchConfig', component: () => import('@/views/SearchConfig.vue'), meta: { title: '搜索配置' } },
]
},
]
const router = createRouter({ history: createWebHistory('/admin/'), routes })
+2 -2
View File
@@ -2,7 +2,7 @@
<div class="landing-page">
<header class="landing-header">
<div class="header-inner">
<span class="logo">Trade<span>Mate</span></span>
<router-link to="/" class="logo">Trade<span>Mate</span></router-link>
<span class="subtitle">管理后台</span>
<div class="header-right">
<el-button v-if="isLoggedIn" @click="goDashboard">进入后台</el-button>
@@ -111,7 +111,7 @@ function goDashboard() { router.push('/dashboard') }
.landing-page { min-height: 100vh; display: flex; flex-direction: column; background: #f5f5f5; }
.landing-header { background: #fff; border-bottom: 1px solid #eee; padding: 0 40px; height: 60px; display: flex; align-items: center; position: sticky; top: 0; z-index: 100; }
.header-inner { width: 100%; max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 16px; }
.logo { font-size: 20px; font-weight: 700; color: #1890ff; }
.logo { font-size: 20px; font-weight: 700; color: #1890ff; text-decoration: none; }
.logo span { color: #333; }
.subtitle { font-size: 13px; color: #999; flex: 1; }
.header-right { flex-shrink: 0; }
+216
View File
@@ -0,0 +1,216 @@
<template>
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
<h3 style="margin:0">搜索 API 配置</h3>
<el-button type="primary" @click="showAdd">添加搜索源</el-button>
</div>
<el-table :data="list" v-loading="loading" border stripe>
<el-table-column prop="name" label="名称" width="160" />
<el-table-column prop="provider_type" label="类型" width="120">
<template #default="{ row }">
<el-tag :type="typeColor(row.provider_type)">{{ typeLabel(row.provider_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="api_endpoint" label="接口地址" min-width="200">
<template #default="{ row }">
<span v-if="row.api_endpoint" style="font-size:12px;color:#999">{{ row.api_endpoint }}</span>
<span v-else style="color:#ccc">-</span>
</template>
</el-table-column>
<el-table-column prop="api_key" label="API Key" width="180">
<template #default="{ row }">
<span v-if="row.api_key" style="font-family:monospace;font-size:12px">{{ row.api_key }}</span>
<span v-else style="color:#ccc">无密钥</span>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="80" align="center" />
<el-table-column prop="enabled" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'" size="small">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="testProvider(row)">测试</el-button>
<el-button size="small" @click="editProvider(row)">编辑</el-button>
<el-popconfirm title="确认删除?" @confirm="deleteProvider(row)">
<template #reference>
<el-button size="small" type="danger" plain>删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialog.visible" :title="dialog.isEdit ? '编辑搜索源' : '添加搜索源'" width="520px">
<el-form :model="form" label-width="100px" label-position="top">
<el-form-item label="名称" required>
<el-input v-model="form.name" placeholder="例:SearXNG 主搜索" />
</el-form-item>
<el-form-item label="类型" required>
<el-select v-model="form.provider_type" style="width:100%">
<el-option value="searxng" label="SearXNG (需境外服务器自建)" />
<el-option value="bing" label="Bing Search API (国内可用)" />
</el-select>
</el-form-item>
<el-form-item v-if="form.provider_type === 'searxng'" label="接口地址" required>
<el-input v-model="form.api_endpoint" placeholder="https://your-searxng.com" />
</el-form-item>
<el-form-item v-if="form.provider_type === 'bing'" label="API Key" required>
<el-input v-model="form.api_key" placeholder="Azure Bing Search API Key" type="password" show-password />
</el-form-item>
<el-form-item v-if="form.provider_type === 'google_cse'" label="API Key" required>
<el-input v-model="form.api_key" placeholder="Google API Key" type="password" show-password />
</el-form-item>
<el-form-item v-if="form.provider_type === 'google_cse'" label="Search Engine ID" required>
<el-input v-model="form.cx" placeholder="cx=xxxx" />
</el-form-item>
<el-form-item label="优先级(越小越优先)">
<el-input-number v-model="form.priority" :min="0" :max="99" />
<span style="font-size:12px;color:#999;margin-left:8px">0=最高</span>
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialog.visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="saveProvider">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="testDialog.visible" title="测试结果" width="520px">
<div v-if="testDialog.loading" style="text-align:center;padding:24px">
<el-icon class="is-loading" :size="24"><Loading /></el-icon>
<p>正在测试...</p>
</div>
<div v-else-if="testDialog.error" style="color:#f56c6c">{{ testDialog.error }}</div>
<div v-else>
<el-alert type="success" :closable="false" style="margin-bottom:12px">搜索成功返回 {{ testDialog.results?.length }} 条结果</el-alert>
<div v-for="(r, i) in testDialog.results" :key="i" style="margin-bottom:8px;padding:8px;background:#fafafa;border-radius:4px">
<div style="font-weight:600;font-size:13px">{{ r.title }}</div>
<div style="font-size:11px;color:#999">{{ r.url }}</div>
<div style="font-size:12px;color:#666;margin-top:4px">{{ r.snippet }}</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import http from '@/api'
const loading = ref(false)
const saving = ref(false)
const list = ref([])
const dialog = reactive({ visible: false, isEdit: false, id: null })
const form = reactive({
name: '',
provider_type: 'searxng',
api_key: '',
api_endpoint: '',
cx: '',
priority: 0,
enabled: true,
})
const testDialog = reactive({ visible: false, loading: false, results: [], error: '' })
function typeLabel(t) {
const m = { searxng: 'SearXNG', bing: 'Bing' }
return m[t] || t
}
function typeColor(t) {
const m = { searxng: '', bing: 'primary' }
return m[t] || ''
}
async function fetchList() {
loading.value = true
try {
const res = await http.get('/admin/search-providers')
list.value = res.items || []
} catch (e) { ElMessage.error('加载失败') }
finally { loading.value = false }
}
function showAdd() {
dialog.isEdit = false
dialog.id = null
form.name = ''
form.provider_type = 'searxng'
form.api_key = ''
form.api_endpoint = ''
form.cx = ''
form.priority = 0
form.enabled = true
dialog.visible = true
}
function editProvider(p) {
dialog.isEdit = true
dialog.id = p.id
form.name = p.name
form.provider_type = p.provider_type
form.api_key = p.api_key || ''
form.api_endpoint = p.api_endpoint || ''
form.cx = (p.extra_config?.cx) || ''
form.priority = p.priority
form.enabled = p.enabled
dialog.visible = true
}
async function saveProvider() {
if (!form.name || !form.provider_type) { ElMessage.warning('请填写名称和类型'); return }
saving.value = true
try {
const data = {
name: form.name,
provider_type: form.provider_type,
api_key: form.api_key || null,
api_endpoint: form.api_endpoint || null,
extra_config: form.cx ? { cx: form.cx } : {},
priority: form.priority,
enabled: form.enabled,
}
if (dialog.isEdit) {
await http.put(`/admin/search-providers/${dialog.id}`, data)
ElMessage.success('已更新')
} else {
await http.post('/admin/search-providers', data)
ElMessage.success('已添加')
}
dialog.visible = false
await fetchList()
} catch (e) { ElMessage.error('保存失败') }
finally { saving.value = false }
}
async function deleteProvider(p) {
try {
await http.delete(`/admin/search-providers/${p.id}`)
ElMessage.success('已删除')
await fetchList()
} catch (e) { ElMessage.error('删除失败') }
}
async function testProvider(p) {
testDialog.visible = true
testDialog.loading = true
testDialog.results = []
testDialog.error = ''
try {
const res = await http.post(`/admin/search-providers/${p.id}/test`)
testDialog.results = res.results || []
if (!res.success) testDialog.error = res.error || '测试失败'
} catch (e) { testDialog.error = e?.detail || '请求失败' }
finally { testDialog.loading = false }
}
onMounted(fetchList)
</script>
@@ -0,0 +1,67 @@
"""add search_providers table
Revision ID: 7fe16f1f9962
Revises: ecab04cc0e1d
Create Date: 2026-05-25 10:18:37.103091
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = '7fe16f1f9962'
down_revision: Union[str, None] = 'ecab04cc0e1d'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('referral_codes',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('code', sa.String(length=20), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_referral_codes_code'), 'referral_codes', ['code'], unique=True)
op.create_index(op.f('ix_referral_codes_user_id'), 'referral_codes', ['user_id'], unique=False)
op.create_table('referrals',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('referrer_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('referred_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('code', sa.String(length=20), nullable=False),
sa.Column('reward_days', sa.Integer(), nullable=True),
sa.Column('status', sa.String(length=20), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('referred_id')
)
op.create_index(op.f('ix_referrals_referrer_id'), 'referrals', ['referrer_id'], unique=False)
op.create_table('search_providers',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('provider_type', sa.String(length=50), nullable=False),
sa.Column('api_key', sa.Text(), nullable=True),
sa.Column('api_endpoint', sa.String(length=500), nullable=True),
sa.Column('extra_config', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('priority', sa.Integer(), nullable=True),
sa.Column('enabled', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('search_providers')
op.drop_index(op.f('ix_referrals_referrer_id'), table_name='referrals')
op.drop_table('referrals')
op.drop_index(op.f('ix_referral_codes_user_id'), table_name='referral_codes')
op.drop_index(op.f('ix_referral_codes_code'), table_name='referral_codes')
op.drop_table('referral_codes')
# ### end Alembic commands ###
+189
View File
@@ -0,0 +1,189 @@
from typing import Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from app.database import get_db
from app.api.v1.deps import get_current_user
from app.models.search_provider import SearchProvider
from app.services.search import SearchService
router = APIRouter()
async def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
if current_user.get("role") != "admin":
raise HTTPException(status_code=403, detail="Admin access required")
return current_user
class ProviderCreate(BaseModel):
name: str
provider_type: str
api_key: Optional[str] = None
api_endpoint: Optional[str] = None
extra_config: Optional[dict] = None
priority: int = 0
enabled: bool = True
class ProviderUpdate(BaseModel):
name: Optional[str] = None
api_key: Optional[str] = None
api_endpoint: Optional[str] = None
extra_config: Optional[dict] = None
priority: Optional[int] = None
enabled: Optional[bool] = None
@router.get("/search-providers")
async def list_providers(
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=100),
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(SearchProvider).order_by(SearchProvider.priority).offset((page - 1) * size).limit(size)
)
providers = result.scalars().all()
total_result = await db.execute(select(SearchProvider))
total = len(total_result.scalars().all())
return {
"items": [
{
"id": str(p.id),
"name": p.name,
"provider_type": p.provider_type,
"api_key": p.api_key[:8] + "..." if p.api_key and len(p.api_key) > 8 else p.api_key,
"api_endpoint": p.api_endpoint,
"extra_config": p.extra_config,
"priority": p.priority,
"enabled": p.enabled,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
for p in providers
],
"total": total,
"page": page,
"size": size,
}
@router.post("/search-providers")
async def create_provider(
data: ProviderCreate,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
provider = SearchProvider(
name=data.name,
provider_type=data.provider_type,
api_key=data.api_key,
api_endpoint=data.api_endpoint,
extra_config=data.extra_config or {},
priority=data.priority,
enabled=data.enabled,
)
db.add(provider)
await db.flush()
return {
"id": str(provider.id),
"name": provider.name,
"provider_type": provider.provider_type,
"message": "Provider created",
}
@router.get("/search-providers/{provider_id}")
async def get_provider(
provider_id: str,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
_validate_uuid(provider_id)
result = await db.execute(select(SearchProvider).where(SearchProvider.id == provider_id))
p = result.scalar_one_or_none()
if not p:
raise HTTPException(status_code=404, detail="Provider not found")
return {
"id": str(p.id),
"name": p.name,
"provider_type": p.provider_type,
"api_key": p.api_key,
"api_endpoint": p.api_endpoint,
"extra_config": p.extra_config,
"priority": p.priority,
"enabled": p.enabled,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
@router.put("/search-providers/{provider_id}")
async def update_provider(
provider_id: str,
data: ProviderUpdate,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
_validate_uuid(provider_id)
result = await db.execute(select(SearchProvider).where(SearchProvider.id == provider_id))
p = result.scalar_one_or_none()
if not p:
raise HTTPException(status_code=404, detail="Provider not found")
if data.name is not None:
p.name = data.name
if data.api_key is not None:
p.api_key = data.api_key
if data.api_endpoint is not None:
p.api_endpoint = data.api_endpoint
if data.extra_config is not None:
p.extra_config = data.extra_config
if data.priority is not None:
p.priority = data.priority
if data.enabled is not None:
p.enabled = data.enabled
await db.flush()
return {"message": "Provider updated"}
@router.delete("/search-providers/{provider_id}")
async def delete_provider(
provider_id: str,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
_validate_uuid(provider_id)
result = await db.execute(delete(SearchProvider).where(SearchProvider.id == provider_id))
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Provider not found")
return {"message": "Provider deleted"}
@router.post("/search-providers/{provider_id}/test")
async def test_provider(
provider_id: str,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
_validate_uuid(provider_id)
result = await db.execute(select(SearchProvider).where(SearchProvider.id == provider_id))
p = result.scalar_one_or_none()
if not p:
raise HTTPException(status_code=404, detail="Provider not found")
try:
svc = SearchService(db)
results = await svc._search_provider(p, "test", 3)
return {"success": True, "results": results}
except Exception as e:
return {"success": False, "error": str(e)}
def _validate_uuid(uuid_str: str):
import uuid
try:
uuid.UUID(uuid_str)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid UUID")
+53 -1
View File
@@ -10,6 +10,11 @@ from app.core.security import hash_password, verify_password, create_access_toke
from pydantic import BaseModel, EmailStr
from datetime import datetime, timedelta
from app.services.admin import AdminService
from app.models.subscription import Subscription
from app.api.v1.referral import apply_referral
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -18,6 +23,7 @@ class RegisterRequest(BaseModel):
phone: str
password: str
username: str = ""
ref_code: str = ""
class LoginResponse(BaseModel):
@@ -47,11 +53,28 @@ async def register(data: RegisterRequest, request: Request, db: AsyncSession = D
phone=data.phone,
username=data.username or data.phone,
password_hash=hash_password(data.password),
tier="free",
tier="pro",
)
db.add(user)
await db.flush()
trial_end = datetime.utcnow() + timedelta(days=settings.TRIAL_DAYS)
sub = Subscription(
user_id=user.id,
plan="pro_trial",
status="active",
started_at=datetime.utcnow(),
expires_at=trial_end,
)
db.add(sub)
if data.ref_code:
try:
from app.api.v1.referral import do_claim_referral
await do_claim_referral(data.ref_code, str(user.id), db)
except Exception as e:
logger.warning(f"Referral claim failed: {e}")
client_ip = request.client.host if request.client else None
await AdminService(db).log_usage(str(user.id), "user.register", {"phone": data.phone}, ip=client_ip)
@@ -89,6 +112,20 @@ async def login(
client_ip = request.client.host if request.client else None
await AdminService(db).log_usage(str(user.id), "user.login", {"login_id": login_id}, ip=client_ip)
if user.tier == "pro":
sub_result = await db.execute(
select(Subscription).where(
Subscription.user_id == user.id,
Subscription.plan == "pro_trial",
Subscription.status == "active",
)
)
trial_sub = sub_result.scalar_one_or_none()
if trial_sub and trial_sub.expires_at and trial_sub.expires_at < datetime.utcnow():
trial_sub.status = "expired"
user.tier = "free"
await db.flush()
return LoginResponse(
access_token=create_access_token({"sub": str(user.id), "tier": user.tier, "role": user.role}),
refresh_token=create_refresh_token({"sub": str(user.id)}),
@@ -178,6 +215,20 @@ async def get_me(
if not user:
raise HTTPException(status_code=404, detail="User not found")
trial_days_left = 0
if user.tier == "pro":
sub_result = await db.execute(
select(Subscription).where(
Subscription.user_id == user.id,
Subscription.plan == "pro_trial",
Subscription.status == "active",
)
)
trial_sub = sub_result.scalar_one_or_none()
if trial_sub and trial_sub.expires_at:
remaining = (trial_sub.expires_at - datetime.utcnow()).days
trial_days_left = max(0, remaining)
return {
"id": str(user.id),
"phone": user.phone,
@@ -186,6 +237,7 @@ async def get_me(
"role": user.role,
"settings": user.settings,
"created_at": user.created_at.isoformat() if user.created_at else None,
"trial_days_left": trial_days_left,
}
+6
View File
@@ -5,6 +5,7 @@ from app.database import get_db
from app.services.customer import CustomerService
from app.services.customer_health import CustomerHealthService
from app.services.import_service import import_service
from app.services.usage import UsageService
from app.services import export
from app.core.security import decode_token
from app.api.v1.deps import get_current_user_id
@@ -98,8 +99,13 @@ async def create_customer(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
usage = UsageService(db)
ok, msg = await usage.check_quota(user_id, "create_customer")
if not ok:
raise HTTPException(status_code=429, detail=msg)
service = CustomerService(db)
customer = await service.create_customer(user_id, data)
await usage.record_usage(user_id, "create_customer")
return customer
+6
View File
@@ -4,6 +4,7 @@ from typing import Optional, List
from app.database import get_db
from app.services.product import ProductService
from app.services import export
from app.services.usage import UsageService
from app.api.v1.deps import get_current_user_id
from pydantic import BaseModel
import io
@@ -175,8 +176,13 @@ async def create_product(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
usage = UsageService(db)
ok, msg = await usage.check_quota(user_id, "create_product")
if not ok:
raise HTTPException(status_code=429, detail=msg)
service = ProductService(db)
product = await service.create_product(user_id, data.dict())
await usage.record_usage(user_id, "create_product")
return product
+142
View File
@@ -0,0 +1,142 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.api.v1.deps import get_current_user_id
from app.models.referral import ReferralCode, Referral
from app.models.subscription import Subscription
from app.models.user import User
from app.config import settings
from datetime import datetime, timedelta
import uuid
import secrets
import string
router = APIRouter()
def generate_code() -> str:
return "TM" + "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(6))
@router.post("/code")
async def get_or_create_code(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(ReferralCode).where(ReferralCode.user_id == user_id))
existing = result.scalar_one_or_none()
if existing:
return {"code": existing.code, "url": f"/workspace/?ref={existing.code}"}
code = generate_code()
while True:
check = await db.execute(select(ReferralCode).where(ReferralCode.code == code))
if not check.scalar_one_or_none():
break
code = generate_code()
rc = ReferralCode(user_id=user_id, code=code)
db.add(rc)
await db.commit()
return {"code": code, "url": f"/workspace/?ref={code}"}
@router.get("/stats")
async def get_referral_stats(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(Referral).where(Referral.referrer_id == user_id))
referrals = result.scalars().all()
total_reward_days = sum(r.reward_days for r in referrals if r.status == "completed")
return {
"total_referrals": len(referrals),
"completed": sum(1 for r in referrals if r.status == "completed"),
"total_reward_days": total_reward_days,
}
async def apply_referral(code: str, new_user_id: str, db: AsyncSession):
rc_result = await db.execute(select(ReferralCode).where(ReferralCode.code == code))
rc = rc_result.scalar_one_or_none()
if not rc:
return
if str(rc.user_id) == new_user_id:
return
existing = await db.execute(select(Referral).where(Referral.referred_id == new_user_id))
if existing.scalar_one_or_none():
return
reward_days = 15
referrer_sub = await db.execute(
select(Subscription).where(
Subscription.user_id == rc.user_id,
Subscription.status == "active",
).order_by(Subscription.created_at.desc()).limit(1)
)
referrer_sub_row = referrer_sub.scalar_one_or_none()
if referrer_sub_row:
old_expiry = referrer_sub_row.expires_at or datetime.utcnow()
referrer_sub_row.expires_at = old_expiry + timedelta(days=reward_days)
else:
new_sub = Subscription(
user_id=rc.user_id,
plan="pro_trial",
status="active",
started_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(days=reward_days),
)
db.add(new_sub)
user_result = await db.execute(select(User).where(User.id == rc.user_id))
u = user_result.scalar_one_or_none()
if u and u.tier == "free":
u.tier = "pro"
user_result = await db.execute(select(User).where(User.id == new_user_id))
ru = user_result.scalar_one_or_none()
if ru and ru.tier in ("free", "guest"):
ru.tier = "pro"
ref_sub = Subscription(
user_id=new_user_id,
plan="pro_trial",
status="active",
started_at=datetime.utcnow(),
expires_at=datetime.utcnow() + timedelta(days=reward_days),
)
db.add(ref_sub)
referral = Referral(
referrer_id=rc.user_id,
referred_id=new_user_id,
code=code,
reward_days=reward_days,
)
db.add(referral)
await db.flush()
@router.post("/claim")
async def claim_referral(
code: str,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
rc_result = await db.execute(select(ReferralCode).where(ReferralCode.code == code))
rc = rc_result.scalar_one_or_none()
if not rc:
raise HTTPException(status_code=404, detail="无效的邀请码")
if str(rc.user_id) == user_id:
raise HTTPException(status_code=400, detail="不能使用自己的邀请码")
existing = await db.execute(select(Referral).where(Referral.referred_id == user_id))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="已经使用过邀请码了")
await apply_referral(code, user_id, db)
await db.commit()
return {"success": True, "reward_days": 15}
+19
View File
@@ -0,0 +1,19 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.v1.deps import get_current_user_id
from app.services.search import SearchService
router = APIRouter()
@router.get("/query")
async def search(
q: str = Query(..., min_length=1, max_length=500),
limit: int = Query(10, ge=1, le=50),
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
svc = SearchService(db)
results = await svc.search(q, limit)
return {"query": q, "results": results}
+17
View File
@@ -0,0 +1,17 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.api.v1.deps import get_current_user_id
from app.services.usage import UsageService
router = APIRouter()
@router.get("/stats")
async def get_usage_stats(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
svc = UsageService(db)
stats = await svc.get_usage_stats(user_id)
return stats
+2
View File
@@ -100,6 +100,8 @@ class Settings(BaseSettings):
FREE_MAX_PRODUCTS: int = 1
FREE_DAILY_QUOTATIONS: int = 3
TRIAL_DAYS: int = 7
PRO_DAILY_TRANSLATE_CHARS: int = 50000
PRO_DAILY_REPLIES: int = 200
PRO_DAILY_MARKETING: int = 50
+8 -19
View File
@@ -141,27 +141,16 @@ class QuotaMiddleware(BaseHTTPMiddleware):
if method == "GET":
return await call_next(request)
quota_map = {
"/api/v1/translate": {
"free": settings.FREE_DAILY_TRANSLATE_CHARS,
"pro": settings.PRO_DAILY_TRANSLATE_CHARS,
},
"/api/v1/translate/reply": {
"free": settings.FREE_DAILY_REPLIES,
"pro": settings.PRO_DAILY_REPLIES,
},
"/api/v1/marketing": {
"free": settings.FREE_DAILY_MARKETING,
"pro": settings.PRO_DAILY_MARKETING,
},
"/api/v1/quotations": {
"free": settings.FREE_DAILY_QUOTATIONS,
"pro": settings.PRO_DAILY_QUOTATIONS,
},
}
quota_map = [
("/api/v1/translate/reply", {"free": settings.FREE_DAILY_REPLIES, "pro": settings.PRO_DAILY_REPLIES}),
("/api/v1/translate", {"free": settings.FREE_DAILY_TRANSLATE_CHARS, "pro": settings.PRO_DAILY_TRANSLATE_CHARS}),
("/api/v1/marketing/generate", {"free": settings.FREE_DAILY_MARKETING, "pro": settings.PRO_DAILY_MARKETING}),
("/api/v1/marketing", {"free": settings.FREE_DAILY_MARKETING, "pro": settings.PRO_DAILY_MARKETING}),
("/api/v1/quotations", {"free": settings.FREE_DAILY_QUOTATIONS, "pro": settings.PRO_DAILY_QUOTATIONS}),
]
matched_key = None
for prefix, limits in quota_map.items():
for prefix, limits in quota_map:
if path.startswith(prefix):
matched_key = prefix
break
+5 -1
View File
@@ -54,7 +54,7 @@ async def health():
return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"}
from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant, discovery, certification, invoice
from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant, discovery, certification, invoice, usage, referral, admin_search, search
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"])
@@ -81,6 +81,10 @@ app.include_router(ai_assistant.router, prefix="/api/v1/ai", tags=["ai-assistant
app.include_router(discovery.router, prefix="/api/v1/discovery", tags=["discovery"])
app.include_router(certification.router, prefix="/api/v1/certification", tags=["certification"])
app.include_router(invoice.router, prefix="/api/v1/invoices", tags=["invoices"])
app.include_router(usage.router, prefix="/api/v1/usage", tags=["usage"])
app.include_router(referral.router, prefix="/api/v1/referral", tags=["referral"])
app.include_router(admin_search.router, prefix="/api/v1/admin", tags=["admin"])
app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
if __name__ == "__main__":
+4
View File
@@ -14,6 +14,8 @@ from .system_config import SystemConfig
from .translation_quota import TranslationQuota
from .certification import Certification, CertType, CertStatus
from .invoice import Invoice, InvoiceType, InvoiceStatus
from .referral import ReferralCode, Referral
from .search_provider import SearchProvider
__all__ = [
"User", "Product",
@@ -32,4 +34,6 @@ __all__ = [
"TranslationQuota",
"Certification", "CertType", "CertStatus",
"Invoice", "InvoiceType", "InvoiceStatus",
"ReferralCode", "Referral",
"SearchProvider",
]
+26
View File
@@ -0,0 +1,26 @@
from sqlalchemy import Column, String, Integer, DateTime, ForeignKey, Boolean
from sqlalchemy.dialects.postgresql import UUID
from datetime import datetime
from app.database import Base
import uuid
class ReferralCode(Base):
__tablename__ = "referral_codes"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
code = Column(String(20), unique=True, nullable=False, index=True)
created_at = Column(DateTime, default=datetime.utcnow)
class Referral(Base):
__tablename__ = "referrals"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
referrer_id = Column(UUID(as_uuid=True), nullable=False, index=True)
referred_id = Column(UUID(as_uuid=True), nullable=False, unique=True)
code = Column(String(20), nullable=False)
reward_days = Column(Integer, default=15)
status = Column(String(20), default="completed")
created_at = Column(DateTime, default=datetime.utcnow)
+20
View File
@@ -0,0 +1,20 @@
from sqlalchemy import Column, String, Integer, DateTime, Boolean, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from datetime import datetime
from app.database import Base
import uuid
class SearchProvider(Base):
__tablename__ = "search_providers"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(100), nullable=False)
provider_type = Column(String(50), nullable=False)
api_key = Column(Text, nullable=True)
api_endpoint = Column(String(500), nullable=True)
extra_config = Column(JSONB, default={})
priority = Column(Integer, default=0)
enabled = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+24 -12
View File
@@ -218,21 +218,32 @@ URL: {company_url}
async def _ai_strategy(self, product: str, market: str) -> Dict[str, Any]:
if not self._ai_available:
return self._template_strategy(product, market)
system = """你是外贸客户发现专家。根据用户的产品和目标市场,分析出潜在买家画像和获取策略
system = """你是外贸客户发现专家。根据用户的产品和目标市场,列出15家有可能采购该产品的潜在公司
请以 JSON 格式返回(不要用 markdown 代码块标记):
{
"buyer_personas": [{"type": "", "description": "", "channels": [], "search_queries": []}],
"strategy": "",
"tips": []
}"""
prompt = f"产品:{product}\n目标市场:{market}\n请分析潜在买家画像和获取策略。"
"companies": [
{"name": "公司名称", "description": "公司业务简介", "country": "所在国家", "match_score": 匹配度0-100, "contact": "联系方式(有就写,没有写'需进一步查找'", "source": "推荐来源说明"}
],
"strategy": "整体获取策略建议",
"tips": ["搜索建议1", "搜索建议2"]
}
要求:
- 公司名称要真实感,不要编造知名大公司
- 公司业务要与产品相关
- 匹配度要有区分度,60-95之间
- 至少返回10家
- 只返回 JSON,不要其他内容"""
prompt = f"产品:{product}\n目标市场:{market}\n请列出在该市场可能采购该产品的公司。"
try:
result = await self.ai.chat(prompt, system_prompt=system)
content = result.get("reply", "")
parsed = self._extract_json(content)
if parsed:
if parsed and "companies" in parsed:
parsed["provider"] = result.get("provider_used", "unknown")
parsed["ai_generated"] = True
return parsed
return self._template_strategy(product, market)
except Exception as e:
@@ -241,13 +252,14 @@ URL: {company_url}
def _template_strategy(self, product: str, market: str) -> Dict[str, Any]:
return {
"buyer_personas": [
{"type": "进口商/批发商", "description": f"从中国进口{product}并在{market}批发的贸易商", "channels": ["LinkedIn", "Google"], "search_queries": [f"{product} importer {market}"]},
{"type": "品牌商/OEM买家", "description": f"{market}售自有品牌{product}公司", "channels": ["LinkedIn", "行业展会"], "search_queries": [f"{product} manufacturer {market}"]},
"companies": [
{"name": f"{product} Importers in {market} (示例)", "description": f"{market}从事{product}进口和批发的贸易商,建议在LinkedIn上搜索相关关键词", "country": market, "match_score": 75, "contact": "需进一步查找", "source": "AI推荐"},
{"name": f"{product} Distributors in {market} (示例)", "description": f"{market}{product}渠道商,建议通过Google搜索关键词", "country": market, "match_score": 70, "contact": "需进一步查找", "source": "AI推荐"},
],
"strategy": f"建议在 LinkedIn 和 Google 搜索 {market}{product} 相关公司",
"tips": ["使用多个搜索词", "找到公司后在 LinkedIn 找决策人"],
"strategy": f"建议在 LinkedIn 和 Google 搜索 {market}{product} 相关公司,使用导入商、批发商、经销商等关键词组合",
"tips": ["使用多个搜索词组合", "找到公司后在 LinkedIn 找决策人", "查看公司网站了解其业务范围"],
"provider": "template",
"ai_generated": True,
}
def _template_analysis(self, url: str) -> Dict[str, Any]:
+42 -3
View File
@@ -14,12 +14,16 @@ logger = logging.getLogger(__name__)
PLANS = {
"free": {"price": 0, "duration_days": None},
"pro": {"price": 99, "duration_days": 30},
"pro_yearly": {"price": 999, "duration_days": 365},
"enterprise": {"price": 399, "duration_days": 30},
"enterprise_yearly": {"price": 3999, "duration_days": 365},
}
PLAN_DESCRIPTIONS = {
"pro": "TradeMate Pro 版会员",
"pro_yearly": "TradeMate Pro 版会员(年付)",
"enterprise": "TradeMate 企业版会员",
"enterprise_yearly": "TradeMate 企业版会员(年付)",
}
@@ -41,6 +45,7 @@ class PaymentService:
"id": "free",
"name": "免费版",
"price": 0,
"period": "month",
"features": [
"1 个产品",
"20 次翻译/天",
@@ -52,6 +57,7 @@ class PaymentService:
"id": "pro",
"name": "Pro 版",
"price": 99,
"period": "month",
"features": [
"10 个产品",
"无限翻译",
@@ -60,19 +66,52 @@ class PaymentService:
"报价单生成",
],
},
{
"id": "pro_yearly",
"name": "Pro 版(年付)",
"price": 999,
"period": "year",
"original_price": 1188,
"features": [
"10 个产品",
"无限翻译",
"50 个客户",
"跟进提醒",
"报价单生成",
"省 ¥189",
],
},
{
"id": "enterprise",
"name": "企业版",
"price": 399,
"period": "month",
"features": [
"无限产品",
"多人协作",
"无限产品/客户",
"团队协作",
"品牌报价单",
"专属语料训练",
"API 接入",
"优先支持",
],
},
]
{
"id": "enterprise_yearly",
"name": "企业版(年付)",
"price": 3999,
"period": "year",
"original_price": 4788,
"features": [
"无限产品/客户",
"团队协作",
"品牌报价单",
"专属语料训练",
"API 接入",
"优先支持",
"省 ¥789",
],
},
],
}
async def get_current_subscription(self, user_id: str) -> Dict[str, Any]:
+102
View File
@@ -0,0 +1,102 @@
import logging
from typing import List, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.models.search_provider import SearchProvider
logger = logging.getLogger(__name__)
IGNORE_DOMAINS = [
"google.com", "facebook.com", "twitter.com", "instagram.com",
"youtube.com", "reddit.com", "amazon.com", "ebay.com",
"wikipedia.org", "linkedin.com", "pinterest.com", "baidu.com",
"bing.com",
]
class SearchService:
def __init__(self, db: AsyncSession):
self.db = db
async def search(self, query: str, limit: int = 10) -> List[Dict[str, str]]:
providers = await self._get_enabled_providers()
for provider in providers:
try:
return await self._search_provider(provider, query, limit)
except Exception as e:
logger.warning(f"Search provider {provider.provider_type} failed: {e}")
return []
async def _get_enabled_providers(self) -> List[SearchProvider]:
result = await self.db.execute(
select(SearchProvider)
.where(SearchProvider.enabled == True)
.order_by(SearchProvider.priority)
)
return list(result.scalars().all())
async def _search_provider(self, provider: SearchProvider, query: str, limit: int) -> List[Dict[str, str]]:
pt = provider.provider_type
if pt == "searxng":
return await searxng_search(provider.api_endpoint, query, limit)
elif pt == "bing":
return await bing_search(provider.api_key, query, limit)
else:
raise ValueError(f"Unknown provider type: {pt}")
async def searxng_search(endpoint: Optional[str], query: str, limit: int) -> List[Dict[str, str]]:
if not endpoint:
raise ValueError("SearXNG endpoint not configured")
import httpx
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
endpoint.rstrip("/") + "/search",
params={"q": query, "format": "json", "language": "zh-CN,en", "categories": "general"},
headers={"User-Agent": "TradeMate/1.0"},
)
if resp.status_code != 200:
raise ValueError(f"SearXNG returned {resp.status_code}")
data = resp.json()
results = []
for item in (data.get("results", []) if isinstance(data, dict) else data):
url = item.get("url", "")
if any(d in url for d in IGNORE_DOMAINS):
continue
results.append({
"title": (item.get("title") or url)[:100],
"url": url.rstrip("/"),
"snippet": (item.get("content") or item.get("snippet") or "")[:200],
})
if len(results) >= limit:
break
return results
async def bing_search(api_key: Optional[str], query: str, limit: int) -> List[Dict[str, str]]:
if not api_key:
raise ValueError("Bing API key not configured")
import httpx
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(
"https://api.bing.microsoft.com/v7.0/search",
params={"q": query, "count": min(limit, 50), "mkt": "en-US", "textFormat": "Raw"},
headers={"Ocp-Apim-Subscription-Key": api_key},
)
if resp.status_code != 200:
raise ValueError(f"Bing returned {resp.status_code}")
data = resp.json()
results = []
for item in data.get("webPages", {}).get("value", []):
url = item.get("url", "")
if any(d in url for d in IGNORE_DOMAINS):
continue
results.append({
"title": (item.get("name") or url)[:100],
"url": url.rstrip("/"),
"snippet": (item.get("snippet") or "")[:200],
})
if len(results) >= limit:
break
return results
+169
View File
@@ -0,0 +1,169 @@
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from fastapi import HTTPException, Depends
from datetime import datetime, date
import logging
from app.models import UsageLog, SystemConfig, User, Customer, Product
from app.models.user import User
from app.models.subscription import Subscription
from app.api.v1.deps import get_current_user_id
from app.database import get_db
logger = logging.getLogger(__name__)
TIER_LIMITS_DEFAULT = {
"free": {"translate_chars": 5000, "replies": 20, "marketing": 5, "customers": 5, "products": 1, "quotations": 3},
"pro": {"translate_chars": 50000, "replies": 200, "marketing": 50, "customers": 100, "products": 20, "quotations": 30},
"enterprise": {"translate_chars": 999999999, "replies": 9999, "marketing": 9999, "customers": 99999, "products": 9999, "quotations": 9999},
}
ACTION_MAP = {
"translate": "translate_chars",
"reply": "replies",
"marketing_generate": "marketing",
"create_customer": "customers",
"create_product": "products",
"create_quotation": "quotations",
}
class UsageService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_limits(self, tier: str) -> dict:
config_key = f"{tier}_daily_limits"
result = await self.db.execute(select(SystemConfig).where(SystemConfig.key == config_key))
row = result.scalar_one_or_none()
if row and row.value:
return {**TIER_LIMITS_DEFAULT.get(tier, {}), **row.value}
return dict(TIER_LIMITS_DEFAULT.get(tier, {}))
async def get_tier(self, user_id: str) -> str:
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
return "free"
return user.tier or "free"
async def get_daily_usage(self, user_id: str, action: str) -> int:
today = date.today()
stmt = select(func.count()).where(
UsageLog.user_id == user_id,
UsageLog.action == action,
func.cast(UsageLog.created_at, date) == today,
)
result = await self.db.execute(stmt)
return result.scalar() or 0
async def get_daily_chars(self, user_id: str) -> int:
today = date.today()
stmt = select(func.coalesce(func.sum(
(UsageLog.detail["chars"]).as_integer()
), 0)).where(
UsageLog.user_id == user_id,
UsageLog.action == "translate",
func.cast(UsageLog.created_at, date) == today,
)
result = await self.db.execute(stmt)
return result.scalar() or 0
async def get_total_count(self, user_id: str, model_class) -> int:
stmt = select(func.count()).where(model_class.user_id == user_id)
result = await self.db.execute(stmt)
return result.scalar() or 0
async def check_quota(self, user_id: str, action: str, chars: int = 0) -> tuple[bool, str]:
tier = await self.get_tier(user_id)
limits = await self.get_limits(tier)
limit_key = ACTION_MAP.get(action)
if not limit_key:
return True, ""
limit = limits.get(limit_key, 999999)
if action == "translate":
used = await self.get_daily_chars(user_id)
if used + chars > limit:
remaining = max(0, limit - used)
return False, f"今日翻译字符已达上限({limit}字符),剩余{remaining}字符。升级 Pro 获取更多额度。"
elif action in ("create_customer",):
used = await self.get_total_count(user_id, Customer)
if used >= limit:
return False, f"客户数量已达上限({limit}个)。升级 Pro 获取更多客户管理额度。"
elif action in ("create_product",):
used = await self.get_total_count(user_id, Product)
if used >= limit:
return False, f"产品数量已达上限({limit}个)。升级 Pro 获取更多产品额度。"
else:
used = await self.get_daily_usage(user_id, action)
if used >= limit:
return False, f"今日{action}次数已达上限({limit}次)。升级 Pro 获取更多额度。"
return True, ""
async def record_usage(self, user_id: str, action: str, chars: int = 0, detail: dict = None):
log = UsageLog(
user_id=user_id,
action=action,
detail=detail or {},
)
if chars:
log.detail["chars"] = chars
self.db.add(log)
await self.db.commit()
async def get_usage_stats(self, user_id: str) -> dict:
tier = await self.get_tier(user_id)
limits = await self.get_limits(tier)
trial_days_left = 0
if tier == "pro":
result = await self.db.execute(
select(Subscription).where(
Subscription.user_id == user_id,
Subscription.plan == "pro_trial",
Subscription.status == "active",
)
)
trial_sub = result.scalar_one_or_none()
if trial_sub and trial_sub.expires_at:
remaining = (trial_sub.expires_at - datetime.utcnow()).days
trial_days_left = max(0, remaining)
customer_count = await self.get_total_count(user_id, Customer)
product_count = await self.get_total_count(user_id, Product)
translate_chars = await self.get_daily_chars(user_id)
reply_count = await self.get_daily_usage(user_id, "reply")
marketing_count = await self.get_daily_usage(user_id, "marketing_generate")
quotation_count = await self.get_daily_usage(user_id, "create_quotation")
return {
"tier": tier,
"limits": limits,
"usage": {
"translate_chars": translate_chars,
"replies": reply_count,
"marketing": marketing_count,
"customers": customer_count,
"products": product_count,
"quotations": quotation_count,
},
"trial_days_left": trial_days_left,
}
def require_quota(action: str, chars_field: str = None):
async def _check(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
svc = UsageService(db)
if action == "translate" and chars_field:
raise HTTPException(status_code=400, detail="translate action needs explicit chars check")
ok, msg = await svc.check_quota(user_id, action)
if not ok:
raise HTTPException(status_code=429, detail=msg)
return user_id
return _check
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+20
View File
@@ -0,0 +1,20 @@
{
"name": "opencode-search-mcp",
"version": "1.0.0",
"description": "MCP server wrapping opencode search capabilities",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"@opencode-ai/sdk": "^1.14.41",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.7.0"
}
}
+139
View File
@@ -0,0 +1,139 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createOpencodeClient } from "@opencode-ai/sdk";
import { z } from "zod";
const server = new McpServer({
name: "opencode-search",
version: "1.0.0",
});
const client = createOpencodeClient({
baseUrl: process.env.OPENCODE_URL || "http://127.0.0.1:4096",
});
server.registerTool(
"search_files",
{
title: "Search Files",
description: "Search for files and directories by name in the opencode workspace",
inputSchema: z.object({
query: z.string(),
directory: z.string().optional(),
limit: z.number().min(1).max(200).optional(),
}),
},
async ({ query, directory, limit }) => {
try {
const results = await (client.find.files as any)({
query: {
query,
directory: directory || undefined,
limit: limit || 50,
},
});
return {
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
};
} catch (e) {
return {
content: [{ type: "text", text: `Error: ${(e as Error).message}` }],
isError: true,
};
}
}
);
server.registerTool(
"search_text",
{
title: "Search Text",
description: "Search for text content within files using regex patterns",
inputSchema: z.object({
pattern: z.string(),
directory: z.string().optional(),
}),
},
async ({ pattern, directory }) => {
try {
const results = await (client.find.text as any)({
query: {
pattern,
directory: directory || undefined,
},
});
return {
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
};
} catch (e) {
return {
content: [{ type: "text", text: `Error: ${(e as Error).message}` }],
isError: true,
};
}
}
);
server.registerTool(
"search_symbols",
{
title: "Search Symbols",
description: "Search for code symbols in the workspace",
inputSchema: z.object({
pattern: z.string(),
directory: z.string().optional(),
limit: z.number().min(1).max(200).optional(),
}),
},
async ({ pattern, directory, limit }) => {
try {
const results = await (client.find.symbols as any)({
query: {
pattern,
directory: directory || undefined,
limit: limit || 50,
},
});
return {
content: [{ type: "text", text: JSON.stringify(results, null, 2) }],
};
} catch (e) {
return {
content: [{ type: "text", text: `Error: ${(e as Error).message}` }],
isError: true,
};
}
}
);
server.registerTool(
"get_workspace_path",
{
title: "Get Workspace Path",
description: "Get the current opencode workspace path info",
inputSchema: z.object({}),
},
async () => {
try {
const path = await client.path.get();
return {
content: [{ type: "text", text: JSON.stringify(path, null, 2) }],
};
} catch (e) {
return {
content: [{ type: "text", text: `Error: ${(e as Error).message}` }],
isError: true,
};
}
}
);
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((e) => {
console.error("MCP server error:", e);
process.exit(1);
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
+30 -1
View File
@@ -209,13 +209,29 @@
<text class="ob-result-hint">你可以去"营销素材""产品库"查看更多</text>
</view>
<view class="ob-upgrade" v-if="onboardingStep === 4">
<text class="ob-upgrade-title">🚀 升级 Pro解锁全部功能</text>
<view class="ob-compare-row"><text>翻译字符/</text><text class="free">5,000</text><text class="pro">50,000</text></view>
<view class="ob-compare-row"><text>客户管理</text><text class="free">最多5个</text><text class="pro">最多100个</text></view>
<view class="ob-compare-row"><text>产品管理</text><text class="free">最多1个</text><text class="pro">最多20个</text></view>
<view class="ob-compare-row"><text>跟进提醒</text><text class="free"></text><text class="pro"></text></view>
<view class="ob-compare-row"><text>挖掘新客</text><text class="free"></text><text class="pro"></text></view>
<text class="ob-upgrade-price"> ¥99/</text>
</view>
<view class="ob-actions">
<button class="ob-btn ob-btn-primary" @click="onboardingNext" v-if="onboardingStep === 1">
开始生成
</button>
<button class="ob-btn ob-btn-primary" @click="finishOnboarding" v-if="onboardingStep === 3">
<button class="ob-btn ob-btn-primary" @click="onboardingStep = 4" v-if="onboardingStep === 3">
开始使用
</button>
<button class="ob-btn ob-btn-primary" @click="goUpgrade" v-if="onboardingStep === 4">
升级 Pro
</button>
<button class="ob-btn ob-btn-secondary" @click="finishOnboarding" v-if="onboardingStep === 4">
暂时不用
</button>
</view>
<text class="ob-skip" @click="finishOnboarding" v-if="onboardingStep === 1">跳过以后再说</text>
@@ -403,6 +419,11 @@ const finishOnboarding = () => {
loadData()
}
const goUpgrade = () => {
finishOnboarding()
uni.navigateTo({ url: PAGES.UPGRADE })
}
const loadData = async () => {
try {
const [userRes, silentRes, overviewRes] = await Promise.all([
@@ -1111,8 +1132,16 @@ const playTryResult = () => {
.ob-actions { margin-top: 32rpx; }
.ob-btn { width: 100%; height: 88rpx; border-radius: 12rpx; font-size: 30rpx; border: none; display: flex; align-items: center; justify-content: center; }
.ob-btn-primary { background: #1890ff; color: #fff; }
.ob-btn-secondary { background: #f5f5f5; color: #666; margin-top: 12rpx; }
.ob-skip { display: block; text-align: center; margin-top: 24rpx; font-size: 24rpx; color: #999; }
.ob-upgrade { margin: 20rpx 0; }
.ob-upgrade-title { font-size: 30rpx; font-weight: 600; color: #333; display: block; text-align: center; margin-bottom: 24rpx; }
.ob-compare-row { display: flex; justify-content: space-between; padding: 16rpx 0; border-bottom: 1px solid #f0f0f0; font-size: 26rpx; }
.ob-compare-row .free { color: #999; width: 80rpx; text-align: center; }
.ob-compare-row .pro { color: #1890ff; font-weight: 500; width: 80rpx; text-align: center; }
.ob-upgrade-price { display: block; text-align: center; margin-top: 20rpx; font-size: 28rpx; color: #ff4d4f; font-weight: 600; }
.modal-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
+16
View File
@@ -19,4 +19,20 @@
.el-button--primary { --el-button-bg-color: #1890ff; --el-button-border-color: #1890ff; --el-button-hover-bg-color: #40a9ff; --el-button-hover-border-color: #40a9ff; --el-button-active-bg-color: #096dd9; --el-button-active-border-color: #096dd9; }
.el-tag--primary { --el-tag-bg-color: #e6f7ff; --el-tag-border-color: #91d5ff; --el-tag-text-color: #1890ff; }
a { color: #1890ff; }
.el-table { width: 100%; }
.el-table__body-wrapper { overflow-x: auto; }
@media (max-width: 768px) {
.el-dialog { width: calc(100% - 20px) !important; max-width: 100% !important; }
.el-dialog__body { padding: 16px !important; }
.el-form-item { margin-bottom: 14px !important; }
.el-card__body { padding: 16px !important; }
.el-tabs__content { padding: 0 !important; }
.el-table { font-size: 12px; }
.el-table .cell { padding-left: 6px !important; padding-right: 6px !important; }
.el-empty { padding: 20px 0 !important; }
[class*="el-col-"] { margin-bottom: 12px; }
.el-row--flex { flex-wrap: wrap; }
}
</style>
+2
View File
@@ -110,4 +110,6 @@ export function submitFeedback(data) { return http.post('/feedback', data) }
export function sendWhatsApp(data) { return http.post('/whatsapp/send', data) }
export function getUsageStats() { return http.get('/usage/stats') }
export default http
+36 -15
View File
@@ -1,16 +1,22 @@
<template>
<div class="user-layout">
<aside class="sidebar" :class="{ collapsed }">
<div class="sidebar-mask" v-if="showMobileMenu" @click="showMobileMenu = false" />
<aside class="sidebar" :class="{ collapsed, 'mobile-show': showMobileMenu }">
<div class="sidebar-header">
<span class="logo">{{ collapsed ? 'TM' : 'TradeMate' }}</span>
<router-link to="/workspace" class="logo">{{ collapsed ? 'TM' : 'TradeMate' }}</router-link>
<el-button v-if="showMobileMenu" text style="color:#999;font-size:20px;margin-left:auto" @click="showMobileMenu = false">
<el-icon><Close /></el-icon>
</el-button>
</div>
<el-menu
:default-active="route.path"
:collapse="collapsed"
router
:collapse-transition="false"
@select="showMobileMenu = false"
>
<el-menu-item index="/"><el-icon><Odometer /></el-icon><span>工作台</span></el-menu-item>
<el-menu-item index="/workspace"><el-icon><Odometer /></el-icon><span>工作台</span></el-menu-item>
<el-menu-item index="/translate"><el-icon><ChatLineSquare /></el-icon><span>智能翻译</span></el-menu-item>
<el-menu-item index="/customers"><el-icon><User /></el-icon><span>客户管理</span></el-menu-item>
<el-menu-item index="/products"><el-icon><Goods /></el-icon><span>产品库</span></el-menu-item>
@@ -25,11 +31,11 @@
<div class="main-area">
<header class="topbar">
<el-button text @click="collapsed = !collapsed" style="font-size:18px;margin-right:12px">
<el-icon><Fold v-if="!collapsed" /><Expand v-else /></el-icon>
<el-button text class="menu-btn" @click="showMobileMenu = true">
<el-icon :size="20"><Expand /></el-icon>
</el-button>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="'/'">工作台</el-breadcrumb-item>
<el-breadcrumb separator="/" class="breadcrumb">
<el-breadcrumb-item :to="'/workspace'">工作台</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta?.title" :to="route.path">{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
<div class="topbar-right">
@@ -41,7 +47,7 @@
<el-dropdown trigger="click">
<el-button text style="display:flex;align-items:center;gap:6px">
<el-icon><User /></el-icon>
<span>{{ auth.user?.username || '用户' }}</span>
<span class="user-name">{{ auth.user?.username || '用户' }}</span>
<el-icon><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
@@ -60,14 +66,14 @@
</main>
<footer class="footer">
<span>TradeMate 外贸小助手 &copy; {{ new Date().getFullYear() }}</span>
<span>TradeMate &copy; {{ new Date().getFullYear() }}</span>
</footer>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { getUnreadCount } from '@/api'
@@ -76,10 +82,10 @@ const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const collapsed = ref(false)
const showMobileMenu = ref(false)
const unread = ref(0)
onMounted(async () => {
await auth.fetchUser()
try {
const res = await getUnreadCount()
unread.value = res.count || res || 0
@@ -88,22 +94,37 @@ onMounted(async () => {
function handleLogout() {
auth.logout()
router.push('/login')
router.push('/')
}
</script>
<style scoped>
.user-layout { display: flex; height: 100vh; overflow: hidden; }
.sidebar-mask { display: none; }
.sidebar { width: 220px; background: #fff; border-right: 1px solid #e8e8e8; transition: width 0.25s; flex-shrink: 0; display: flex; flex-direction: column; }
.sidebar.collapsed { width: 64px; }
.sidebar-header { height: 60px; display: flex; align-items: center; justify-content: center; color: #1890ff; font-size: 18px; font-weight: 700; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; }
.sidebar-header { height: 60px; display: flex; align-items: center; padding: 0 16px; color: #1890ff; font-size: 18px; font-weight: 700; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; }
.sidebar :deep(.el-menu) { border-right: none; flex: 1; }
.sidebar :deep(.el-menu-item) { margin: 2px 8px; border-radius: 8px; }
.sidebar :deep(.el-menu-item.is-active) { background: #e6f7ff; color: #1890ff !important; font-weight: 500; }
.main-area { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar { height: 60px; background: #fff; border-bottom: 1px solid #e8e8e8; display: flex; align-items: center; padding: 0 24px; flex-shrink: 0; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.topbar { height: 60px; background: #fff; border-bottom: 1px solid #e8e8e8; display: flex; align-items: center; padding: 0 16px; flex-shrink: 0; gap: 12px; }
.menu-btn { display: none; font-size: 20px; }
.breadcrumb { flex: 1; min-width: 0; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.notif-badge :deep(.el-badge__content) { top: 8px; right: 4px; }
.content { flex: 1; padding: 24px; overflow-y: auto; background: #f5f5f5; }
.footer { text-align: center; padding: 12px; color: #999; font-size: 12px; border-top: 1px solid #e8e8e8; background: #fff; flex-shrink: 0; }
@media (max-width: 768px) {
.sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 1000; transition: left 0.3s; }
.sidebar.mobile-show { left: 0; }
.sidebar.collapsed { width: 220px; }
.sidebar.collapsed.mobile-show { left: 0; }
.sidebar-mask { display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 999; opacity: 0; pointer-events: none; transition: opacity 0.3s; }
.sidebar-mask:has(+ .sidebar.mobile-show) { opacity: 1; pointer-events: auto; }
.menu-btn { display: inline-flex; }
.user-name { display: none; }
.content { padding: 16px; }
}
</style>
+124 -18
View File
@@ -1,28 +1,134 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{ path: '/login', redirect: '/' },
{ path: '/', name: 'Landing', component: () => import('@/views/WorkspaceLanding.vue') },
{
path: '/',
path: '/workspace',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Workspace', component: () => import('@/views/Workspace.vue'), meta: { title: '工作台' } },
{ path: 'translate', name: 'Translate', component: () => import('@/views/Translate.vue'), meta: { title: '智能翻译' } },
{ path: 'customers', name: 'Customers', component: () => import('@/views/Customers.vue'), meta: { title: '客户管理' } },
{ path: 'products', name: 'Products', component: () => import('@/views/Products.vue'), meta: { title: '产品库' } },
{ path: 'quotations', name: 'Quotations', component: () => import('@/views/Quotations.vue'), meta: { title: '报价单' } },
{ path: 'marketing', name: 'Marketing', component: () => import('@/views/Marketing.vue'), meta: { title: '营销素材' } },
{ path: 'discovery', name: 'Discovery', component: () => import('@/views/Discovery.vue'), meta: { title: '挖掘新客' } },
{ path: 'followup', name: 'Followup', component: () => import('@/views/Followup.vue'), meta: { title: '智能跟进' } },
{ path: 'analytics', name: 'Analytics', component: () => import('@/views/Analytics.vue'), meta: { title: '数据分析' } },
{ path: 'team', name: 'Team', component: () => import('@/views/Team.vue'), meta: { title: '团队协作' } },
{ path: 'notifications', name: 'Notifications', component: () => import('@/views/Notifications.vue'), meta: { title: '通知中心' } },
{ path: 'profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人中心' } },
{ path: 'upgrade', name: 'Upgrade', component: () => import('@/views/Upgrade.vue'), meta: { title: '升级会员' } },
{ path: 'certification', name: 'Certification', component: () => import('@/views/Certification.vue'), meta: { title: '实名认证' } },
{ path: 'invoice', name: 'Invoice', component: () => import('@/views/Invoice.vue'), meta: { title: '发票管理' } },
{ path: 'feedback', name: 'Feedback', component: () => import('@/views/Feedback.vue'), meta: { title: '意见反馈' } },
]
},
{
path: '/translate',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Translate', component: () => import('@/views/Translate.vue'), meta: { title: '智能翻译' } },
]
},
{
path: '/customers',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Customers', component: () => import('@/views/Customers.vue'), meta: { title: '客户管理' } },
]
},
{
path: '/products',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Products', component: () => import('@/views/Products.vue'), meta: { title: '产品库' } },
]
},
{
path: '/quotations',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Quotations', component: () => import('@/views/Quotations.vue'), meta: { title: '报价单' } },
]
},
{
path: '/marketing',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Marketing', component: () => import('@/views/Marketing.vue'), meta: { title: '营销素材' } },
]
},
{
path: '/discovery',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Discovery', component: () => import('@/views/Discovery.vue'), meta: { title: '挖掘新客' } },
]
},
{
path: '/followup',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Followup', component: () => import('@/views/Followup.vue'), meta: { title: '智能跟进' } },
]
},
{
path: '/analytics',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Analytics', component: () => import('@/views/Analytics.vue'), meta: { title: '数据分析' } },
]
},
{
path: '/team',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Team', component: () => import('@/views/Team.vue'), meta: { title: '团队协作' } },
]
},
{
path: '/notifications',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Notifications', component: () => import('@/views/Notifications.vue'), meta: { title: '通知中心' } },
]
},
{
path: '/profile',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人中心' } },
]
},
{
path: '/upgrade',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Upgrade', component: () => import('@/views/Upgrade.vue'), meta: { title: '升级会员' } },
]
},
{
path: '/certification',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Certification', component: () => import('@/views/Certification.vue'), meta: { title: '实名认证' } },
]
},
{
path: '/invoice',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Invoice', component: () => import('@/views/Invoice.vue'), meta: { title: '发票管理' } },
]
},
{
path: '/feedback',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Feedback', component: () => import('@/views/Feedback.vue'), meta: { title: '意见反馈' } },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
@@ -33,7 +139,7 @@ const router = createRouter({ history: createWebHistory('/workspace/'), routes }
router.beforeEach((to, from, next) => {
if (to.meta?.requiresAuth) {
const token = localStorage.getItem('token')
if (!token) next({ name: 'Login', query: { redirect: to.fullPath } })
if (!token) next({ name: 'Landing', query: { redirect: to.fullPath } })
else next()
} else {
next()
+3 -3
View File
@@ -1,7 +1,7 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="6" v-for="item in cards" :key="item.label">
<el-col :xs="12" :sm="6" v-for="item in cards" :key="item.label">
<el-card shadow="hover" style="text-align:center;margin-bottom:20px">
<div class="card-value">{{ item.value }}</div>
<div class="card-label">{{ item.label }}</div>
@@ -10,7 +10,7 @@
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header><span>客户状态分布</span></template>
<div style="padding:20px;text-align:center">
@@ -22,7 +22,7 @@
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header><span>国家分布 Top 10</span></template>
<div style="padding:20px">
+8 -2
View File
@@ -67,7 +67,10 @@ async function search() {
loading.value = true
searched.value = true
try {
const res = await discoverySearch(form.value)
const res = await discoverySearch({
product_description: form.value.product,
target_market: form.value.market || 'US',
})
const d = res.data || res
results.value = d.companies || d.items || d.results || d || []
} catch { ElMessage.error('挖掘失败') }
@@ -85,7 +88,10 @@ async function generateOutreach() {
if (!outForm.value.company || !outForm.value.product) { ElMessage.warning('请填写完整'); return }
outLoading.value = true
try {
const res = await discoveryOutreach(outForm.value)
const res = await discoveryOutreach({
company: { name: outForm.value.company, channel: outForm.value.channel },
product: { name: outForm.value.product },
})
outreachResult.value = res.data?.content || res.content || res.text || ''
} catch { ElMessage.error('生成失败') }
finally { outLoading.value = false }
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="6" v-for="s in statItems" :key="s.label">
<el-col :xs="12" :sm="6" v-for="s in statItems" :key="s.label">
<el-card shadow="hover" style="text-align:center;margin-bottom:16px">
<div class="stat-num">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
+10 -11
View File
@@ -1,13 +1,13 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="8">
<el-col :xs="24" :sm="8">
<el-card shadow="never">
<div style="text-align:center;padding:20px 0">
<el-avatar :size="72" style="background:#409eff;font-size:28px">{{ (user?.username || 'U')[0].toUpperCase() }}</el-avatar>
<h3 style="margin:12px 0 4px">{{ user?.username || '用户' }}</h3>
<el-tag size="small" :type="tierType(user?.tier)">{{ user?.tier || 'free' }}</el-tag>
<el-tag v-if="user?.role === 'admin'" type="danger" size="small" style="margin-left:4px">管理员</el-tag>
<el-avatar :size="72" style="background:#1890ff;font-size:28px">{{ (auth.user?.username || 'U')[0].toUpperCase() }}</el-avatar>
<h3 style="margin:12px 0 4px">{{ auth.user?.username || '用户' }}</h3>
<el-tag size="small" :type="tierType(auth.user?.tier)">{{ auth.user?.tier || 'free' }}</el-tag>
<el-tag v-if="auth.user?.role === 'admin'" type="danger" size="small" style="margin-left:4px">管理员</el-tag>
</div>
<el-divider style="margin:8px 0" />
<div class="profile-menu">
@@ -29,7 +29,7 @@
</div>
</el-card>
</el-col>
<el-col :span="16">
<el-col :xs="24" :sm="16">
<el-card shadow="never">
<template #header><span>编辑资料</span></template>
<el-form :model="form" label-width="80" size="large">
@@ -71,7 +71,6 @@ import { updateProfile, changePassword } from '@/api'
import { ElMessage } from 'element-plus'
const auth = useAuthStore()
const user = auth.user
const saving = ref(false)
const showPassword = ref(false)
const pwLoading = ref(false)
@@ -81,10 +80,10 @@ const pwForm = reactive({ old_password: '', new_password: '' })
function tierType(t) { return { free: 'warning', pro: 'primary', enterprise: 'success' }[t] || 'info' }
onMounted(() => {
if (user.value) {
form.username = user.value.username || ''
form.email = user.value.email || ''
form.phone = user.value.phone || ''
if (auth.user) {
form.username = auth.user.username || ''
form.email = auth.user.email || ''
form.phone = auth.user.phone || ''
}
})
+1 -1
View File
@@ -3,7 +3,7 @@
<el-card shadow="never">
<template #header><span>文本翻译</span></template>
<el-input v-model="form.text" type="textarea" :rows="5" placeholder="输入需要翻译的外贸文本..." />
<div style="margin:16px 0;display:flex;gap:12px;align-items:center">
<div style="margin:16px 0;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<el-select v-model="form.target_lang" style="width:160px">
<el-option label="英语" value="en" />
<el-option label="中文" value="zh" />
+15 -9
View File
@@ -1,24 +1,29 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="8" v-for="p in plans" :key="p.id">
<el-card shadow="hover" :class="{ 'plan-highlight': p.id === currentPlan }">
<el-col :xs="24" :sm="8" v-for="p in plans" :key="p.id">
<el-card shadow="hover" :class="{ 'plan-highlight': p.id === currentPlan, 'plan-yearly': p.period === 'year' }">
<template #header>
<div style="text-align:center">
<el-tag v-if="p.period === 'year'" type="success" size="small" style="margin-bottom:8px">年付省 {{ (p.original_price || p.price * 12) - p.price }} </el-tag>
<h3 style="margin:0">{{ p.name }}</h3>
<p style="font-size:28px;font-weight:700;color:#409eff;margin:12px 0">
¥{{ p.price || 0 }}<span style="font-size:14px;font-weight:400;color:#999">/</span>
<p style="font-size:28px;font-weight:700;color:#1890ff;margin:12px 0">
¥{{ p.price }}<span style="font-size:14px;font-weight:400;color:#999">/{{ p.period === 'year' ? '年' : '月' }}</span>
</p>
<p v-if="p.original_price" style="font-size:12px;color:#999;margin:-8px 0 0">
<del>¥{{ p.original_price }}/</del>{{ Math.round((1 - p.price / p.original_price) * 100) }}% 优惠
</p>
</div>
</template>
<div>
<p v-for="f in p.features || []" :key="f" style="font-size:13px;color:#666;margin:8px 0">
<el-icon color="#67c23a" style="margin-right:6px"><Check /></el-icon>{{ f }}
<el-icon color="#52c41a" style="margin-right:6px"><Check /></el-icon>{{ f }}
</p>
</div>
<div style="text-align:center;margin-top:16px">
<el-button v-if="p.id === currentPlan" type="default" disabled>当前套餐</el-button>
<el-button v-else type="primary" :loading="loadingId === p.id" @click="upgrade(p.id)">升级</el-button>
<el-button v-else-if="p.id === 'free'" @click="handleFree">当前套餐</el-button>
<el-button v-else type="primary" :loading="loadingId === p.id" @click="upgrade(p.id)">{{ p.price === 0 ? '当前套餐' : '升级' }}</el-button>
</div>
</el-card>
</el-col>
@@ -40,7 +45,7 @@ onMounted(async () => {
try {
const [plansRes, subRes] = await Promise.all([getPlans(), getSubscription().catch(() => null)])
const pd = plansRes.data || plansRes
plans.value = pd.plans || pd.items || pd || []
plans.value = (pd.plans || pd.items || pd || []).filter(p => p.id !== 'free')
if (subRes) {
const sd = subRes.data || subRes
currentPlan.value = sd.plan_id || sd.plan
@@ -51,7 +56,7 @@ onMounted(async () => {
async function upgrade(planId) {
loadingId.value = planId
try {
const res = await createOrder(planId)
const res = await createOrder(planId, 'native')
ElMessage.success('订单已创建' + (res.pay_url ? ',正在跳转支付...' : ''))
if (res.pay_url) window.open(res.pay_url)
} catch (e) { ElMessage.error(e?.detail || '升级失败') }
@@ -60,5 +65,6 @@ async function upgrade(planId) {
</script>
<style scoped>
.plan-highlight { border: 2px solid #409eff; transform: scale(1.02); }
.plan-highlight { border: 2px solid #1890ff; transform: scale(1.02); }
.plan-yearly { border: 2px solid #52c41a; }
</style>
+215 -50
View File
@@ -1,94 +1,229 @@
<template>
<div>
<div class="workspace">
<div class="welcome-section">
<div class="welcome-left">
<h2>你好{{ auth.user?.username || '用户' }}</h2>
<p class="welcome-desc">欢迎回来以下是你的业务概览</p>
</div>
<div class="welcome-right">
<el-button type="primary" @click="$router.push('/translate')">快速翻译</el-button>
<el-button @click="$router.push('/customers')">客户管理</el-button>
</div>
</div>
<el-alert
v-if="usageStats.tier === 'pro' && usageStats.trial_days_left > 0"
:title="'Pro 试用中,剩余 ' + usageStats.trial_days_left + ' 天'"
type="success"
show-icon
:closable="false"
style="margin-bottom:16px"
>
<template #default>
<span>试用结束后将自动恢复为免费版 <el-button text type="primary" size="small" @click="showUpgrade = true">立即升级正式版</el-button></span>
</template>
</el-alert>
<el-row :gutter="20">
<el-col :span="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ item.value }}</div>
<el-col :xs="12" :sm="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover" class="stat-card" @click="item.route && $router.push(item.route)">
<div class="stat-value" :style="{ color: item.color }">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="section-title">本月用量</span>
<el-button v-if="usageStats.tier !== 'enterprise'" text type="primary" size="small" @click="showUpgrade = true">升级以获取更多额度</el-button>
</div>
</template>
<div class="usage-grid">
<div v-for="u in usageItems" :key="u.key" class="usage-item">
<div class="usage-label">
<span>{{ u.label }}</span>
<span class="usage-value">{{ u.used }} / {{ u.limit === 999999999 ? '∞' : u.limit }}</span>
</div>
<el-progress :percentage="u.pct" :color="u.pct > 80 ? '#ff4d4f' : u.pct > 50 ? '#faad14' : '#52c41a'" :stroke-width="12" />
</div>
</div>
</el-card>
<el-card shadow="never" class="section-card">
<template #header><span class="section-title">功能矩阵</span></template>
<div class="feature-grid">
<div v-for="f in features" :key="f.title" class="feature-card" @click="$router.push(f.route)">
<div class="feature-icon" :style="{ background: f.color + '15' }">
<el-icon :size="26" :color="f.color"><component :is="f.icon" /></el-icon>
</div>
<div class="feature-info">
<h4>{{ f.title }}</h4>
<p>{{ f.desc }}</p>
</div>
</div>
</div>
</el-card>
<el-row :gutter="20" style="margin-top:20px">
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8">
<el-card shadow="never">
<template #header><span>快速翻译</span></template>
<el-input v-model="quickText" type="textarea" :rows="3" placeholder="输入要翻译的文本..." />
<template #header><span class="section-title">快速翻译</span></template>
<el-input v-model="quickText" type="textarea" :rows="4" placeholder="输入要翻译的文本..." />
<div style="margin-top:12px;display:flex;gap:8px">
<el-select v-model="quickLang" style="width:140px">
<el-select v-model="quickLang" style="width:130px" size="default">
<el-option label="英语" value="en" />
<el-option label="中文" value="zh" />
<el-option label="西班牙语" value="es" />
<el-option label="西语" value="es" />
<el-option label="日语" value="ja" />
</el-select>
<el-button type="primary" :loading="translating" @click="doQuickTranslate">翻译</el-button>
</div>
<p v-if="quickResult" style="margin-top:12px;padding:12px;background:#f5f5f5;border-radius:6px">{{ quickResult }}</p>
<p v-if="quickResult" class="quick-result">{{ quickResult }}</p>
</el-card>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8">
<el-card shadow="never">
<template #header><span>跟进提醒</span></template>
<template #header>
<div class="card-header">
<span class="section-title">跟进提醒</span>
<el-button text type="primary" size="small" @click="$router.push('/followup')">查看全部</el-button>
</div>
</template>
<div v-if="followups.length">
<div v-for="f in followups" :key="f.id" class="followup-item">
<span class="followup-name">{{ f.customer_name }}</span>
<span class="followup-days">{{ f.silent_days }}天未联系</span>
<div v-for="f in followups" :key="f.id" class="list-item">
<div class="list-item-left">
<span class="list-item-name">{{ f.customer_name }}</span>
<span class="list-item-meta">{{ f.silent_days }}天未联系</span>
</div>
<el-button text type="primary" size="small" @click="$router.push('/followup')">去跟进</el-button>
</div>
</div>
<el-empty v-else description="暂无跟进提醒" :image-size="60" />
<el-empty v-else description="暂无跟进提醒" :image-size="50" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="section-title">沉默客户</span>
<el-button text type="primary" size="small" @click="$router.push('/customers')">查看全部</el-button>
</div>
</template>
<div v-if="silentCustomers.length">
<div v-for="c in silentCustomers" :key="c.id" class="list-item">
<div class="list-item-left">
<span class="list-item-name">{{ c.name }}</span>
<span class="list-item-meta">{{ c.silent_days || '?' }}天未联系</span>
</div>
<el-tag :type="(c.silent_days || 0) > 14 ? 'danger' : 'warning'" size="small">{{ c.silent_days || 0 }}</el-tag>
</div>
</div>
<el-empty v-else description="暂无沉默客户" :image-size="50" />
</el-card>
</el-col>
</el-row>
<el-card shadow="never" style="margin-top:20px">
<template #header><span>功能入口</span></template>
<div class="feature-grid">
<div v-for="f in features" :key="f.title" class="feature-item" @click="$router.push(f.route)">
<el-icon :size="24" :color="f.color"><component :is="f.icon" /></el-icon>
<span>{{ f.title }}</span>
</div>
<el-dialog v-model="showUpgrade" title="升级套餐" width="700">
<el-table :data="planData" border>
<el-table-column label="功能" prop="feature" width="140" />
<el-table-column label="免费版" width="160">
<template #default="{ row }"><span v-html="row.free" /></template>
</el-table-column>
<el-table-column label="Pro ¥99/月" width="160">
<template #default="{ row }"><span v-html="row.pro" /></template>
</el-table-column>
<el-table-column label="企业 ¥399/月" width="160">
<template #default="{ row }"><span v-html="row.enterprise" /></template>
</el-table-column>
</el-table>
<div style="text-align:center;margin-top:20px">
<el-button type="primary" size="large" @click="$router.push('/upgrade')">立即升级</el-button>
</div>
</el-card>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAnalyticsOverview, translate, getFollowupPending } from '@/api'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { getAnalyticsOverview, translate, getFollowupPending, getSilentCustomers, getUsageStats } from '@/api'
const auth = useAuthStore()
const stats = ref([])
const quickText = ref('')
const quickLang = ref('en')
const quickResult = ref('')
const translating = ref(false)
const followups = ref([])
const silentCustomers = ref([])
const usageStats = ref({ tier: 'free', limits: {}, usage: {} })
const showUpgrade = ref(false)
const usageItems = computed(() => {
const u = usageStats.value.usage || {}
const l = usageStats.value.limits || {}
const tier = usageStats.value.tier || 'free'
const items = [
{ key: 'translate_chars', label: '翻译字符', used: u.translate_chars || 0, limit: l.translate_chars || 0 },
{ key: 'replies', label: '回复建议', used: u.replies || 0, limit: l.replies || 0 },
{ key: 'marketing', label: '营销生成', used: u.marketing || 0, limit: l.marketing || 0 },
{ key: 'customers', label: '客户数', used: u.customers || 0, limit: l.customers || 0 },
{ key: 'products', label: '产品数', used: u.products || 0, limit: l.products || 0 },
{ key: 'quotations', label: '报价单', used: u.quotations || 0, limit: l.quotations || 0 },
]
return items.map(i => ({
...i,
pct: i.limit >= 999999 ? 0 : Math.min(100, Math.round((i.used / (i.limit || 1)) * 100)),
}))
})
const planData = [
{ feature: '翻译字符/天', free: '5,000', pro: '50,000', enterprise: '∞' },
{ feature: '回复建议/天', free: '20', pro: '200', enterprise: '∞' },
{ feature: '营销生成/天', free: '5', pro: '50', enterprise: '∞' },
{ feature: '客户管理', free: '最多5个', pro: '最多100个', enterprise: '∞' },
{ feature: '产品管理', free: '最多1个', pro: '最多20个', enterprise: '∞' },
{ feature: '报价单/天', free: '3', pro: '30', enterprise: '∞' },
{ feature: '跟进提醒', free: '—', pro: '✓', enterprise: '✓' },
{ feature: 'WhatsApp 集成', free: '—', pro: '✓', enterprise: '✓' },
{ feature: '挖掘新客', free: '—', pro: '✓', enterprise: '✓' },
{ feature: '团队协作', free: '—', pro: '—', enterprise: '✓' },
]
const features = [
{ title: '智能翻译', icon: 'ChatLineSquare', color: '#409eff', route: '/translate' },
{ title: '客户管理', icon: 'User', color: '#67c23a', route: '/customers' },
{ title: '产品库', icon: 'Goods', color: '#e6a23c', route: '/products' },
{ title: '报价单', icon: 'DocumentCopy', color: '#f56c6c', route: '/quotations' },
{ title: '营销素材', icon: 'Promotion', color: '#909399', route: '/marketing' },
{ title: '挖掘新客', icon: 'Search', color: '#409eff', route: '/discovery' },
{ title: '智能跟进', icon: 'Message', color: '#67c23a', route: '/followup' },
{ title: '数据分析', icon: 'DataAnalysis', color: '#e6a23c', route: '/analytics' },
{ title: '团队协作', icon: 'UserFilled', color: '#f56c6c', route: '/team' },
{ title: '智能翻译', desc: '多语言翻译 + 回复建议 + 信息提取', icon: 'ChatLineSquare', color: '#1890ff', route: '/translate' },
{ title: '客户管理', desc: 'CRM + 健康评分 + 跟进记录', icon: 'User', color: '#52c41a', route: '/customers' },
{ title: '产品库', desc: '双语产品管理 + 关键词标签', icon: 'Goods', color: '#faad14', route: '/products' },
{ title: '报价单', desc: 'AI 智能报价 + PDF 导出 + 状态追踪', icon: 'DocumentCopy', color: '#ff4d4f', route: '/quotations' },
{ title: '营销素材', desc: 'AI 生成开发信/WhatsApp话术/产品描述', icon: 'Promotion', color: '#722ed1', route: '/marketing' },
{ title: '挖掘新客', desc: 'AI 搜索潜在客户 + 开发信生成', icon: 'Search', color: '#13c2c2', route: '/discovery' },
{ title: '智能跟进', desc: '自动生成跟进话术 + 一键发送', icon: 'Message', color: '#eb2f96', route: '/followup' },
{ title: '数据分析', desc: '客户/翻译/报价多维度统计', icon: 'DataAnalysis', color: '#1890ff', route: '/analytics' },
{ title: '团队协作', desc: '团队管理 + 角色权限 + 成员邀请', icon: 'UserFilled', color: '#fa8c16', route: '/team' },
]
onMounted(async () => {
try {
const [overview, fup] = await Promise.all([
const [overview, fup, silent, usage] = await Promise.all([
getAnalyticsOverview().catch(() => null),
getFollowupPending().catch(() => [])
getFollowupPending().catch(() => []),
getSilentCustomers(7).catch(() => []),
getUsageStats().catch(() => null),
])
if (usage) {
usageStats.value = usage.data || usage
}
const d = overview?.data || overview || {}
stats.value = [
{ value: d.customers?.total || d.total_customers || 0, label: '客户总数' },
{ value: d.translations?.today || d.today_translations || 0, label: '今日翻译' },
{ value: d.quotations?.total || d.total_quotations || 0, label: '报价单数' },
{ value: fup?.length || 0, label: '待跟进' },
{ value: d.customers?.total || 0, label: '客户总数', color: '#1890ff', route: '/customers' },
{ value: d.translations?.today || 0, label: '今日翻译', color: '#52c41a', route: '/translate' },
{ value: d.quotations?.total || 0, label: '报价单数', color: '#faad14', route: '/quotations' },
{ value: fup?.length || 0, label: '待跟进', color: '#ff4d4f', route: '/followup' },
]
followups.value = Array.isArray(fup) ? fup.slice(0, 5) : []
silentCustomers.value = Array.isArray(silent) ? silent.slice(0, 5) : (silent?.items || silent?.data || [])
} catch { /* ignore */ }
})
@@ -104,14 +239,44 @@ async function doQuickTranslate() {
</script>
<style scoped>
.stat-card { cursor: default; text-align: center; }
.stat-value { font-size: 32px; font-weight: 700; color: #409eff; }
.workspace { max-width: 1200px; margin: 0 auto; }
.welcome-section { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.welcome-left h2 { font-size: 24px; font-weight: 700; color: #333; margin: 0 0 4px; }
.welcome-desc { font-size: 14px; color: #999; margin: 0; }
.welcome-right { display: flex; gap: 12px; }
.stat-card { cursor: pointer; text-align: center; transition: all 0.25s; }
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.08); }
.stat-value { font-size: 32px; font-weight: 700; }
.stat-label { font-size: 14px; color: #999; margin-top: 4px; }
.followup-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
.followup-name { font-weight: 500; }
.followup-days { color: #f56c6c; font-size: 12px; }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; }
.feature-item { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 20px 12px; cursor: pointer; border-radius: 8px; transition: background 0.2s; }
.feature-item:hover { background: #f0f5ff; }
.feature-item span { font-size: 13px; color: #333; }
.section-card { margin-top: 20px; }
.section-title { font-weight: 600; font-size: 15px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@media (max-width: 768px) { .feature-grid { grid-template-columns: repeat(2, 1fr); } }
.feature-card { display: flex; gap: 16px; padding: 20px; border-radius: 10px; cursor: pointer; transition: all 0.25s; border: 1px solid #f0f0f0; }
.feature-card:hover { border-color: #d9d9d9; box-shadow: 0 4px 16px rgba(0,0,0,0.06); transform: translateY(-2px); }
.feature-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.feature-info { flex: 1; min-width: 0; }
.feature-info h4 { font-size: 14px; font-weight: 600; color: #333; margin: 0 0 4px; }
.feature-info p { font-size: 12px; color: #999; margin: 0; line-height: 1.4; }
.list-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #f5f5f5; }
.list-item:last-child { border-bottom: none; }
.list-item-left { display: flex; flex-direction: column; gap: 2px; }
.list-item-name { font-size: 14px; font-weight: 500; color: #333; }
.list-item-meta { font-size: 12px; color: #999; }
.quick-result { margin-top: 12px; padding: 12px; background: #f5f5f5; border-radius: 6px; font-size: 13px; line-height: 1.5; }
.usage-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
.usage-item { }
.usage-label { display: flex; justify-content: space-between; font-size: 13px; color: #666; margin-bottom: 6px; }
.usage-value { color: #999; }
@media (max-width: 768px) {
.welcome-section { flex-direction: column; align-items: flex-start; gap: 12px; }
.welcome-left h2 { font-size: 20px; }
.feature-card { padding: 14px; gap: 12px; }
.feature-icon { width: 40px; height: 40px; }
.feature-icon :deep(.el-icon) { font-size: 20px !important; }
.feature-info h4 { font-size: 13px; }
.feature-info p { font-size: 11px; display: none; }
}
</style>
@@ -0,0 +1,213 @@
<template>
<div class="landing-page">
<header class="landing-header">
<div class="header-inner">
<router-link to="/" class="logo">Trade<span>Mate</span></router-link>
<span class="subtitle">外贸小助手 · 工作台</span>
<div class="header-right">
<a href="/">首页</a>
<el-button v-if="isLoggedIn" type="primary" @click="goWorkspace">进入工作台</el-button>
</div>
</div>
</header>
<section class="hero">
<div class="hero-inner">
<div class="hero-left">
<h1>外贸智能工作台</h1>
<p class="hero-desc">智能翻译客户管理营销文案报价单WhatsApp 沟通 一个工具打通外贸全流程</p>
<div class="hero-features">
<div v-for="hf in heroFeatures" :key="hf" class="hero-tag">{{ hf }}</div>
</div>
</div>
<div class="hero-right">
<div v-if="!isLoggedIn" class="login-card">
<el-tabs v-model="tab" stretch>
<el-tab-pane label="登录" name="login">
<el-form :model="form" size="large" @keyup.enter="submit">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名/手机号" prefix-icon="User" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<p v-if="error" class="form-error">{{ error }}</p>
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
</el-form>
</el-tab-pane>
<el-tab-pane label="注册" name="register">
<el-form :model="regForm" size="large" @keyup.enter="register">
<el-form-item>
<el-input v-model="regForm.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item>
<el-input v-model="regForm.phone" placeholder="手机号" prefix-icon="Iphone" />
</el-form-item>
<el-form-item>
<el-input v-model="regForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<p v-if="regError" class="form-error">{{ regError }}</p>
<el-button type="primary" :loading="regLoading" style="width:100%" @click="register">注册</el-button>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
<div v-else class="login-card logged-in">
<el-icon :size="48" color="#52c41a"><CircleCheckFilled /></el-icon>
<h3>已登录</h3>
<p style="color:#999;font-size:13px;margin:4px 0 16px">{{ auth.user?.username }}</p>
<el-button type="primary" @click="goWorkspace">进入工作台</el-button>
</div>
</div>
</div>
</section>
<section class="features">
<div class="feature-grid">
<div v-for="f in features" :key="f.title" class="feature-card" @click="handleClick(f)">
<div class="feature-icon" :style="{ background: f.color + '15' }">
<el-icon :size="28" :color="f.color"><component :is="f.icon" /></el-icon>
</div>
<h3>{{ f.title }}</h3>
<p>{{ f.desc }}</p>
</div>
</div>
</section>
<footer class="landing-footer">
<span>TradeMate 外贸小助手 &copy; {{ new Date().getFullYear() }}</span>
</footer>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { register as registerApi } from '@/api'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const isLoggedIn = computed(() => !!localStorage.getItem('token'))
const tab = ref('login')
const loading = ref(false)
const error = ref('')
const form = reactive({ username: '', password: '' })
const regLoading = ref(false)
const regError = ref('')
const regForm = reactive({ username: '', phone: '', password: '' })
const heroFeatures = ['智能翻译', '客户管理', '营销文案', '报价单', 'WhatsApp', 'AI 助手']
const features = [
{ title: '智能翻译', desc: '20+ 语言商务翻译,AI 回复建议,信息提取', icon: 'ChatLineSquare', color: '#1890ff' },
{ title: '客户管理', desc: 'CRM + 健康评分 + 跟进记录 + 沉默预警', icon: 'User', color: '#52c41a' },
{ title: '产品库', desc: '双语产品管理,关键词标签,批量导入导出', icon: 'Goods', color: '#faad14' },
{ title: '报价单', desc: 'AI 智能报价,PDF 导出,状态追踪', icon: 'DocumentCopy', color: '#ff4d4f' },
{ title: '营销素材', desc: 'AI 生成开发信/WhatsApp 话术/产品描述', icon: 'Promotion', color: '#722ed1' },
{ title: '挖掘新客', desc: 'AI 搜索潜在客户,开发信定向生成', icon: 'Search', color: '#13c2c2' },
{ title: '智能跟进', desc: '自动生成跟进话术,一键发送 WhatsApp', icon: 'Message', color: '#eb2f96' },
{ title: '数据分析', desc: '客户/翻译/报价多维度统计图表', icon: 'DataAnalysis', color: '#1890ff' },
{ title: '团队协作', desc: '团队管理,角色权限,成员邀请', icon: 'UserFilled', color: '#fa8c16' },
]
async function submit() {
if (!form.username || !form.password) { error.value = '请输入用户名和密码'; return }
loading.value = true
error.value = ''
try {
await auth.login(form)
await auth.fetchUser()
ElMessage.success('登录成功')
const redirect = route.query.redirect || '/workspace'
router.push(redirect)
} catch (e) {
error.value = e?.detail || '登录失败'
} finally {
loading.value = false
}
}
async function register() {
if (!regForm.username || !regForm.phone || !regForm.password) { regError.value = '请填写完整'; return }
regLoading.value = true
regError.value = ''
try {
await registerApi(regForm)
ElMessage.success('注册成功,请登录')
tab.value = 'login'
form.username = regForm.username
regForm.username = ''
regForm.phone = ''
regForm.password = ''
} catch (e) {
regError.value = e?.detail || '注册失败'
} finally {
regLoading.value = false
}
}
function handleClick(f) {
if (!isLoggedIn.value) {
tab.value = 'login'
document.querySelector('.hero')?.scrollIntoView({ behavior: 'smooth' })
} else {
router.push(f.route || ('/' + f.title))
}
}
function goWorkspace() { router.push('/workspace') }
</script>
<style scoped>
.landing-page { min-height: 100vh; display: flex; flex-direction: column; background: #f5f5f5; }
.landing-header { background: #fff; border-bottom: 1px solid #eee; padding: 0 40px; height: 60px; display: flex; align-items: center; position: sticky; top: 0; z-index: 100; }
.header-inner { width: 100%; max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 16px; }
.logo { font-size: 20px; font-weight: 700; color: #1890ff; text-decoration: none; }
.logo span { color: #333; }
.subtitle { font-size: 13px; color: #999; flex: 1; }
.header-right { display: flex; align-items: center; gap: 12px; }
.header-right a { text-decoration: none; color: #555; font-size: 14px; }
.header-right a:hover { color: #1890ff; }
.hero { background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); color: #fff; }
.hero-inner { max-width: 1200px; margin: 0 auto; padding: 60px 20px; display: flex; gap: 48px; align-items: center; }
.hero-left { flex: 1; }
.hero-left h1 { font-size: 36px; font-weight: 800; margin-bottom: 16px; line-height: 1.2; }
.hero-desc { font-size: 16px; opacity: 0.85; line-height: 1.6; margin-bottom: 20px; }
.hero-features { display: flex; flex-wrap: wrap; gap: 10px; }
.hero-tag { background: rgba(255,255,255,0.15); padding: 6px 16px; border-radius: 20px; font-size: 13px; }
.hero-right { flex-shrink: 0; width: 380px; }
.login-card { background: #fff; border-radius: 12px; padding: 28px; box-shadow: 0 8px 40px rgba(0,0,0,0.15); }
.login-card.logged-in { text-align: center; padding: 40px 28px; }
.login-card.logged-in h3 { margin: 12px 0 4px; font-size: 18px; color: #333; }
.form-error { color: #f56c6c; text-align: center; font-size: 13px; margin: -8px 0 12px; }
.features { max-width: 1200px; margin: -30px auto 40px; padding: 0 20px; }
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
.feature-card { background: #fff; border-radius: 12px; padding: 28px 24px; cursor: pointer; transition: all 0.25s; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
.feature-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
.feature-icon { width: 52px; height: 52px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px; }
.feature-card h3 { font-size: 16px; margin-bottom: 8px; color: #333; }
.feature-card p { font-size: 13px; color: #999; line-height: 1.5; }
.landing-footer { text-align: center; padding: 24px; color: #999; font-size: 12px; margin-top: auto; border-top: 1px solid #e8e8e8; background: #fff; }
@media (max-width: 768px) {
.hero-inner { flex-direction: column; padding: 40px 20px; }
.hero-right { width: 100%; }
.feature-grid { grid-template-columns: repeat(2, 1fr); }
.hero-left h1 { font-size: 28px; }
.landing-header { padding: 0 16px; }
.subtitle { display: none; }
.header-right a { display: none; }
}
@media (max-width: 480px) {
.feature-grid { grid-template-columns: 1fr; }
.feature-card { padding: 20px 16px; }
}
</style>