deploy 2026-04-18 09:19
This commit is contained in:
parent
26da03f739
commit
d632e895e8
@ -92,6 +92,21 @@ export const getReportByToken = (token) => get('/report/get', { token })
|
|||||||
// 报告列表
|
// 报告列表
|
||||||
export const getReportList = (params) => get('/report/list', params)
|
export const getReportList = (params) => get('/report/list', params)
|
||||||
|
|
||||||
|
// 报告页:下次服务建议日期
|
||||||
|
export const getReportSuggestion = (token) => get(`/report/${encodeURIComponent(token)}/suggestion`)
|
||||||
|
|
||||||
|
// 报告页:宠主留资(下次提醒)
|
||||||
|
export const submitReportReminder = (token, phone) =>
|
||||||
|
post(`/report/${encodeURIComponent(token)}/reminder`, { phone, consent: true })
|
||||||
|
|
||||||
|
// 报告页:一键退订
|
||||||
|
export const unsubscribeReportReminder = (unsubscribeToken) =>
|
||||||
|
post('/report/unsubscribe', { unsubscribeToken })
|
||||||
|
|
||||||
|
// 老板后台:回访池列表
|
||||||
|
export const getReportLeads = (storeId, status = 'pending') =>
|
||||||
|
get('/report/leads', { storeId, status })
|
||||||
|
|
||||||
// 服务类型列表(不传 storeId 时仅返回系统默认,供未写入门店会话的 C 端展示)
|
// 服务类型列表(不传 storeId 时仅返回系统默认,供未写入门店会话的 C 端展示)
|
||||||
export const getServiceTypeList = (storeId) => {
|
export const getServiceTypeList = (storeId) => {
|
||||||
const params = {}
|
const params = {}
|
||||||
|
|||||||
306
src/components/report/ReminderCard.vue
Normal file
306
src/components/report/ReminderCard.vue
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
<template>
|
||||||
|
<view class="reminder-card" v-if="visible">
|
||||||
|
<!-- 默认状态:展示建议日期 + 留手机号 -->
|
||||||
|
<view v-if="!submitted" class="rc-main">
|
||||||
|
<view class="rc-title">
|
||||||
|
<text class="rc-title-emoji">🐾</text>
|
||||||
|
<text class="rc-title-text">{{ titleText }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rc-date-wrap">
|
||||||
|
<view class="rc-date-main">{{ formatChineseDate(suggestion.remindDate) }}</view>
|
||||||
|
<view class="rc-date-sub">{{ suggestion.intervalText }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rc-form">
|
||||||
|
<view class="rc-input-wrap">
|
||||||
|
<input
|
||||||
|
class="rc-input"
|
||||||
|
type="number"
|
||||||
|
maxlength="11"
|
||||||
|
placeholder="留个手机号,到点微信提醒"
|
||||||
|
v-model="phone"
|
||||||
|
:disabled="submitting"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="rc-consent" @click="toggleConsent">
|
||||||
|
<view class="rc-checkbox" :class="{ checked: consent }">
|
||||||
|
<text v-if="consent" class="rc-check">✓</text>
|
||||||
|
</view>
|
||||||
|
<text class="rc-consent-text">
|
||||||
|
同意门店通过短信/微信向我发送服务到期提醒
|
||||||
|
<text class="rc-consent-sub">(可随时退订)</text>
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="rc-btn"
|
||||||
|
:class="{ disabled: !canSubmit }"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
@click="onSubmit"
|
||||||
|
>
|
||||||
|
{{ submitting ? '提交中…' : '设置提醒' }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="errorMsg" class="rc-error">{{ errorMsg }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 成功状态 -->
|
||||||
|
<view v-else class="rc-success">
|
||||||
|
<view class="rc-success-icon">✓</view>
|
||||||
|
<view class="rc-success-title">已为您设置提醒</view>
|
||||||
|
<view class="rc-success-date">
|
||||||
|
{{ formatChineseDate(savedDate) }}
|
||||||
|
</view>
|
||||||
|
<view class="rc-success-desc">
|
||||||
|
到点我们会通过微信/短信告诉你,无需惦记。
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { getReportSuggestion, submitReportReminder } from '../../api/index.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
token: { type: String, required: true },
|
||||||
|
petName: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const suggestion = ref({ remindDate: '', intervalText: '', petName: '', serviceType: '' })
|
||||||
|
const phone = ref('')
|
||||||
|
const consent = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const savedDate = ref('')
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
const titleText = computed(() => {
|
||||||
|
const name = suggestion.value.petName || props.petName || '毛孩子'
|
||||||
|
const srv = friendlyServiceLabel(suggestion.value.serviceType)
|
||||||
|
return `${name}下次${srv}建议时间`
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => {
|
||||||
|
return !submitting.value && consent.value && /^1[3-9]\d{9}$/.test(phone.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleConsent = () => {
|
||||||
|
consent.value = !consent.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function friendlyServiceLabel(srv) {
|
||||||
|
if (!srv) return '服务'
|
||||||
|
if (srv.includes('驱虫')) return '驱虫'
|
||||||
|
if (srv.includes('指甲')) return '剪指甲'
|
||||||
|
if (srv.includes('美容') && srv.includes('洗')) return '洗护'
|
||||||
|
if (srv.includes('美容')) return '美容'
|
||||||
|
if (srv.includes('洗')) return '洗澡'
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatChineseDate(dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
// dateStr 格式:YYYY-MM-DD
|
||||||
|
const parts = String(dateStr).split('-')
|
||||||
|
if (parts.length !== 3) return dateStr
|
||||||
|
const [y, m, d] = parts
|
||||||
|
return `${y}年${parseInt(m, 10)}月${parseInt(d, 10)}日`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSuggestion() {
|
||||||
|
if (!props.token) return
|
||||||
|
try {
|
||||||
|
const res = await getReportSuggestion(props.token)
|
||||||
|
if (res && res.code === 200 && res.data) {
|
||||||
|
suggestion.value = res.data
|
||||||
|
visible.value = true
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 静默失败:留资是锦上添花,不应阻塞主流程
|
||||||
|
visible.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
errorMsg.value = ''
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const res = await submitReportReminder(props.token, phone.value)
|
||||||
|
if (res && res.code === 200 && res.data && res.data.ok) {
|
||||||
|
savedDate.value = res.data.remindDate || suggestion.value.remindDate
|
||||||
|
submitted.value = true
|
||||||
|
} else {
|
||||||
|
errorMsg.value = (res && res.message) || '提交失败,请稍后再试'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = '网络异常,请稍后再试'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadSuggestion)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reminder-card {
|
||||||
|
margin: 4px 16px 16px;
|
||||||
|
background: linear-gradient(140deg, #ecfdf5 0%, #ffffff 60%);
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 18px 16px 16px;
|
||||||
|
box-shadow: 0 10px 24px rgba(22, 163, 74, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
.rc-title-emoji { font-size: 16px; }
|
||||||
|
.rc-title-text { font-weight: 600; }
|
||||||
|
|
||||||
|
.rc-date-wrap {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px dashed #86efac;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.rc-date-main {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #166534;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.rc-date-sub {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-form { margin-top: 14px; }
|
||||||
|
.rc-input-wrap {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d1fae5;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.rc-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #1f2937;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-consent {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 4px 2px;
|
||||||
|
}
|
||||||
|
.rc-checkbox {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
min-width: 18px;
|
||||||
|
border: 1.5px solid #94a3b8;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 1px;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.rc-checkbox.checked {
|
||||||
|
background: #16a34a;
|
||||||
|
border-color: #16a34a;
|
||||||
|
}
|
||||||
|
.rc-check {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.rc-consent-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.5;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.rc-consent-sub { color: #94a3b8; }
|
||||||
|
|
||||||
|
.rc-btn {
|
||||||
|
margin-top: 12px;
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
line-height: 44px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 6px 14px rgba(22, 163, 74, 0.3);
|
||||||
|
}
|
||||||
|
.rc-btn.disabled {
|
||||||
|
background: #cbd5e1;
|
||||||
|
box-shadow: none;
|
||||||
|
color: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-error {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #dc2626;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rc-success {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 8px 4px;
|
||||||
|
}
|
||||||
|
.rc-success-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
line-height: 44px;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #16a34a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.rc-success-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.rc-success-date {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
.rc-success-desc {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -15,7 +15,9 @@
|
|||||||
{"path": "pages/mine/MyPets"},
|
{"path": "pages/mine/MyPets"},
|
||||||
{"path": "pages/mine/Profile"},
|
{"path": "pages/mine/Profile"},
|
||||||
{"path": "pages/video-player/videoPlayer"},
|
{"path": "pages/video-player/videoPlayer"},
|
||||||
{"path": "pages/report-view/reportView"}
|
{"path": "pages/report-view/reportView"},
|
||||||
|
{"path": "pages/unsubscribe/Unsubscribe"},
|
||||||
|
{"path": "pages/mine/Leads"}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
"navigationStyle": "custom",
|
"navigationStyle": "custom",
|
||||||
|
|||||||
399
src/pages/mine/Leads.vue
Normal file
399
src/pages/mine/Leads.vue
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
<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>
|
||||||
@ -39,6 +39,11 @@
|
|||||||
<text class="menu-text">门店日程</text>
|
<text class="menu-text">门店日程</text>
|
||||||
<text class="menu-arrow">›</text>
|
<text class="menu-arrow">›</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="menu-item" @click="goLeadsPage">
|
||||||
|
<view class="menu-icon-wrap"><AppIcon name="orders" :size="16" color="#666660" /></view>
|
||||||
|
<text class="menu-text">回访池</text>
|
||||||
|
<text class="menu-arrow">›</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -102,6 +107,7 @@ const goStaffPage = () => uni.navigateTo({ url: '/pages/mine/Staff' })
|
|||||||
const goServiceTypePage = () => uni.navigateTo({ url: '/pages/mine/ServiceType' })
|
const goServiceTypePage = () => uni.navigateTo({ url: '/pages/mine/ServiceType' })
|
||||||
const goStorePage = () => uni.navigateTo({ url: '/pages/mine/Store' })
|
const goStorePage = () => uni.navigateTo({ url: '/pages/mine/Store' })
|
||||||
const goSchedulePage = () => uni.navigateTo({ url: '/pages/mine/Schedule' })
|
const goSchedulePage = () => uni.navigateTo({ url: '/pages/mine/Schedule' })
|
||||||
|
const goLeadsPage = () => uni.navigateTo({ url: '/pages/mine/Leads' })
|
||||||
const openProfile = () => uni.navigateTo({ url: '/pages/mine/Profile' })
|
const openProfile = () => uni.navigateTo({ url: '/pages/mine/Profile' })
|
||||||
const goMyPetsPage = () => uni.navigateTo({ url: '/pages/mine/MyPets' })
|
const goMyPetsPage = () => uni.navigateTo({ url: '/pages/mine/MyPets' })
|
||||||
const goMyOrdersPage = () => uni.navigateTo({ url: '/pages/mine/MyOrders' })
|
const goMyOrdersPage = () => uni.navigateTo({ url: '/pages/mine/MyOrders' })
|
||||||
|
|||||||
160
src/pages/unsubscribe/Unsubscribe.vue
Normal file
160
src/pages/unsubscribe/Unsubscribe.vue
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
<template>
|
||||||
|
<view class="unsub-page">
|
||||||
|
<view class="unsub-card">
|
||||||
|
<view v-if="status === 'loading'" class="unsub-state">
|
||||||
|
<view class="unsub-spinner"></view>
|
||||||
|
<text class="unsub-tip">正在为您处理…</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="status === 'ok'" class="unsub-state">
|
||||||
|
<view class="unsub-icon success">
|
||||||
|
<text class="unsub-icon-char">✓</text>
|
||||||
|
</view>
|
||||||
|
<text class="unsub-title">已退订</text>
|
||||||
|
<text class="unsub-desc">
|
||||||
|
我们不会再向您发送服务到期提醒。
|
||||||
|
</text>
|
||||||
|
<text class="unsub-desc">
|
||||||
|
如果您改变主意,随时可以在报告页重新设置。
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="status === 'missing'" class="unsub-state">
|
||||||
|
<view class="unsub-icon error">
|
||||||
|
<text class="unsub-icon-char">!</text>
|
||||||
|
</view>
|
||||||
|
<text class="unsub-title">链接缺少退订标识</text>
|
||||||
|
<text class="unsub-desc">请从微信/短信中的退订链接直接打开。</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="unsub-state">
|
||||||
|
<view class="unsub-icon error">
|
||||||
|
<text class="unsub-icon-char">!</text>
|
||||||
|
</view>
|
||||||
|
<text class="unsub-title">退订未成功</text>
|
||||||
|
<text class="unsub-desc">{{ errorMsg || '链接无效或已失效,您可以直接忽略后续消息。' }}</text>
|
||||||
|
<button class="unsub-btn" @click="doUnsubscribe" :disabled="retrying">重试</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
|
import { unsubscribeReportReminder } from '../../api/index.js'
|
||||||
|
|
||||||
|
const status = ref('loading') // loading | ok | missing | fail
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const unsubToken = ref('')
|
||||||
|
const retrying = ref(false)
|
||||||
|
|
||||||
|
function resolveToken() {
|
||||||
|
// #ifdef H5
|
||||||
|
const urlToken = new URLSearchParams(window.location.search).get('t')
|
||||||
|
|| new URLSearchParams(window.location.search).get('token')
|
||||||
|
if (urlToken) return urlToken
|
||||||
|
// #endif
|
||||||
|
return unsubToken.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doUnsubscribe() {
|
||||||
|
const token = resolveToken()
|
||||||
|
if (!token) {
|
||||||
|
status.value = 'missing'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
retrying.value = true
|
||||||
|
status.value = 'loading'
|
||||||
|
try {
|
||||||
|
const res = await unsubscribeReportReminder(token)
|
||||||
|
if (res && res.code === 200 && res.data && res.data.ok) {
|
||||||
|
status.value = 'ok'
|
||||||
|
} else {
|
||||||
|
errorMsg.value = (res && res.message) || '退订失败'
|
||||||
|
status.value = 'fail'
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = '网络异常,请稍后再试'
|
||||||
|
status.value = 'fail'
|
||||||
|
} finally {
|
||||||
|
retrying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((options) => {
|
||||||
|
if (options && (options.t || options.token)) {
|
||||||
|
unsubToken.value = options.t || options.token
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(doUnsubscribe)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.unsub-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f7fb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.unsub-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
|
||||||
|
padding: 32px 24px;
|
||||||
|
text-align: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.unsub-state { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.unsub-spinner {
|
||||||
|
width: 32px; height: 32px;
|
||||||
|
border: 3px solid #e2e8f0;
|
||||||
|
border-top-color: #16a34a;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
.unsub-icon {
|
||||||
|
width: 56px; height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.unsub-icon.success { background: #16a34a; }
|
||||||
|
.unsub-icon.error { background: #f59e0b; }
|
||||||
|
.unsub-icon-char { color: #fff; font-size: 28px; font-weight: 700; line-height: 1; }
|
||||||
|
.unsub-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.unsub-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.unsub-tip { font-size: 14px; color: #475569; }
|
||||||
|
.unsub-btn {
|
||||||
|
margin-top: 18px;
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #16a34a;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.unsub-btn[disabled] { opacity: 0.5; }
|
||||||
|
</style>
|
||||||
@ -84,6 +84,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 下次服务提醒(留资钩子 A) -->
|
||||||
|
<ReminderCard
|
||||||
|
:token="reportData.reportToken || routeToken"
|
||||||
|
:pet-name="reportData.petName"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 生成海报按钮 -->
|
<!-- 生成海报按钮 -->
|
||||||
<div class="action-section">
|
<div class="action-section">
|
||||||
<button class="van-button van-button--primary van-button--round van-button--block" @click="generatePoster">生成图片分享朋友圈</button>
|
<button class="van-button van-button--primary van-button--round van-button--block" @click="generatePoster">生成图片分享朋友圈</button>
|
||||||
@ -103,6 +109,7 @@ import { onLoad } from '@dcloudio/uni-app'
|
|||||||
import { getReportByToken, imgUrl } from '../api/index.js'
|
import { getReportByToken, imgUrl } from '../api/index.js'
|
||||||
import { formatDateTimeYMDHM } from '../utils/datetime.js'
|
import { formatDateTimeYMDHM } from '../utils/datetime.js'
|
||||||
import AppIcon from '../components/AppIcon.vue'
|
import AppIcon from '../components/AppIcon.vue'
|
||||||
|
import ReminderCard from '../components/report/ReminderCard.vue'
|
||||||
import { navigateTo } from '../utils/globalState.js'
|
import { navigateTo } from '../utils/globalState.js'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user