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 底部导航修复记录
- 新增历史变更条目
This commit is contained in:
TradeMate Dev
2026-05-12 20:24:42 +08:00
parent 69e164dcae
commit 7b62c2f8b4
125 changed files with 19725 additions and 728 deletions
+516 -18
View File
@@ -1,5 +1,24 @@
<template>
<view class="customers-container">
<view class="health-overview" v-if="healthOverview">
<view class="health-stat active" @click="filter = 'all'; loadCustomers()">
<text class="health-stat-value">{{ healthOverview.active }}</text>
<text class="health-stat-label">活跃客户</text>
</view>
<view class="health-stat watch" @click="filter = 'all'; loadCustomers()">
<text class="health-stat-value">{{ healthOverview.watch }}</text>
<text class="health-stat-label">需关注</text>
</view>
<view class="health-stat critical" @click="filter = 'silent'; loadSilent()">
<text class="health-stat-value">{{ healthOverview.critical }}</text>
<text class="health-stat-label">高危流失</text>
</view>
<view class="health-stat total">
<text class="health-stat-value">{{ healthOverview.total }}</text>
<text class="health-stat-label">共计</text>
</view>
</view>
<view class="filter-tabs">
<view
class="tab-item"
@@ -44,6 +63,16 @@
<view class="customer-header">
<text class="customer-name">{{ item.name }}</text>
<text class="customer-status" :class="item.status">{{ getStatusText(item.status) }}</text>
<text
class="risk-badge"
:class="item.risk_level"
v-if="item.risk_level"
>{{ getRiskText(item.risk_level) }}</text>
<text
class="health-badge"
:class="getHealthGrade(item.id)"
v-if="getHealthGrade(item.id)"
>{{ getHealthLabel(getHealthGrade(item.id)) }}</text>
</view>
<view class="customer-detail">
<text class="detail-item" v-if="item.company">{{ item.company }}</text>
@@ -54,9 +83,13 @@
<text class="contact-time">最后联系: {{ formatTime(item.last_contact_at) }}</text>
<text class="silence-days" v-if="item.silence_days > 0">沉默 {{ item.silence_days }} </text>
</view>
<view class="risk-reasons" v-if="item.reasons && item.reasons.length > 0">
<text class="risk-reason" v-for="(r, i) in item.reasons" :key="i">{{ r }}</text>
</view>
</view>
<view class="customer-actions">
<view class="action-icon" @click="showCustomerDetail(item)"></view>
<view class="action-icon" @click="showConversation(item)"></view>
<view class="action-icon" @click="editCustomer(item)"></view>
<view class="action-icon delete" @click="deleteCustomer(item.id)"></view>
</view>
@@ -67,8 +100,19 @@
<text>暂无客户数据</text>
</view>
<view class="add-btn" @click="showAddModal = true">
<text class="add-icon">+</text>
<view class="bottom-actions">
<view class="action-btn export-btn" @click="exportCsv">
<text class="btn-icon">CSV</text>
<text class="btn-text">导出</text>
</view>
<view class="action-btn import-btn" @click="importCustomers">
<text class="btn-icon"></text>
<text class="btn-text">导入</text>
</view>
<view class="action-btn add-btn" @click="showAddModal = true">
<text class="btn-icon">+</text>
<text class="btn-text">新增</text>
</view>
</view>
<view class="modal" v-if="showAddModal || showEditModal" @click="closeModal">
@@ -121,8 +165,63 @@
<view class="detail-header">
<text class="detail-name">{{ currentCustomer.name }}</text>
<text class="detail-status" :class="currentCustomer.status">{{ getStatusText(currentCustomer.status) }}</text>
<text
class="health-badge large"
:class="currentHealth?.grade"
v-if="currentHealth"
>{{ getHealthLabel(currentHealth.grade) }}</text>
<text class="health-score" v-if="currentHealth">{{ currentHealth.total_score }}</text>
</view>
<view class="detail-body">
<view class="health-detail-section" v-if="currentHealth">
<view class="health-detail-title">健康度评分</view>
<view class="dimension-row">
<text class="dimension-label">响应趋势</text>
<view class="dimension-bar">
<view class="dimension-fill" :style="{ width: (currentHealth.dimensions?.response_trend?.score || 0) + '%' }"></view>
</view>
<text class="dimension-score">{{ currentHealth.dimensions?.response_trend?.score || 0 }}</text>
<text class="dimension-trend" :class="currentHealth.dimensions?.response_trend?.trend">{{ trendLabel(currentHealth.dimensions?.response_trend?.trend) }}</text>
</view>
<view class="dimension-row">
<text class="dimension-label">情感轨迹</text>
<view class="dimension-bar">
<view class="dimension-fill" :style="{ width: (currentHealth.dimensions?.sentiment?.score || 0) + '%' }"></view>
</view>
<text class="dimension-score">{{ currentHealth.dimensions?.sentiment?.score || 0 }}</text>
<text class="dimension-label small">{{ currentHealth.dimensions?.sentiment?.label }}</text>
</view>
<view class="dimension-row">
<text class="dimension-label">询盘深度</text>
<view class="dimension-bar">
<view class="dimension-fill" :style="{ width: (currentHealth.dimensions?.inquiry_depth?.score || 0) + '%' }"></view>
</view>
<text class="dimension-score">{{ currentHealth.dimensions?.inquiry_depth?.score || 0 }}</text>
<text class="dimension-label small" v-if="currentHealth.dimensions?.inquiry_depth?.signal_count">信号 {{ currentHealth.dimensions.inquiry_depth.signal_count }}</text>
</view>
<view class="dimension-row">
<text class="dimension-label">沉默天数</text>
<view class="dimension-bar">
<view class="dimension-fill" :style="{ width: (currentHealth.dimensions?.silence?.score || 0) + '%' }"></view>
</view>
<text class="dimension-score">{{ currentHealth.dimensions?.silence?.score || 0 }}</text>
<text class="dimension-label small">{{ currentHealth.dimensions?.silence?.days || 0 }}</text>
</view>
<view class="dimension-row">
<text class="dimension-label">商业价值</text>
<view class="dimension-bar">
<view class="dimension-fill" :style="{ width: (currentHealth.dimensions?.business_value?.score || 0) + '%' }"></view>
</view>
<text class="dimension-score">{{ currentHealth.dimensions?.business_value?.score || 0 }}</text>
<text class="dimension-label small" v-if="currentHealth.dimensions?.business_value?.total_value">${{ formatValue(currentHealth.dimensions.business_value.total_value) }}</text>
</view>
<view class="suggestion-box" v-if="currentHealth.suggestion">
<text class="suggestion-icon">💡</text>
<text class="suggestion-text">{{ currentHealth.suggestion }}</text>
</view>
</view>
<scroll-view class="detail-body" scroll-y>
<view class="detail-row" v-if="currentCustomer.company">
<text class="detail-label">公司:</text>
<text class="detail-value">{{ currentCustomer.company }}</text>
@@ -143,25 +242,51 @@
<text class="detail-label">邮箱:</text>
<text class="detail-value">{{ currentCustomer.email }}</text>
</view>
</view>
</scroll-view>
<view class="detail-footer">
<button class="close-btn" @click="showDetailModal = false">关闭</button>
</view>
</view>
</view>
<view class="conversation-modal" v-if="showConversationModal" @click="showConversationModal = false">
<view class="conversation-content" @click.stop>
<view class="conversation-header">
<text class="conversation-title">沟通记录 - {{ conversationCustomer?.name }}</text>
<text class="conversation-close" @click="showConversationModal = false">×</text>
</view>
<scroll-view class="conversation-body" scroll-y>
<view class="msg-item" v-for="(msg, i) in conversation" :key="i">
<text class="msg-direction" :class="msg.direction">{{ msg.direction === 'inbound' ? '客户' : '我' }}</text>
<text class="msg-content">{{ msg.content }}</text>
<text class="msg-time">{{ formatTime(msg.created_at) }}</text>
</view>
<view class="empty-msg" v-if="conversation.length === 0">
<text>暂无沟通记录</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onShow } from 'vue'
import { customerApi } from '@/utils/api.js'
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { customerApi, healthApi, silentPatternApi } from '@/utils/api.js'
const filter = ref('all')
const customers = ref([])
const showAddModal = ref(false)
const showEditModal = ref(false)
const showDetailModal = ref(false)
const showConversationModal = ref(false)
const currentCustomer = ref(null)
const currentHealth = ref(null)
const conversationCustomer = ref(null)
const conversation = ref([])
const healthOverview = ref(null)
const healthScores = ref({})
const formData = ref({
name: '',
company: '',
@@ -176,17 +301,59 @@ const statusOptions = ['lead', 'negotiating', 'customer', 'lost']
onShow(() => {
loadCustomers()
loadHealthOverview()
})
const loadCustomers = async () => {
try {
const res = await customerApi.list(1, 20, filter.value === 'all' ? undefined : filter.value)
customers.value = res.items || []
loadHealthScores()
} catch (err) {
uni.showToast({ title: err.message || '加载失败', icon: 'none' })
}
}
const loadHealthOverview = async () => {
try {
const res = await healthApi.overview()
healthOverview.value = res
} catch (err) {
console.error('加载健康概览失败', err)
}
}
const loadHealthScores = async () => {
try {
const res = await healthApi.allScores()
const map = {}
;(res.items || []).forEach(h => { map[h.customer_id] = h })
healthScores.value = map
} catch (err) {
console.error('加载健康评分失败', err)
}
}
const getHealthGrade = (customerId) => {
return healthScores.value[customerId]?.grade || null
}
const getHealthLabel = (grade) => {
const map = { active: '活跃', watch: '关注', critical: '高危' }
return map[grade] || grade
}
const trendLabel = (trend) => {
const map = { improving: '↑', declining: '↓', stable: '→' }
return map[trend] || ''
}
const formatValue = (val) => {
if (!val) return '0'
if (val >= 10000) return (val / 10000).toFixed(1) + '万'
return val.toLocaleString()
}
const loadSilent = async () => {
try {
const res = await customerApi.getSilent(7)
@@ -196,6 +363,11 @@ const loadSilent = async () => {
}
}
const getRiskText = (level) => {
const map = { high: '高风险', medium: '中风险', low: '低风险' }
return map[level] || level
}
const getStatusText = (status) => {
const map = { lead: '潜在', negotiating: '谈判中', customer: '已成交', lost: '已丢失' }
return map[status] || status
@@ -206,9 +378,28 @@ const formatTime = (time) => {
return time.split('T')[0]
}
const showCustomerDetail = (item) => {
const showCustomerDetail = async (item) => {
currentCustomer.value = item
showDetailModal.value = true
try {
const res = await healthApi.customerHealth(item.id)
currentHealth.value = res
} catch (err) {
currentHealth.value = null
console.error('加载健康详情失败', err)
}
}
const showConversation = async (item) => {
conversationCustomer.value = item
showConversationModal.value = true
try {
const res = await customerApi.getConversation(item.id)
conversation.value = res.items || res.messages || []
} catch (err) {
conversation.value = []
uni.showToast({ title: '加载沟通记录失败', icon: 'none' })
}
}
const editCustomer = (item) => {
@@ -255,6 +446,45 @@ const submitCustomer = async () => {
}
}
const exportCsv = () => {
const url = customerApi.exportCsv()
const token = uni.getStorageSync('token')
uni.downloadFile({
url,
header: { Authorization: `Bearer ${token}` },
success: (res) => {
if (res.statusCode === 200) {
uni.showToast({ title: '导出成功', icon: 'success' })
} else {
uni.showToast({ title: '导出失败', icon: 'none' })
}
},
fail: () => { uni.showToast({ title: '导出失败', icon: 'none' }) },
})
}
const importCustomers = () => {
uni.chooseImage({
count: 1,
success: async (res) => {
const file = res.tempFilePaths[0]
uni.showLoading({ title: '导入中...' })
try {
const result = await customerApi.importCustomers(file)
uni.hideLoading()
uni.showModal({
title: '导入完成',
content: `成功导入 ${result.imported || 0}\n失败 ${(result.errors || []).length}`,
success: () => loadCustomers(),
})
} catch (err) {
uni.hideLoading()
uni.showToast({ title: err.message || '导入失败', icon: 'none' })
}
},
})
}
const deleteCustomer = async (id) => {
uni.showModal({
title: '确认删除',
@@ -281,6 +511,141 @@ const deleteCustomer = async (id) => {
padding: 20rpx;
}
.health-overview {
display: flex;
gap: 12rpx;
margin-bottom: 20rpx;
}
.health-stat {
flex: 1;
background: #fff;
border-radius: 12rpx;
padding: 16rpx 8rpx;
text-align: center;
}
.health-stat-value {
font-size: 36rpx;
font-weight: 700;
display: block;
}
.health-stat-label {
font-size: 20rpx;
color: #999;
margin-top: 4rpx;
}
.health-stat.active .health-stat-value { color: #52c41a; }
.health-stat.watch .health-stat-value { color: #fa8c16; }
.health-stat.critical .health-stat-value { color: #ff4d4f; }
.health-stat.total .health-stat-value { color: #1890ff; }
.health-badge {
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 4rpx;
margin-left: 8rpx;
}
.health-badge.active { background: #f6ffed; color: #52c41a; }
.health-badge.watch { background: #fff7e6; color: #fa8c16; }
.health-badge.critical { background: #fff1f0; color: #ff4d4f; }
.health-badge.large { padding: 4rpx 16rpx; font-size: 24rpx; }
.health-score {
font-size: 40rpx;
font-weight: 700;
color: #1890ff;
margin-left: auto;
}
.health-detail-section {
padding: 20rpx;
background: #f9f9f9;
border-radius: 12rpx;
margin: 0 30rpx 20rpx;
}
.health-detail-title {
font-size: 26rpx;
font-weight: 600;
color: #333;
margin-bottom: 16rpx;
}
.dimension-row {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.dimension-label {
font-size: 22rpx;
color: #666;
min-width: 80rpx;
}
.dimension-label.small {
min-width: auto;
font-size: 20rpx;
}
.dimension-bar {
flex: 1;
height: 12rpx;
background: #e8e8e8;
border-radius: 6rpx;
overflow: hidden;
}
.dimension-fill {
height: 100%;
background: linear-gradient(90deg, #52c41a, #1890ff);
border-radius: 6rpx;
transition: width 0.3s;
}
.dimension-score {
font-size: 22rpx;
font-weight: 600;
color: #333;
min-width: 32rpx;
text-align: right;
}
.dimension-trend {
font-size: 20rpx;
min-width: 24rpx;
}
.dimension-trend.improving { color: #52c41a; }
.dimension-trend.declining { color: #ff4d4f; }
.dimension-trend.stable { color: #999; }
.suggestion-box {
display: flex;
align-items: flex-start;
gap: 8rpx;
margin-top: 16rpx;
padding: 12rpx;
background: #e6f7ff;
border-radius: 8rpx;
}
.suggestion-icon {
font-size: 24rpx;
}
.suggestion-text {
font-size: 24rpx;
color: #1890ff;
line-height: 1.4;
flex: 1;
}
.filter-tabs {
display: flex;
background: #fff;
@@ -327,6 +692,7 @@ const deleteCustomer = async (id) => {
.customer-header {
display: flex;
align-items: center;
flex-wrap: wrap;
margin-bottom: 12rpx;
}
@@ -347,6 +713,17 @@ const deleteCustomer = async (id) => {
.customer-status.customer { background: #f6ffed; color: #52c41a; }
.customer-status.lost { background: #fff1f0; color: #ff4d4f; }
.risk-badge {
font-size: 20rpx;
padding: 2rpx 10rpx;
border-radius: 4rpx;
margin-left: 8rpx;
}
.risk-badge.high { background: #fff1f0; color: #ff4d4f; }
.risk-badge.medium { background: #fff7e6; color: #fa8c16; }
.risk-badge.low { background: #f6ffed; color: #52c41a; }
.customer-detail {
display: flex;
flex-wrap: wrap;
@@ -373,6 +750,21 @@ const deleteCustomer = async (id) => {
color: #ff4d4f;
}
.risk-reasons {
display: flex;
flex-wrap: wrap;
gap: 8rpx;
margin-top: 8rpx;
}
.risk-reason {
font-size: 20rpx;
color: #ff4d4f;
background: #fff1f0;
padding: 2rpx 10rpx;
border-radius: 4rpx;
}
.customer-actions {
display: flex;
flex-direction: column;
@@ -401,23 +793,48 @@ const deleteCustomer = async (id) => {
padding: 100rpx;
}
.add-btn {
.bottom-actions {
position: fixed;
right: 40rpx;
bottom: 40rpx;
width: 100rpx;
height: 100rpx;
background: #1890ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.4);
flex-direction: column;
gap: 24rpx;
}
.add-icon {
font-size: 60rpx;
.action-btn {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.add-btn {
background: #1890ff;
}
.import-btn {
background: #52c41a;
}
.export-btn {
background: #722ed1;
}
.btn-icon {
font-size: 32rpx;
color: #fff;
line-height: 1;
}
.btn-text {
font-size: 18rpx;
color: #fff;
margin-top: 2rpx;
}
.modal {
@@ -588,4 +1005,85 @@ const deleteCustomer = async (id) => {
border-radius: 8rpx;
font-size: 28rpx;
}
</style>
.conversation-modal {
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;
}
.conversation-content {
width: 90%;
height: 70%;
background: #fff;
border-radius: 16rpx;
display: flex;
flex-direction: column;
}
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 2rpx solid #f5f5f5;
}
.conversation-title {
font-size: 32rpx;
font-weight: 600;
}
.conversation-close {
font-size: 44rpx;
color: #999;
}
.conversation-body {
flex: 1;
padding: 30rpx;
}
.msg-item {
padding: 16rpx;
margin-bottom: 16rpx;
background: #f9f9f9;
border-radius: 12rpx;
}
.msg-direction {
font-size: 22rpx;
padding: 2rpx 10rpx;
border-radius: 4rpx;
margin-bottom: 8rpx;
display: inline-block;
}
.msg-direction.inbound { background: #e6f7ff; color: #1890ff; }
.msg-direction.outbound { background: #f6ffed; color: #52c41a; }
.msg-content {
font-size: 26rpx;
line-height: 1.5;
display: block;
margin-bottom: 8rpx;
}
.msg-time {
font-size: 20rpx;
color: #999;
}
.empty-msg {
text-align: center;
color: #999;
padding: 60rpx;
}
</style>