400 lines
10 KiB
Vue
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>
|