Files
trade-assistant/user-frontend/src/views/Workspace.vue
T
TradeMate Dev a95e8b2b73 feat: frontend credit system UI
Admin:
- New CreditManagement.vue (tabs: rates, packages, plans, user credits, consumptions, stats)
- Sidebar menu + router entry
- Full CRUD for credit packages and subscription plans
- User credit balance adjustment
- Consumption log viewer

User:
- Credits.vue replaces Upgrade.vue (package purchase, subscription, history tabs)
- Credit balance display in topbar + dashboard header CTA card
- Navigation restructured: discovery first
- Profile redirects to /credits
- Dashboard upgrade dialog simplified to redirect to /credits
2026-06-12 11:00:22 +08:00

300 lines
15 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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="$router.push('/credits')">购买更多次数</el-button></span>
</template>
</el-alert>
<el-card shadow="never" style="margin-bottom:16px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);color:#fff">
<div style="display:flex;align-items:center;justify-content:space-between;padding:8px 0">
<div>
<div style="font-size:14px;opacity:0.9">信用余额</div>
<div style="font-size:28px;font-weight:700">{{ creditBalance ?? '...' }} </div>
</div>
<div style="text-align:right">
<el-button type="primary" @click="$router.push('/discovery')" style="margin-bottom:4px">发现新客户</el-button>
<br>
<el-button text style="color:#fff;opacity:0.8" @click="$router.push('/credits')">购买次数 </el-button>
</div>
</div>
</el-card>
<el-row :gutter="20">
<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="$router.push('/credits')">购买更多次数</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 :xs="24" :sm="12" :md="8">
<el-card shadow="never">
<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:130px" size="default">
<el-option label="英语" value="en" />
<el-option label="中文" value="zh" />
<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" class="quick-result">{{ quickResult }}</p>
</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('/followup')">查看全部</el-button>
</div>
</template>
<div v-if="followups.length">
<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="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-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>{{ row.free }}</span></template>
</el-table-column>
<el-table-column label="Pro ¥99/月" width="160">
<template #default="{ row }"><span>{{ row.pro }}</span></template>
</el-table-column>
<el-table-column label="企业 ¥399/月" width="160">
<template #default="{ row }"><span>{{ row.enterprise }}</span></template>
</el-table-column>
</el-table>
<div style="text-align:center;margin-top:20px">
<el-button type="primary" size="large" @click="$router.push('/credits')">购买次数</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { getCreditBalance } from '@/api'
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 creditBalance = ref(null)
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: '智能翻译', 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 {
getCreditBalance().then(res => { creditBalance.value = res.balance }).catch(() => {})
const [overview, fup, silent, usage] = await Promise.all([
getAnalyticsOverview().catch(() => null),
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 || 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 */ }
})
async function doQuickTranslate() {
if (!quickText.value.trim()) return
translating.value = true
try {
const res = await translate({ text: quickText.value, target_lang: quickLang.value })
quickResult.value = res.data?.translated_text || res.translated_text || ''
} catch { quickResult.value = '翻译失败' }
finally { translating.value = false }
}
</script>
<style scoped>
.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; }
.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>