deploy 2026-04-18 09:19

This commit is contained in:
MaDaLei 2026-04-18 09:19:46 +08:00
parent 26da03f739
commit d632e895e8
7 changed files with 896 additions and 1 deletions

View File

@ -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 = {}

View 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>

View File

@ -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
View 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>

View File

@ -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' })

View 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>

View File

@ -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)