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:
@@ -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; }
|
||||
|
||||
@@ -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,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; }
|
||||
|
||||
@@ -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 ###
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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]:
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 外贸小助手 © {{ new Date().getFullYear() }}</span>
|
||||
<span>TradeMate © {{ 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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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 || ''
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 外贸小助手 © {{ 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>
|
||||
Reference in New Issue
Block a user