Files
trade-assistant/uni-app/src/pages/index/index.vue
T
TradeMate Dev 7b62c2f8b4 feat: 修复 H5 底部导航覆盖 + 更新项目进度文档
## H5 底部导航修复 (Bug #10)
- 精简 App.vue,移除重复 tabbar,仅保留全局样式
- uni-page 设置 height: calc(100% - 50px) + overflow-y: auto
- 内容区域精确停在底部导航上方,独立滚动不再叠加
- 恢复 custom-tab-bar 组件

## 项目进度文档
- PROGRESS.md 更新至 10 个 Bug 修复
- 新增 H5 底部导航修复记录
- 新增历史变更条目
2026-05-12 20:24:42 +08:00

857 lines
22 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
<view class="index-container">
<view class="header">
<view class="user-info" v-if="hasLogin">
<text class="username">{{ userInfo?.username || '用户' }}</text>
<text class="tier">{{ userInfo?.tier === 'pro' ? 'Pro' : '免费版' }}</text>
</view>
<view class="guest-info" v-else>
<text class="guest-label">👋 游客模式</text>
<button class="login-btn" @click="goToLogin">登录</button>
</view>
<text class="logout" @click="handleLogout" v-if="hasLogin">退出</text>
</view>
<view class="stats-grid" v-if="hasLogin">
<view class="stat-card">
<text class="stat-value">{{ stats.customers }}</text>
<text class="stat-label">客户总数</text>
</view>
<view class="stat-card warning">
<text class="stat-value">{{ stats.silentCustomers }}</text>
<text class="stat-label">沉默客户</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.todayTranslations }}</text>
<text class="stat-label">今日翻译</text>
</view>
<view class="stat-card">
<text class="stat-value">{{ stats.quotations }}</text>
<text class="stat-label">报价单</text>
</view>
</view>
<view class="guest-welcome" v-else>
<view class="welcome-content">
<text class="welcome-title">欢迎使用外贸小助手 🎉</text>
<text class="welcome-desc">您当前处于游客模式可以体验翻译和信息提取功能</text>
<text class="welcome-hint">登录后可解锁客户管理报价单生成数据分析等更多功能</text>
</view>
<view class="quick-try-section">
<view class="section-title">快速体验</view>
<view class="try-area">
<textarea
class="try-input"
v-model="tryText"
placeholder="输入中文或英文,体验智能翻译..."
/>
<view class="try-actions">
<button class="try-btn primary" @click="handleTryTranslate" :disabled="tryLoading">
{{ tryLoading ? '翻译中...' : '翻译' }}
</button>
<button class="try-btn" @click="handleTryExtract" :disabled="tryLoading">
{{ tryLoading ? '提取中...' : '提取信息' }}
</button>
</view>
</view>
<view class="try-result" v-if="tryResult">
<view class="result-header">
<text class="result-label">翻译结果</text>
<text class="result-copy" @click="copyTryResult">复制</text>
</view>
<view class="result-content">
<text class="result-text">{{ tryResult }}</text>
</view>
</view>
<view class="try-extracted" v-if="tryExtracted">
<view class="result-header">
<text class="result-label">提取结果</text>
</view>
<view class="extracted-content">
<text class="extracted-text">{{ tryExtracted }}</text>
</view>
</view>
</view>
</view>
<view class="section" v-if="hasLogin">
<view class="section-title">待跟进客户</view>
<view class="silent-list" v-if="silentCustomers.length > 0">
<view class="silent-item" v-for="item in silentCustomers" :key="item.id">
<view class="silent-info">
<text class="silent-name">{{ item.name }}</text>
<text class="silent-country">{{ item.country }}</text>
</view>
<view class="silent-days">
<text class="days">{{ item.silence_days }}</text>
<text class="label">未联系</text>
</view>
</view>
</view>
<view class="empty" v-else>暂无待跟进客户</view>
</view>
<view class="quick-actions">
<view class="action-item" @click="goToPage('/pages/translate/translate')">
<text class="action-icon">🔤</text>
<text class="action-text">翻译</text>
</view>
<view class="action-item" @click="hasLogin ? goToPage('/pages/customers/customers') : goToLogin()">
<text class="action-icon">👥</text>
<text class="action-text">客户</text>
</view>
<view class="action-item" @click="hasLogin ? goToPage('/pages/marketing/marketing') : goToLogin()">
<text class="action-icon">📢</text>
<text class="action-text">营销</text>
</view>
<view class="action-item" @click="hasLogin ? goToPage('/pages/quotation/quotation') : goToLogin()">
<text class="action-icon">📄</text>
<text class="action-text">报价</text>
</view>
</view>
<view class="section" v-if="hasLogin && followupStats.pending > 0">
<view class="section-title">
<text>待跟进提醒</text>
<text class="section-more" @click="goToPage('/pages/followup/followup')">查看全部 ></text>
</view>
<view class="followup-card" @click="goToPage('/pages/followup/followup')">
<text class="followup-count">{{ followupStats.pending }}</text>
<text class="followup-label">个客户需要跟进</text>
<text class="followup-hint">{{ followupStats.sent }} 已发送 · {{ followupStats.replied }} 已回复</text>
</view>
</view>
<view class="more-section">
<view class="section-title">更多功能</view>
<view class="more-grid">
<view class="more-item" @click="hasLogin ? goToPage('/pages/product/product') : goToLogin()">
<text class="more-icon">📦</text>
<text class="more-text">产品库</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/followup/followup') : goToLogin()">
<text class="more-icon">📋</text>
<text class="more-text">跟进</text>
<text class="notif-badge" v-if="hasLogin && followupStats.pending > 0">{{ followupStats.pending > 99 ? '99+' : followupStats.pending }}</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/notification/notification') : goToLogin()">
<text class="more-icon">🔔</text>
<text class="more-text">通知</text>
<text class="notif-badge" v-if="hasLogin && unreadCount > 0">{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/analytics/analytics') : goToLogin()">
<text class="more-icon">📊</text>
<text class="more-text">分析</text>
</view>
<view class="more-item" @click="goToPage('/pages/upgrade/upgrade')">
<text class="more-icon">💎</text>
<text class="more-text">升级</text>
</view>
<view class="more-item" @click="goToPage('/pages/feedback/feedback')">
<text class="more-icon">💬</text>
<text class="more-text">反馈</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/team/team') : goToLogin()">
<text class="more-icon">👨👩👧👦</text>
<text class="more-text">团队</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage('/pages/admin/admin') : goToLogin()">
<text class="more-icon"></text>
<text class="more-text">管理</text>
</view>
</view>
</view>
<view class="onboarding-overlay" v-if="showOnboarding">
<view class="onboarding-modal">
<text class="ob-title">欢迎使用外贸小助手</text>
<text class="ob-subtitle">先告诉我你的产品信息我会为你生成营销素材</text>
<view class="ob-input-group" v-if="onboardingStep === 1">
<text class="ob-label">产品名称</text>
<input class="ob-input" v-model="productName" placeholder="例如:户外折叠椅" />
</view>
<view class="ob-input-group" v-if="onboardingStep === 1">
<text class="ob-label">产品描述</text>
<textarea class="ob-input ob-textarea" v-model="productDesc" placeholder="例如:承重150kg,防水面料,带杯架和扶手" />
</view>
<view class="ob-input-group" v-if="onboardingStep === 1">
<text class="ob-label">目标市场</text>
<input class="ob-input" v-model="targetMarket" placeholder="例如:US importers" />
</view>
<view class="ob-generating" v-if="onboardingStep === 2">
<text class="ob-gen-text">正在为你生成营销素材...</text>
</view>
<view class="ob-result" v-if="onboardingStep === 3 && generatedContent.length > 0">
<text class="ob-result-title">已为你生成以下内容</text>
<view class="ob-content-item" v-for="(item, i) in generatedContent" :key="i">
<text class="ob-content-text">{{ item }}</text>
</view>
<text class="ob-result-hint">你可以去"营销素材""产品库"查看更多</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>
</view>
<text class="ob-skip" @click="finishOnboarding" v-if="onboardingStep === 1">跳过以后再说</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi } from '@/utils/api.js'
const userInfo = ref(null)
const hasLogin = computed(() => {
const token = uni.getStorageSync('token')
const isGuest = uni.getStorageSync('isGuest')
return !!token && !isGuest
})
const stats = ref({
customers: 0,
silentCustomers: 0,
todayTranslations: 0,
quotations: 0,
})
const silentCustomers = ref([])
const unreadCount = ref(0)
const followupStats = ref({ pending: 0, sent: 0, replied: 0 })
const showOnboarding = ref(false)
const onboardingStep = ref(1)
const productName = ref('')
const productDesc = ref('')
const targetMarket = ref('US importers')
const generatedContent = ref([])
const tryText = ref('')
const tryResult = ref('')
const tryExtracted = ref('')
const tryLoading = ref(false)
onShow(() => {
const token = uni.getStorageSync('token')
const isGuest = uni.getStorageSync('isGuest')
if (token && !isGuest) {
uni.setStorageSync('isGuest', false)
loadData()
checkOnboarding()
loadUnread()
loadFollowupStats()
} else {
tryResult.value = ''
tryExtracted.value = ''
tryText.value = ''
}
})
const checkOnboarding = async () => {
if (uni.getStorageSync('onboarded')) return
try {
const res = await onboardingApi.status()
if (!res.onboarded) {
showOnboarding.value = true
}
} catch (_) {
}
}
const onboardingNext = async () => {
if (!productName.value.trim()) {
uni.showToast({ title: '请输入产品名称', icon: 'none' })
return
}
onboardingStep.value = 2
try {
const res = await onboardingApi.createProduct(
productName.value.trim(),
productDesc.value.trim(),
'',
targetMarket.value.trim() || 'US importers',
)
generatedContent.value = res.generated_content || []
onboardingStep.value = 3
} catch (err) {
uni.showToast({ title: err.message || '生成失败', icon: 'none' })
onboardingStep.value = 1
}
}
const loadUnread = async () => {
try {
const res = await notificationApi.unreadCount()
unreadCount.value = res.count || 0
} catch (_) {}
}
const loadFollowupStats = async () => {
try {
const res = await followupApi.stats()
followupStats.value = res
} catch (_) {}
}
const finishOnboarding = () => {
uni.setStorageSync('onboarded', true)
showOnboarding.value = false
onboardingStep.value = 1
productName.value = ''
productDesc.value = ''
generatedContent.value = []
loadData()
}
const loadData = async () => {
try {
const [userRes, silentRes, overviewRes] = await Promise.all([
authApi.getUserInfo(),
customerApi.getSilent(3),
analyticsApi.getOverview(),
])
userInfo.value = userRes
silentCustomers.value = silentRes.customers || []
stats.value = {
customers: overviewRes.customers?.total || 0,
silentCustomers: overviewRes.customers?.silent_customers || 0,
todayTranslations: overviewRes.translations?.today || 0,
quotations: overviewRes.quotations?.total || 0,
}
} catch (err) {
console.error('加载数据失败', err)
}
}
const goToPage = (url) => {
if (url === '/pages/followup/followup') {
uni.navigateTo({ url })
} else {
uni.switchTab({ url })
}
}
const goToLogin = () => {
uni.reLaunch({ url: '/pages/login/login' })
}
const handleLogout = () => {
uni.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
uni.clearStorageSync()
uni.reLaunch({ url: '/pages/login/login' })
}
},
})
}
const handleTryTranslate = async () => {
if (!tryText.value.trim()) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
tryLoading.value = true
tryResult.value = ''
tryExtracted.value = ''
try {
const chinesePattern = /[\u4e00-\u9fa5]/
const targetLang = chinesePattern.test(tryText.value) ? 'en' : 'zh'
const isGuest = uni.getStorageSync('isGuest')
const res = isGuest
? await translateApi.publicTranslate(tryText.value, targetLang, 'auto')
: await translateApi.translate(tryText.value, targetLang, 'auto')
tryResult.value = res.translated_text || res.translated || '翻译成功'
uni.showToast({ title: '翻译成功', icon: 'success' })
} catch (err) {
uni.showToast({ title: err.message || '翻译失败', icon: 'none' })
} finally {
tryLoading.value = false
}
}
const handleTryExtract = async () => {
if (!tryText.value.trim()) {
uni.showToast({ title: '请输入内容', icon: 'none' })
return
}
tryLoading.value = true
tryResult.value = ''
tryExtracted.value = ''
try {
const isGuest = uni.getStorageSync('isGuest')
const res = isGuest
? await translateApi.publicExtract(tryText.value, 'auto')
: await translateApi.extract(tryText.value, 'auto')
const extracted = res.extracted || {}
tryExtracted.value = JSON.stringify(extracted, null, 2)
uni.showToast({ title: '提取成功', icon: 'success' })
} catch (err) {
uni.showToast({ title: err.message || '提取失败', icon: 'none' })
} finally {
tryLoading.value = false
}
}
const copyTryResult = () => {
if (!tryResult.value) return
uni.setClipboardData({
data: tryResult.value,
success: () => {
uni.showToast({ title: '已复制', icon: 'success' })
},
})
}
</script>
<style lang="scss" scoped>
.index-container {
min-height: 100%;
background: #f5f5f5;
padding: 20rpx;
box-sizing: border-box;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
background: linear-gradient(135deg, #1890ff 0%, #69c0ff 100%);
border-radius: 16rpx;
margin-bottom: 30rpx;
}
.user-info {
display: flex;
align-items: center;
}
.guest-info {
display: flex;
align-items: center;
gap: 16rpx;
}
.guest-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
}
.login-btn {
background: rgba(255, 255, 255, 0.2);
color: #fff;
font-size: 24rpx;
padding: 8rpx 24rpx;
border-radius: 32rpx;
border: none;
}
.username {
font-size: 32rpx;
color: #fff;
font-weight: 600;
margin-right: 16rpx;
}
.tier {
font-size: 22rpx;
color: #fff;
background: rgba(255, 255, 255, 0.2);
padding: 4rpx 12rpx;
border-radius: 8rpx;
}
.logout {
font-size: 26rpx;
color: #fff;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20rpx;
margin-bottom: 30rpx;
}
.stat-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
text-align: center;
}
.stat-card.warning .stat-value {
color: #ff4d4f;
}
.stat-value {
font-size: 48rpx;
font-weight: bold;
color: #1890ff;
display: block;
}
.stat-label {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
display: block;
}
.guest-welcome {
background: #fff;
border-radius: 20rpx;
padding: 40rpx 32rpx;
margin-bottom: 30rpx;
}
.welcome-content {
text-align: center;
margin-bottom: 40rpx;
}
.welcome-title {
font-size: 36rpx;
font-weight: 700;
color: #333;
display: block;
margin-bottom: 16rpx;
}
.welcome-desc {
font-size: 26rpx;
color: #666;
display: block;
margin-bottom: 12rpx;
}
.welcome-hint {
font-size: 24rpx;
color: #1890ff;
background: #e6f7ff;
padding: 12rpx 24rpx;
border-radius: 32rpx;
display: inline-block;
}
.quick-try-section {
border-top: 2rpx solid #f5f5f5;
padding-top: 32rpx;
}
.section-title {
font-size: 30rpx;
font-weight: 600;
margin-bottom: 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.section-more {
font-size: 24rpx;
color: #1890ff;
}
.try-area {
background: #fafafa;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.try-input {
width: 100%;
min-height: 180rpx;
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
font-size: 28rpx;
line-height: 1.6;
box-sizing: border-box;
}
.try-actions {
display: flex;
gap: 20rpx;
margin-top: 20rpx;
}
.try-btn {
flex: 1;
height: 80rpx;
background: #fff;
border: 2rpx solid #d9d9d9;
color: #666;
border-radius: 12rpx;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
}
.try-btn.primary {
background: #1890ff;
color: #fff;
border-color: #1890ff;
}
.try-btn[disabled] {
opacity: 0.6;
}
.try-result, .try-extracted {
background: #f6ffed;
border-radius: 12rpx;
padding: 24rpx;
margin-top: 20rpx;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.result-label {
font-size: 24rpx;
color: #52c41a;
font-weight: 500;
}
.result-copy {
font-size: 24rpx;
color: #1890ff;
}
.result-content, .extracted-content {
background: #fff;
border-radius: 8rpx;
padding: 16rpx;
}
.result-text, .extracted-text {
font-size: 28rpx;
color: #333;
line-height: 1.6;
white-space: pre-wrap;
}
.section {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 30rpx;
}
.silent-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.silent-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.silent-info {
display: flex;
flex-direction: column;
}
.silent-name {
font-size: 28rpx;
color: #333;
}
.silent-country {
font-size: 24rpx;
color: #999;
margin-top: 4rpx;
}
.silent-days {
text-align: right;
}
.silent-days .days {
font-size: 28rpx;
color: #ff4d4f;
font-weight: 600;
}
.silent-days .label {
font-size: 22rpx;
color: #999;
display: block;
}
.empty {
text-align: center;
color: #999;
padding: 40rpx;
}
.quick-actions {
display: flex;
justify-content: space-around;
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
}
.action-item {
display: flex;
flex-direction: column;
align-items: center;
}
.action-icon {
width: 80rpx;
height: 80rpx;
background: #e6f7ff;
color: #1890ff;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
font-weight: 600;
margin-bottom: 12rpx;
}
.action-text {
font-size: 24rpx;
color: #666;
}
.followup-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16rpx;
padding: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.followup-count {
font-size: 64rpx;
font-weight: bold;
color: #fff;
}
.followup-label {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.9);
margin-top: 8rpx;
}
.followup-hint {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.7);
margin-top: 12rpx;
}
.more-section {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-top: 20rpx;
}
.more-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
margin-top: 20rpx;
}
.more-item {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
}
.more-icon {
width: 72rpx;
height: 72rpx;
background: #f0f5ff;
color: #667eea;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 28rpx;
font-weight: 600;
margin-bottom: 8rpx;
}
.more-text {
font-size: 22rpx;
color: #666;
}
.notif-badge {
position: absolute; top: -8rpx; right: -8rpx;
background: #ff4d4f; color: #fff; font-size: 18rpx;
min-width: 30rpx; height: 30rpx; border-radius: 15rpx;
text-align: center; line-height: 30rpx; padding: 0 6rpx;
}
.onboarding-overlay {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center;
z-index: 999; padding: 40rpx;
}
.onboarding-modal {
background: #fff; border-radius: 24rpx; padding: 48rpx 40rpx;
width: 100%; max-width: 600rpx;
}
.ob-title { font-size: 36rpx; font-weight: 700; color: #333; display: block; text-align: center; }
.ob-subtitle { font-size: 26rpx; color: #999; display: block; text-align: center; margin: 16rpx 0 40rpx; }
.ob-input-group { margin-bottom: 24rpx; }
.ob-label { font-size: 26rpx; color: #666; display: block; margin-bottom: 8rpx; }
.ob-input { width: 100%; height: 80rpx; background: #f5f5f5; border-radius: 12rpx; padding: 0 20rpx; font-size: 28rpx; box-sizing: border-box; }
.ob-textarea { height: 160rpx; padding: 20rpx; }
.ob-generating { padding: 60rpx 0; text-align: center; }
.ob-gen-text { font-size: 28rpx; color: #1890ff; }
.ob-result { margin: 24rpx 0; }
.ob-result-title { font-size: 26rpx; color: #333; font-weight: 500; display: block; margin-bottom: 16rpx; }
.ob-content-item {
padding: 16rpx; background: #f9f9f9; border-radius: 12rpx; margin-bottom: 12rpx;
}
.ob-content-text { font-size: 24rpx; color: #555; line-height: 1.6; }
.ob-result-hint { font-size: 22rpx; color: #999; display: block; text-align: center; margin-top: 16rpx; }
.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-skip { display: block; text-align: center; margin-top: 24rpx; font-size: 24rpx; color: #999; }
</style>