petstore-frontend/src/pages/mine/Leads.vue
2026-04-18 09:19:46 +08:00

400 lines
10 KiB
Vue

<template>
<view class="page-shell leads-page">
<!-- 顶部导航 -->
<view class="leads-nav nav-gradient" :style="navSafeStyle">
<view class="nav-back" @click="goBack">
<AppIcon name="back" :size="16" color="#ffffff" />
</view>
<text class="nav-title">回访池</text>
<view class="nav-placeholder"></view>
</view>
<!-- 概述 -->
<view class="page-section hero">
<view class="hero-title">即将到期 · 待回访</view>
<view class="hero-sub">
<text class="hero-count">{{ pendingCount }}</text> 位宠主等着你联系
<text class="hero-hint">系统根据服务类型自动推算下次到店时间</text>
</view>
</view>
<!-- Tab 切换 -->
<view class="tab-bar">
<view
class="tab-item"
:class="{ active: tab === 'pending' }"
@click="switchTab('pending')"
>
<text>待回访</text>
</view>
<view
class="tab-item"
:class="{ active: tab === 'all' }"
@click="switchTab('all')"
>
<text>全部留资</text>
</view>
</view>
<!-- 列表 -->
<view v-if="loading" class="state-wrap">
<view class="spinner"></view>
<text class="state-text">加载中…</text>
</view>
<view v-else-if="list.length === 0" class="empty">
<text>{{ tab === 'pending' ? '暂无待回访,今天可以喘口气' : '还没有宠主留资' }}</text>
</view>
<view v-else class="list">
<view
v-for="item in list"
:key="item.id"
class="lead-card"
:class="statusClass(item)"
>
<view class="lead-head">
<view class="lead-pet">
<text class="pet-name">{{ item.petName || '未命名' }}</text>
<text class="pet-service">{{ item.serviceType || '-' }}</text>
</view>
<view class="lead-status-wrap">
<view class="lead-status" :class="statusClass(item)">
{{ statusText(item) }}
</view>
</view>
</view>
<view class="lead-body">
<view class="lead-row">
<text class="row-label">下次建议</text>
<text class="row-value strong">{{ formatDate(item.remindDate) }}</text>
<text class="row-tag" :class="dueTagClass(item)">{{ dueTagText(item) }}</text>
</view>
<view class="lead-row">
<text class="row-label">手机号</text>
<text class="row-value phone">{{ item.phone }}</text>
</view>
<view class="lead-row">
<text class="row-label">留资时间</text>
<text class="row-value">{{ formatDateTime(item.createTime) }}</text>
</view>
</view>
<view class="lead-actions">
<button class="lead-btn primary" @click="callPhone(item.phone)">
拨号联系
</button>
<button class="lead-btn ghost" @click="copyPhone(item.phone)">
复制手机号
</button>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppIcon from '../../components/AppIcon.vue'
import { getReportLeads } from '../../api/index.js'
import { getStoreSession, getUserSession } from '../../utils/session.js'
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
const userInfo = getUserSession()
const storeInfo = getStoreSession()
const tab = ref('pending')
const loading = ref(false)
const list = ref([])
const pendingCount = ref(0)
const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44
const menuRect = uni.getMenuButtonBoundingClientRect?.()
if (menuRect && menuRect.top && menuRect.height) {
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4)
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height
}
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
})()
const switchTab = (t) => {
if (tab.value === t) return
tab.value = t
loadList()
}
const loadList = async () => {
if (!storeInfo.id) return
loading.value = true
try {
const res = await getReportLeads(storeInfo.id, tab.value)
if (res && res.code === 200) {
list.value = res.data || []
if (tab.value === 'pending') {
pendingCount.value = list.value.length
}
}
} finally {
loading.value = false
}
}
const refreshPendingCount = async () => {
if (tab.value === 'pending') return
try {
const res = await getReportLeads(storeInfo.id, 'pending')
if (res && res.code === 200) {
pendingCount.value = (res.data || []).length
}
} catch (e) {
// ignore
}
}
const goBack = () => {
if (uni.navigateBack) {
uni.navigateBack({ delta: 1, fail: () => uni.switchTab && uni.switchTab({ url: '/pages/mine/Mine' }) })
}
}
const formatDate = (d) => {
if (!d) return '-'
const parts = String(d).split('-')
if (parts.length !== 3) return d
return `${parts[0]}${parseInt(parts[1], 10)}${parseInt(parts[2], 10)}`
}
const formatDateTime = (t) => {
if (!t) return '-'
return formatDateTimeYMDHM(t)
}
function daysUntil(dateStr) {
if (!dateStr) return null
const d = new Date(dateStr + 'T00:00:00')
const today = new Date()
today.setHours(0, 0, 0, 0)
const diff = Math.round((d.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
return diff
}
const dueTagText = (item) => {
const days = daysUntil(item.remindDate)
if (days === null) return ''
if (days < 0) return `已过 ${-days}`
if (days === 0) return '今天到期'
if (days <= 7) return `还有 ${days}`
return `${days} 天后`
}
const dueTagClass = (item) => {
const days = daysUntil(item.remindDate)
if (days === null) return ''
if (days < 0) return 'danger'
if (days <= 3) return 'warn'
return ''
}
const statusText = (item) => {
const map = {
pending: '待回访',
sent: '已发送',
canceled: '已取消',
unsubscribed: '已退订'
}
return map[item.remindStatus] || item.remindStatus || '-'
}
const statusClass = (item) => {
if (item.remindStatus === 'unsubscribed') return 'unsub'
if (item.remindStatus === 'sent') return 'sent'
return ''
}
const callPhone = (phone) => {
if (!phone) return
uni.makePhoneCall({ phoneNumber: phone, fail: () => {} })
}
const copyPhone = (phone) => {
if (!phone) return
uni.setClipboardData({
data: phone,
success: () => uni.showToast({ title: '已复制', icon: 'none' })
})
}
onMounted(() => {
loadList()
})
onShow(() => {
loadList()
refreshPendingCount()
})
</script>
<style scoped>
.leads-page {
min-height: 100vh;
background: #f5f7fb;
padding-bottom: 40px;
box-sizing: border-box;
}
.leads-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px;
color: #fff;
}
.nav-back, .nav-placeholder {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
}
.nav-title { font-size: 17px; font-weight: 700; color: #fff; }
.hero {
padding: 18px 16px 6px;
}
.hero-title {
font-size: 18px;
font-weight: 700;
color: #0f172a;
}
.hero-sub {
margin-top: 4px;
font-size: 13px;
color: #64748b;
line-height: 1.6;
}
.hero-count {
color: #16a34a;
font-weight: 700;
font-size: 18px;
margin: 0 2px;
}
.hero-hint { display: block; color: #94a3b8; margin-top: 2px; }
.tab-bar {
margin: 10px 16px 12px;
background: #fff;
border-radius: 10px;
display: flex;
padding: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.03);
}
.tab-item {
flex: 1;
text-align: center;
padding: 9px 0;
font-size: 13px;
color: #64748b;
border-radius: 8px;
font-weight: 600;
}
.tab-item.active {
background: #ecfdf5;
color: #16a34a;
}
.state-wrap { padding: 40px 16px; text-align: center; color: #94a3b8; font-size: 13px; }
.spinner {
width: 26px; height: 26px;
margin: 0 auto 8px;
border: 3px solid #e2e8f0;
border-top-color: #16a34a;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.state-text { font-size: 13px; color: #94a3b8; }
.empty { padding: 40px 16px; text-align: center; color: #94a3b8; font-size: 13px; }
.list { padding: 0 16px; }
.lead-card {
background: #fff;
border-radius: 14px;
padding: 14px 14px 12px;
margin-bottom: 10px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
border: 1px solid #e8edf4;
}
.lead-card.unsub { opacity: 0.6; }
.lead-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.lead-pet { display: flex; flex-direction: column; }
.pet-name { font-size: 16px; font-weight: 700; color: #0f172a; }
.pet-service { font-size: 12px; color: #64748b; margin-top: 2px; }
.lead-status {
font-size: 12px;
padding: 3px 10px;
border-radius: 999px;
background: #eff6ff;
color: #2563eb;
font-weight: 600;
}
.lead-status.sent { background: #ecfdf5; color: #16a34a; }
.lead-status.unsub { background: #f3f4f6; color: #94a3b8; }
.lead-body { padding: 8px 0; border-top: 1px solid #f1f5f9; }
.lead-row {
display: flex;
align-items: center;
padding: 5px 0;
gap: 8px;
}
.row-label { font-size: 12px; color: #94a3b8; width: 64px; }
.row-value { font-size: 14px; color: #334155; flex: 1; }
.row-value.strong { font-weight: 700; color: #0f172a; }
.row-value.phone { font-family: 'SF Mono', Menlo, Consolas, monospace; color: #0f172a; }
.row-tag {
font-size: 11px;
padding: 2px 8px;
border-radius: 999px;
background: #f1f5f9;
color: #64748b;
}
.row-tag.warn { background: #fff7ed; color: #c2410c; }
.row-tag.danger { background: #fef2f2; color: #dc2626; }
.lead-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.lead-btn {
flex: 1;
height: 36px;
line-height: 36px;
font-size: 13px;
border-radius: 8px;
border: none;
font-weight: 600;
}
.lead-btn.primary {
background: linear-gradient(135deg, #22c55e, #16a34a);
color: #fff;
}
.lead-btn.ghost {
background: #f1f5f9;
color: #334155;
}
</style>