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

- Separate workspace landing from login for better UX
- Referral system rewards both parties with Pro days
- Quota enforcement prevents abuse without breaking endpoints
- 7-day free trial with auto-downgrade on expiry
- Admin-managed search provider config (SearXNG, Bing)
- 15% discount on annual subscriptions
- MCP search server wrapping opencode search
- Fix discovery module field name mismatch causing 422
This commit is contained in:
TradeMate Dev
2026-05-26 11:40:13 +08:00
parent 52dba37f22
commit bed5c7abef
39 changed files with 1988 additions and 152 deletions
+3 -3
View File
@@ -1,7 +1,7 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="6" v-for="item in cards" :key="item.label">
<el-col :xs="12" :sm="6" v-for="item in cards" :key="item.label">
<el-card shadow="hover" style="text-align:center;margin-bottom:20px">
<div class="card-value">{{ item.value }}</div>
<div class="card-label">{{ item.label }}</div>
@@ -10,7 +10,7 @@
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header><span>客户状态分布</span></template>
<div style="padding:20px;text-align:center">
@@ -22,7 +22,7 @@
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header><span>国家分布 Top 10</span></template>
<div style="padding:20px">
+8 -2
View File
@@ -67,7 +67,10 @@ async function search() {
loading.value = true
searched.value = true
try {
const res = await discoverySearch(form.value)
const res = await discoverySearch({
product_description: form.value.product,
target_market: form.value.market || 'US',
})
const d = res.data || res
results.value = d.companies || d.items || d.results || d || []
} catch { ElMessage.error('挖掘失败') }
@@ -85,7 +88,10 @@ async function generateOutreach() {
if (!outForm.value.company || !outForm.value.product) { ElMessage.warning('请填写完整'); return }
outLoading.value = true
try {
const res = await discoveryOutreach(outForm.value)
const res = await discoveryOutreach({
company: { name: outForm.value.company, channel: outForm.value.channel },
product: { name: outForm.value.product },
})
outreachResult.value = res.data?.content || res.content || res.text || ''
} catch { ElMessage.error('生成失败') }
finally { outLoading.value = false }
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="6" v-for="s in statItems" :key="s.label">
<el-col :xs="12" :sm="6" v-for="s in statItems" :key="s.label">
<el-card shadow="hover" style="text-align:center;margin-bottom:16px">
<div class="stat-num">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
+10 -11
View File
@@ -1,13 +1,13 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="8">
<el-col :xs="24" :sm="8">
<el-card shadow="never">
<div style="text-align:center;padding:20px 0">
<el-avatar :size="72" style="background:#409eff;font-size:28px">{{ (user?.username || 'U')[0].toUpperCase() }}</el-avatar>
<h3 style="margin:12px 0 4px">{{ user?.username || '用户' }}</h3>
<el-tag size="small" :type="tierType(user?.tier)">{{ user?.tier || 'free' }}</el-tag>
<el-tag v-if="user?.role === 'admin'" type="danger" size="small" style="margin-left:4px">管理员</el-tag>
<el-avatar :size="72" style="background:#1890ff;font-size:28px">{{ (auth.user?.username || 'U')[0].toUpperCase() }}</el-avatar>
<h3 style="margin:12px 0 4px">{{ auth.user?.username || '用户' }}</h3>
<el-tag size="small" :type="tierType(auth.user?.tier)">{{ auth.user?.tier || 'free' }}</el-tag>
<el-tag v-if="auth.user?.role === 'admin'" type="danger" size="small" style="margin-left:4px">管理员</el-tag>
</div>
<el-divider style="margin:8px 0" />
<div class="profile-menu">
@@ -29,7 +29,7 @@
</div>
</el-card>
</el-col>
<el-col :span="16">
<el-col :xs="24" :sm="16">
<el-card shadow="never">
<template #header><span>编辑资料</span></template>
<el-form :model="form" label-width="80" size="large">
@@ -71,7 +71,6 @@ import { updateProfile, changePassword } from '@/api'
import { ElMessage } from 'element-plus'
const auth = useAuthStore()
const user = auth.user
const saving = ref(false)
const showPassword = ref(false)
const pwLoading = ref(false)
@@ -81,10 +80,10 @@ const pwForm = reactive({ old_password: '', new_password: '' })
function tierType(t) { return { free: 'warning', pro: 'primary', enterprise: 'success' }[t] || 'info' }
onMounted(() => {
if (user.value) {
form.username = user.value.username || ''
form.email = user.value.email || ''
form.phone = user.value.phone || ''
if (auth.user) {
form.username = auth.user.username || ''
form.email = auth.user.email || ''
form.phone = auth.user.phone || ''
}
})
+1 -1
View File
@@ -3,7 +3,7 @@
<el-card shadow="never">
<template #header><span>文本翻译</span></template>
<el-input v-model="form.text" type="textarea" :rows="5" placeholder="输入需要翻译的外贸文本..." />
<div style="margin:16px 0;display:flex;gap:12px;align-items:center">
<div style="margin:16px 0;display:flex;gap:12px;align-items:center;flex-wrap:wrap">
<el-select v-model="form.target_lang" style="width:160px">
<el-option label="英语" value="en" />
<el-option label="中文" value="zh" />
+15 -9
View File
@@ -1,24 +1,29 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="8" v-for="p in plans" :key="p.id">
<el-card shadow="hover" :class="{ 'plan-highlight': p.id === currentPlan }">
<el-col :xs="24" :sm="8" v-for="p in plans" :key="p.id">
<el-card shadow="hover" :class="{ 'plan-highlight': p.id === currentPlan, 'plan-yearly': p.period === 'year' }">
<template #header>
<div style="text-align:center">
<el-tag v-if="p.period === 'year'" type="success" size="small" style="margin-bottom:8px">年付省 {{ (p.original_price || p.price * 12) - p.price }} </el-tag>
<h3 style="margin:0">{{ p.name }}</h3>
<p style="font-size:28px;font-weight:700;color:#409eff;margin:12px 0">
¥{{ p.price || 0 }}<span style="font-size:14px;font-weight:400;color:#999">/</span>
<p style="font-size:28px;font-weight:700;color:#1890ff;margin:12px 0">
¥{{ p.price }}<span style="font-size:14px;font-weight:400;color:#999">/{{ p.period === 'year' ? '年' : '月' }}</span>
</p>
<p v-if="p.original_price" style="font-size:12px;color:#999;margin:-8px 0 0">
<del>¥{{ p.original_price }}/</del>{{ Math.round((1 - p.price / p.original_price) * 100) }}% 优惠
</p>
</div>
</template>
<div>
<p v-for="f in p.features || []" :key="f" style="font-size:13px;color:#666;margin:8px 0">
<el-icon color="#67c23a" style="margin-right:6px"><Check /></el-icon>{{ f }}
<el-icon color="#52c41a" style="margin-right:6px"><Check /></el-icon>{{ f }}
</p>
</div>
<div style="text-align:center;margin-top:16px">
<el-button v-if="p.id === currentPlan" type="default" disabled>当前套餐</el-button>
<el-button v-else type="primary" :loading="loadingId === p.id" @click="upgrade(p.id)">升级</el-button>
<el-button v-else-if="p.id === 'free'" @click="handleFree">当前套餐</el-button>
<el-button v-else type="primary" :loading="loadingId === p.id" @click="upgrade(p.id)">{{ p.price === 0 ? '当前套餐' : '升级' }}</el-button>
</div>
</el-card>
</el-col>
@@ -40,7 +45,7 @@ onMounted(async () => {
try {
const [plansRes, subRes] = await Promise.all([getPlans(), getSubscription().catch(() => null)])
const pd = plansRes.data || plansRes
plans.value = pd.plans || pd.items || pd || []
plans.value = (pd.plans || pd.items || pd || []).filter(p => p.id !== 'free')
if (subRes) {
const sd = subRes.data || subRes
currentPlan.value = sd.plan_id || sd.plan
@@ -51,7 +56,7 @@ onMounted(async () => {
async function upgrade(planId) {
loadingId.value = planId
try {
const res = await createOrder(planId)
const res = await createOrder(planId, 'native')
ElMessage.success('订单已创建' + (res.pay_url ? ',正在跳转支付...' : ''))
if (res.pay_url) window.open(res.pay_url)
} catch (e) { ElMessage.error(e?.detail || '升级失败') }
@@ -60,5 +65,6 @@ async function upgrade(planId) {
</script>
<style scoped>
.plan-highlight { border: 2px solid #409eff; transform: scale(1.02); }
.plan-highlight { border: 2px solid #1890ff; transform: scale(1.02); }
.plan-yearly { border: 2px solid #52c41a; }
</style>
+215 -50
View File
@@ -1,94 +1,229 @@
<template>
<div>
<div class="workspace">
<div class="welcome-section">
<div class="welcome-left">
<h2>你好{{ auth.user?.username || '用户' }}</h2>
<p class="welcome-desc">欢迎回来以下是你的业务概览</p>
</div>
<div class="welcome-right">
<el-button type="primary" @click="$router.push('/translate')">快速翻译</el-button>
<el-button @click="$router.push('/customers')">客户管理</el-button>
</div>
</div>
<el-alert
v-if="usageStats.tier === 'pro' && usageStats.trial_days_left > 0"
:title="'Pro 试用中,剩余 ' + usageStats.trial_days_left + ' 天'"
type="success"
show-icon
:closable="false"
style="margin-bottom:16px"
>
<template #default>
<span>试用结束后将自动恢复为免费版 <el-button text type="primary" size="small" @click="showUpgrade = true">立即升级正式版</el-button></span>
</template>
</el-alert>
<el-row :gutter="20">
<el-col :span="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ item.value }}</div>
<el-col :xs="12" :sm="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover" class="stat-card" @click="item.route && $router.push(item.route)">
<div class="stat-value" :style="{ color: item.color }">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="section-title">本月用量</span>
<el-button v-if="usageStats.tier !== 'enterprise'" text type="primary" size="small" @click="showUpgrade = true">升级以获取更多额度</el-button>
</div>
</template>
<div class="usage-grid">
<div v-for="u in usageItems" :key="u.key" class="usage-item">
<div class="usage-label">
<span>{{ u.label }}</span>
<span class="usage-value">{{ u.used }} / {{ u.limit === 999999999 ? '∞' : u.limit }}</span>
</div>
<el-progress :percentage="u.pct" :color="u.pct > 80 ? '#ff4d4f' : u.pct > 50 ? '#faad14' : '#52c41a'" :stroke-width="12" />
</div>
</div>
</el-card>
<el-card shadow="never" class="section-card">
<template #header><span class="section-title">功能矩阵</span></template>
<div class="feature-grid">
<div v-for="f in features" :key="f.title" class="feature-card" @click="$router.push(f.route)">
<div class="feature-icon" :style="{ background: f.color + '15' }">
<el-icon :size="26" :color="f.color"><component :is="f.icon" /></el-icon>
</div>
<div class="feature-info">
<h4>{{ f.title }}</h4>
<p>{{ f.desc }}</p>
</div>
</div>
</div>
</el-card>
<el-row :gutter="20" style="margin-top:20px">
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8">
<el-card shadow="never">
<template #header><span>快速翻译</span></template>
<el-input v-model="quickText" type="textarea" :rows="3" placeholder="输入要翻译的文本..." />
<template #header><span class="section-title">快速翻译</span></template>
<el-input v-model="quickText" type="textarea" :rows="4" placeholder="输入要翻译的文本..." />
<div style="margin-top:12px;display:flex;gap:8px">
<el-select v-model="quickLang" style="width:140px">
<el-select v-model="quickLang" style="width:130px" size="default">
<el-option label="英语" value="en" />
<el-option label="中文" value="zh" />
<el-option label="西班牙语" value="es" />
<el-option label="西语" value="es" />
<el-option label="日语" value="ja" />
</el-select>
<el-button type="primary" :loading="translating" @click="doQuickTranslate">翻译</el-button>
</div>
<p v-if="quickResult" style="margin-top:12px;padding:12px;background:#f5f5f5;border-radius:6px">{{ quickResult }}</p>
<p v-if="quickResult" class="quick-result">{{ quickResult }}</p>
</el-card>
</el-col>
<el-col :span="12">
<el-col :xs="24" :sm="12" :md="8">
<el-card shadow="never">
<template #header><span>跟进提醒</span></template>
<template #header>
<div class="card-header">
<span class="section-title">跟进提醒</span>
<el-button text type="primary" size="small" @click="$router.push('/followup')">查看全部</el-button>
</div>
</template>
<div v-if="followups.length">
<div v-for="f in followups" :key="f.id" class="followup-item">
<span class="followup-name">{{ f.customer_name }}</span>
<span class="followup-days">{{ f.silent_days }}天未联系</span>
<div v-for="f in followups" :key="f.id" class="list-item">
<div class="list-item-left">
<span class="list-item-name">{{ f.customer_name }}</span>
<span class="list-item-meta">{{ f.silent_days }}天未联系</span>
</div>
<el-button text type="primary" size="small" @click="$router.push('/followup')">去跟进</el-button>
</div>
</div>
<el-empty v-else description="暂无跟进提醒" :image-size="60" />
<el-empty v-else description="暂无跟进提醒" :image-size="50" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="8">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="section-title">沉默客户</span>
<el-button text type="primary" size="small" @click="$router.push('/customers')">查看全部</el-button>
</div>
</template>
<div v-if="silentCustomers.length">
<div v-for="c in silentCustomers" :key="c.id" class="list-item">
<div class="list-item-left">
<span class="list-item-name">{{ c.name }}</span>
<span class="list-item-meta">{{ c.silent_days || '?' }}天未联系</span>
</div>
<el-tag :type="(c.silent_days || 0) > 14 ? 'danger' : 'warning'" size="small">{{ c.silent_days || 0 }}</el-tag>
</div>
</div>
<el-empty v-else description="暂无沉默客户" :image-size="50" />
</el-card>
</el-col>
</el-row>
<el-card shadow="never" style="margin-top:20px">
<template #header><span>功能入口</span></template>
<div class="feature-grid">
<div v-for="f in features" :key="f.title" class="feature-item" @click="$router.push(f.route)">
<el-icon :size="24" :color="f.color"><component :is="f.icon" /></el-icon>
<span>{{ f.title }}</span>
</div>
<el-dialog v-model="showUpgrade" title="升级套餐" width="700">
<el-table :data="planData" border>
<el-table-column label="功能" prop="feature" width="140" />
<el-table-column label="免费版" width="160">
<template #default="{ row }"><span v-html="row.free" /></template>
</el-table-column>
<el-table-column label="Pro ¥99/月" width="160">
<template #default="{ row }"><span v-html="row.pro" /></template>
</el-table-column>
<el-table-column label="企业 ¥399/月" width="160">
<template #default="{ row }"><span v-html="row.enterprise" /></template>
</el-table-column>
</el-table>
<div style="text-align:center;margin-top:20px">
<el-button type="primary" size="large" @click="$router.push('/upgrade')">立即升级</el-button>
</div>
</el-card>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAnalyticsOverview, translate, getFollowupPending } from '@/api'
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { getAnalyticsOverview, translate, getFollowupPending, getSilentCustomers, getUsageStats } from '@/api'
const auth = useAuthStore()
const stats = ref([])
const quickText = ref('')
const quickLang = ref('en')
const quickResult = ref('')
const translating = ref(false)
const followups = ref([])
const silentCustomers = ref([])
const usageStats = ref({ tier: 'free', limits: {}, usage: {} })
const showUpgrade = ref(false)
const usageItems = computed(() => {
const u = usageStats.value.usage || {}
const l = usageStats.value.limits || {}
const tier = usageStats.value.tier || 'free'
const items = [
{ key: 'translate_chars', label: '翻译字符', used: u.translate_chars || 0, limit: l.translate_chars || 0 },
{ key: 'replies', label: '回复建议', used: u.replies || 0, limit: l.replies || 0 },
{ key: 'marketing', label: '营销生成', used: u.marketing || 0, limit: l.marketing || 0 },
{ key: 'customers', label: '客户数', used: u.customers || 0, limit: l.customers || 0 },
{ key: 'products', label: '产品数', used: u.products || 0, limit: l.products || 0 },
{ key: 'quotations', label: '报价单', used: u.quotations || 0, limit: l.quotations || 0 },
]
return items.map(i => ({
...i,
pct: i.limit >= 999999 ? 0 : Math.min(100, Math.round((i.used / (i.limit || 1)) * 100)),
}))
})
const planData = [
{ feature: '翻译字符/天', free: '5,000', pro: '50,000', enterprise: '∞' },
{ feature: '回复建议/天', free: '20', pro: '200', enterprise: '∞' },
{ feature: '营销生成/天', free: '5', pro: '50', enterprise: '∞' },
{ feature: '客户管理', free: '最多5个', pro: '最多100个', enterprise: '∞' },
{ feature: '产品管理', free: '最多1个', pro: '最多20个', enterprise: '∞' },
{ feature: '报价单/天', free: '3', pro: '30', enterprise: '∞' },
{ feature: '跟进提醒', free: '—', pro: '✓', enterprise: '✓' },
{ feature: 'WhatsApp 集成', free: '—', pro: '✓', enterprise: '✓' },
{ feature: '挖掘新客', free: '—', pro: '✓', enterprise: '✓' },
{ feature: '团队协作', free: '—', pro: '—', enterprise: '✓' },
]
const features = [
{ title: '智能翻译', icon: 'ChatLineSquare', color: '#409eff', route: '/translate' },
{ title: '客户管理', icon: 'User', color: '#67c23a', route: '/customers' },
{ title: '产品库', icon: 'Goods', color: '#e6a23c', route: '/products' },
{ title: '报价单', icon: 'DocumentCopy', color: '#f56c6c', route: '/quotations' },
{ title: '营销素材', icon: 'Promotion', color: '#909399', route: '/marketing' },
{ title: '挖掘新客', icon: 'Search', color: '#409eff', route: '/discovery' },
{ title: '智能跟进', icon: 'Message', color: '#67c23a', route: '/followup' },
{ title: '数据分析', icon: 'DataAnalysis', color: '#e6a23c', route: '/analytics' },
{ title: '团队协作', icon: 'UserFilled', color: '#f56c6c', route: '/team' },
{ title: '智能翻译', desc: '多语言翻译 + 回复建议 + 信息提取', icon: 'ChatLineSquare', color: '#1890ff', route: '/translate' },
{ title: '客户管理', desc: 'CRM + 健康评分 + 跟进记录', icon: 'User', color: '#52c41a', route: '/customers' },
{ title: '产品库', desc: '双语产品管理 + 关键词标签', icon: 'Goods', color: '#faad14', route: '/products' },
{ title: '报价单', desc: 'AI 智能报价 + PDF 导出 + 状态追踪', icon: 'DocumentCopy', color: '#ff4d4f', route: '/quotations' },
{ title: '营销素材', desc: 'AI 生成开发信/WhatsApp话术/产品描述', icon: 'Promotion', color: '#722ed1', route: '/marketing' },
{ title: '挖掘新客', desc: 'AI 搜索潜在客户 + 开发信生成', icon: 'Search', color: '#13c2c2', route: '/discovery' },
{ title: '智能跟进', desc: '自动生成跟进话术 + 一键发送', icon: 'Message', color: '#eb2f96', route: '/followup' },
{ title: '数据分析', desc: '客户/翻译/报价多维度统计', icon: 'DataAnalysis', color: '#1890ff', route: '/analytics' },
{ title: '团队协作', desc: '团队管理 + 角色权限 + 成员邀请', icon: 'UserFilled', color: '#fa8c16', route: '/team' },
]
onMounted(async () => {
try {
const [overview, fup] = await Promise.all([
const [overview, fup, silent, usage] = await Promise.all([
getAnalyticsOverview().catch(() => null),
getFollowupPending().catch(() => [])
getFollowupPending().catch(() => []),
getSilentCustomers(7).catch(() => []),
getUsageStats().catch(() => null),
])
if (usage) {
usageStats.value = usage.data || usage
}
const d = overview?.data || overview || {}
stats.value = [
{ value: d.customers?.total || d.total_customers || 0, label: '客户总数' },
{ value: d.translations?.today || d.today_translations || 0, label: '今日翻译' },
{ value: d.quotations?.total || d.total_quotations || 0, label: '报价单数' },
{ value: fup?.length || 0, label: '待跟进' },
{ value: d.customers?.total || 0, label: '客户总数', color: '#1890ff', route: '/customers' },
{ value: d.translations?.today || 0, label: '今日翻译', color: '#52c41a', route: '/translate' },
{ value: d.quotations?.total || 0, label: '报价单数', color: '#faad14', route: '/quotations' },
{ value: fup?.length || 0, label: '待跟进', color: '#ff4d4f', route: '/followup' },
]
followups.value = Array.isArray(fup) ? fup.slice(0, 5) : []
silentCustomers.value = Array.isArray(silent) ? silent.slice(0, 5) : (silent?.items || silent?.data || [])
} catch { /* ignore */ }
})
@@ -104,14 +239,44 @@ async function doQuickTranslate() {
</script>
<style scoped>
.stat-card { cursor: default; text-align: center; }
.stat-value { font-size: 32px; font-weight: 700; color: #409eff; }
.workspace { max-width: 1200px; margin: 0 auto; }
.welcome-section { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
.welcome-left h2 { font-size: 24px; font-weight: 700; color: #333; margin: 0 0 4px; }
.welcome-desc { font-size: 14px; color: #999; margin: 0; }
.welcome-right { display: flex; gap: 12px; }
.stat-card { cursor: pointer; text-align: center; transition: all 0.25s; }
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.08); }
.stat-value { font-size: 32px; font-weight: 700; }
.stat-label { font-size: 14px; color: #999; margin-top: 4px; }
.followup-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
.followup-name { font-weight: 500; }
.followup-days { color: #f56c6c; font-size: 12px; }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; }
.feature-item { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 20px 12px; cursor: pointer; border-radius: 8px; transition: background 0.2s; }
.feature-item:hover { background: #f0f5ff; }
.feature-item span { font-size: 13px; color: #333; }
.section-card { margin-top: 20px; }
.section-title { font-weight: 600; font-size: 15px; }
.card-header { display: flex; justify-content: space-between; align-items: center; }
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@media (max-width: 768px) { .feature-grid { grid-template-columns: repeat(2, 1fr); } }
.feature-card { display: flex; gap: 16px; padding: 20px; border-radius: 10px; cursor: pointer; transition: all 0.25s; border: 1px solid #f0f0f0; }
.feature-card:hover { border-color: #d9d9d9; box-shadow: 0 4px 16px rgba(0,0,0,0.06); transform: translateY(-2px); }
.feature-icon { width: 48px; height: 48px; border-radius: 12px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.feature-info { flex: 1; min-width: 0; }
.feature-info h4 { font-size: 14px; font-weight: 600; color: #333; margin: 0 0 4px; }
.feature-info p { font-size: 12px; color: #999; margin: 0; line-height: 1.4; }
.list-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #f5f5f5; }
.list-item:last-child { border-bottom: none; }
.list-item-left { display: flex; flex-direction: column; gap: 2px; }
.list-item-name { font-size: 14px; font-weight: 500; color: #333; }
.list-item-meta { font-size: 12px; color: #999; }
.quick-result { margin-top: 12px; padding: 12px; background: #f5f5f5; border-radius: 6px; font-size: 13px; line-height: 1.5; }
.usage-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
.usage-item { }
.usage-label { display: flex; justify-content: space-between; font-size: 13px; color: #666; margin-bottom: 6px; }
.usage-value { color: #999; }
@media (max-width: 768px) {
.welcome-section { flex-direction: column; align-items: flex-start; gap: 12px; }
.welcome-left h2 { font-size: 20px; }
.feature-card { padding: 14px; gap: 12px; }
.feature-icon { width: 40px; height: 40px; }
.feature-icon :deep(.el-icon) { font-size: 20px !important; }
.feature-info h4 { font-size: 13px; }
.feature-info p { font-size: 11px; display: none; }
}
</style>
@@ -0,0 +1,213 @@
<template>
<div class="landing-page">
<header class="landing-header">
<div class="header-inner">
<router-link to="/" class="logo">Trade<span>Mate</span></router-link>
<span class="subtitle">外贸小助手 · 工作台</span>
<div class="header-right">
<a href="/">首页</a>
<el-button v-if="isLoggedIn" type="primary" @click="goWorkspace">进入工作台</el-button>
</div>
</div>
</header>
<section class="hero">
<div class="hero-inner">
<div class="hero-left">
<h1>外贸智能工作台</h1>
<p class="hero-desc">智能翻译客户管理营销文案报价单WhatsApp 沟通 一个工具打通外贸全流程</p>
<div class="hero-features">
<div v-for="hf in heroFeatures" :key="hf" class="hero-tag">{{ hf }}</div>
</div>
</div>
<div class="hero-right">
<div v-if="!isLoggedIn" class="login-card">
<el-tabs v-model="tab" stretch>
<el-tab-pane label="登录" name="login">
<el-form :model="form" size="large" @keyup.enter="submit">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名/手机号" prefix-icon="User" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<p v-if="error" class="form-error">{{ error }}</p>
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
</el-form>
</el-tab-pane>
<el-tab-pane label="注册" name="register">
<el-form :model="regForm" size="large" @keyup.enter="register">
<el-form-item>
<el-input v-model="regForm.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item>
<el-input v-model="regForm.phone" placeholder="手机号" prefix-icon="Iphone" />
</el-form-item>
<el-form-item>
<el-input v-model="regForm.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<p v-if="regError" class="form-error">{{ regError }}</p>
<el-button type="primary" :loading="regLoading" style="width:100%" @click="register">注册</el-button>
</el-form>
</el-tab-pane>
</el-tabs>
</div>
<div v-else class="login-card logged-in">
<el-icon :size="48" color="#52c41a"><CircleCheckFilled /></el-icon>
<h3>已登录</h3>
<p style="color:#999;font-size:13px;margin:4px 0 16px">{{ auth.user?.username }}</p>
<el-button type="primary" @click="goWorkspace">进入工作台</el-button>
</div>
</div>
</div>
</section>
<section class="features">
<div class="feature-grid">
<div v-for="f in features" :key="f.title" class="feature-card" @click="handleClick(f)">
<div class="feature-icon" :style="{ background: f.color + '15' }">
<el-icon :size="28" :color="f.color"><component :is="f.icon" /></el-icon>
</div>
<h3>{{ f.title }}</h3>
<p>{{ f.desc }}</p>
</div>
</div>
</section>
<footer class="landing-footer">
<span>TradeMate 外贸小助手 &copy; {{ new Date().getFullYear() }}</span>
</footer>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { register as registerApi } from '@/api'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const isLoggedIn = computed(() => !!localStorage.getItem('token'))
const tab = ref('login')
const loading = ref(false)
const error = ref('')
const form = reactive({ username: '', password: '' })
const regLoading = ref(false)
const regError = ref('')
const regForm = reactive({ username: '', phone: '', password: '' })
const heroFeatures = ['智能翻译', '客户管理', '营销文案', '报价单', 'WhatsApp', 'AI 助手']
const features = [
{ title: '智能翻译', desc: '20+ 语言商务翻译,AI 回复建议,信息提取', icon: 'ChatLineSquare', color: '#1890ff' },
{ title: '客户管理', desc: 'CRM + 健康评分 + 跟进记录 + 沉默预警', icon: 'User', color: '#52c41a' },
{ title: '产品库', desc: '双语产品管理,关键词标签,批量导入导出', icon: 'Goods', color: '#faad14' },
{ title: '报价单', desc: 'AI 智能报价,PDF 导出,状态追踪', icon: 'DocumentCopy', color: '#ff4d4f' },
{ title: '营销素材', desc: 'AI 生成开发信/WhatsApp 话术/产品描述', icon: 'Promotion', color: '#722ed1' },
{ title: '挖掘新客', desc: 'AI 搜索潜在客户,开发信定向生成', icon: 'Search', color: '#13c2c2' },
{ title: '智能跟进', desc: '自动生成跟进话术,一键发送 WhatsApp', icon: 'Message', color: '#eb2f96' },
{ title: '数据分析', desc: '客户/翻译/报价多维度统计图表', icon: 'DataAnalysis', color: '#1890ff' },
{ title: '团队协作', desc: '团队管理,角色权限,成员邀请', icon: 'UserFilled', color: '#fa8c16' },
]
async function submit() {
if (!form.username || !form.password) { error.value = '请输入用户名和密码'; return }
loading.value = true
error.value = ''
try {
await auth.login(form)
await auth.fetchUser()
ElMessage.success('登录成功')
const redirect = route.query.redirect || '/workspace'
router.push(redirect)
} catch (e) {
error.value = e?.detail || '登录失败'
} finally {
loading.value = false
}
}
async function register() {
if (!regForm.username || !regForm.phone || !regForm.password) { regError.value = '请填写完整'; return }
regLoading.value = true
regError.value = ''
try {
await registerApi(regForm)
ElMessage.success('注册成功,请登录')
tab.value = 'login'
form.username = regForm.username
regForm.username = ''
regForm.phone = ''
regForm.password = ''
} catch (e) {
regError.value = e?.detail || '注册失败'
} finally {
regLoading.value = false
}
}
function handleClick(f) {
if (!isLoggedIn.value) {
tab.value = 'login'
document.querySelector('.hero')?.scrollIntoView({ behavior: 'smooth' })
} else {
router.push(f.route || ('/' + f.title))
}
}
function goWorkspace() { router.push('/workspace') }
</script>
<style scoped>
.landing-page { min-height: 100vh; display: flex; flex-direction: column; background: #f5f5f5; }
.landing-header { background: #fff; border-bottom: 1px solid #eee; padding: 0 40px; height: 60px; display: flex; align-items: center; position: sticky; top: 0; z-index: 100; }
.header-inner { width: 100%; max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 16px; }
.logo { font-size: 20px; font-weight: 700; color: #1890ff; text-decoration: none; }
.logo span { color: #333; }
.subtitle { font-size: 13px; color: #999; flex: 1; }
.header-right { display: flex; align-items: center; gap: 12px; }
.header-right a { text-decoration: none; color: #555; font-size: 14px; }
.header-right a:hover { color: #1890ff; }
.hero { background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); color: #fff; }
.hero-inner { max-width: 1200px; margin: 0 auto; padding: 60px 20px; display: flex; gap: 48px; align-items: center; }
.hero-left { flex: 1; }
.hero-left h1 { font-size: 36px; font-weight: 800; margin-bottom: 16px; line-height: 1.2; }
.hero-desc { font-size: 16px; opacity: 0.85; line-height: 1.6; margin-bottom: 20px; }
.hero-features { display: flex; flex-wrap: wrap; gap: 10px; }
.hero-tag { background: rgba(255,255,255,0.15); padding: 6px 16px; border-radius: 20px; font-size: 13px; }
.hero-right { flex-shrink: 0; width: 380px; }
.login-card { background: #fff; border-radius: 12px; padding: 28px; box-shadow: 0 8px 40px rgba(0,0,0,0.15); }
.login-card.logged-in { text-align: center; padding: 40px 28px; }
.login-card.logged-in h3 { margin: 12px 0 4px; font-size: 18px; color: #333; }
.form-error { color: #f56c6c; text-align: center; font-size: 13px; margin: -8px 0 12px; }
.features { max-width: 1200px; margin: -30px auto 40px; padding: 0 20px; }
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
.feature-card { background: #fff; border-radius: 12px; padding: 28px 24px; cursor: pointer; transition: all 0.25s; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
.feature-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
.feature-icon { width: 52px; height: 52px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px; }
.feature-card h3 { font-size: 16px; margin-bottom: 8px; color: #333; }
.feature-card p { font-size: 13px; color: #999; line-height: 1.5; }
.landing-footer { text-align: center; padding: 24px; color: #999; font-size: 12px; margin-top: auto; border-top: 1px solid #e8e8e8; background: #fff; }
@media (max-width: 768px) {
.hero-inner { flex-direction: column; padding: 40px 20px; }
.hero-right { width: 100%; }
.feature-grid { grid-template-columns: repeat(2, 1fr); }
.hero-left h1 { font-size: 28px; }
.landing-header { padding: 0 16px; }
.subtitle { display: none; }
.header-right a { display: none; }
}
@media (max-width: 480px) {
.feature-grid { grid-template-columns: 1fr; }
.feature-card { padding: 20px 16px; }
}
</style>