deploy: update logo and latest build

This commit is contained in:
MaDaLei 2026-04-19 14:26:25 +08:00
parent b486dd313a
commit 2a4545bc00
25 changed files with 293 additions and 56 deletions

View File

@ -1,7 +1,7 @@
# petstore-frontend
#### Description
伴生活馆
小它
#### Software Architecture
Software architecture description

View File

@ -1,4 +1,6 @@
# 宠伴生活馆 - UniApp 项目
# 宠小它 - UniApp 项目
> 品牌:宠小它 · 用心宠小它,温暖伴一生 · 智慧宠物门店服务系统
从 H5 (Vite+Vue3+Vant) 迁移到 UniApp+Vue3用于生成微信小程序。
@ -128,3 +130,8 @@ npm run dev:h5
### 报告分享链接与二维码(小程序)
「生成报告」弹层里的链接与二维码指向 **H5** `report.html?token=`,需与线上部署的静态站点一致。请在对应环境的 `.env` / `.env.production` 中配置 **`VITE_REPORT_PUBLIC_ORIGIN`**(例如 `https://www.example.com`,无尾斜杠)。未配置时仍可复制话术,但链接为空、二维码不展示,并会在弹层内提示。
### 宠主预约草稿P0 C3
当前实现:**已登录**用户在 `CustAppointmentCreate` 填写的内容会 debounce 写入本地 `uni.storage`7 天有效),离开页/重进可恢复;提交成功会清除草稿。
产品文档中的 **「未登录可先填单、提交时再登录并恢复草稿」** 需同时将「新建预约」页加入公开路由(如 `App.vue``isPublicRoute`)并在登录回跳后合并草稿;若未做公开路由,未登录用户仍会在进入预约页前被拦截到登录。

View File

@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>伴生活馆</title>
<meta name="description" content="宠伴生活馆 - 宠物服务,让爱更专业">
<title>小它</title>
<meta name="description" content="宠小它|用心宠小它,温暖伴一生。智慧宠物门店服务系统">
</head>
<body>
<div id="app"></div>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>登录 - 宠伴生活馆</title>
<title>登录 - 宠小它</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
@ -45,8 +45,8 @@
<div id="app">
<div class="page">
<div class="logo">
<div class="logo-text">伴生活馆</div>
<div class="logo-sub">宠物服务,让爱更专业</div>
<div class="logo-text">小它</div>
<div class="logo-sub">用心宠小它,温暖伴一生</div>
</div>
<div class="form-card">
@ -150,7 +150,7 @@
// 模拟微信授权,演示用
const demoUser = { id: 99, name: '微信用户', phone: '', role: 'staff' }
localStorage.setItem('petstore_user', JSON.stringify(demoUser))
localStorage.setItem('petstore_store', JSON.stringify({ id: 2, name: '宠伴生活馆测试店' }))
localStorage.setItem('petstore_store', JSON.stringify({ id: 2, name: '宠小它测试店' }))
window.location.href = 'index.html'
}, 1500)
}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>入驻申请 - 宠伴生活馆</title>
<title>入驻申请 - 宠小它</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }
@ -84,7 +84,7 @@
<div class="success-card">
<div class="success-icon">🎉</div>
<div class="success-title">欢迎加入宠伴生活馆</div>
<div class="success-title">欢迎加入宠小它</div>
<div class="success-sub">店铺创建成功,您可以开始管理您的宠物店了</div>
<div class="success-info">

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>员工注册 - 宠伴生活馆</title>
<title>员工注册 - 宠小它</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f5f5f5; }

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>服务报告 - 宠伴生活馆</title>
<title>服务报告 - 宠小它</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&display=swap" rel="stylesheet" />

View File

@ -98,6 +98,10 @@ export const postReportHighlightStart = (data) => post('/report/highlight/start'
// 获取报告通过token
export const getReportByToken = (token) => get('/report/get', { token })
/** P0报告打开埋点服务端记日志可对接日志平台 */
export const postReportOpenTrack = (token, visitType) =>
post('/report/open-track', { token, visitType: visitType || 'unknown' })
// 报告列表
export const getReportList = (params) => get('/report/list', params)

View File

@ -1,5 +1,5 @@
/* ============================================================
伴生活馆 设计系统 v2 · 简约 · 温暖 · 专业
小它 设计系统 v2 · 简约 · 温暖 · 专业
============================================================ */
:root {

View File

@ -62,7 +62,11 @@ import { buildReportH5FullUrl } from '../../utils/reportPublicUrl.js'
const props = defineProps({
show: { type: Boolean, default: false },
reportToken: { type: String, default: '' },
petName: { type: String, default: '' }
petName: { type: String, default: '' },
/** 门店名(话术变量) */
storeName: { type: String, default: '' },
/** 服务类型(话术变量) */
serviceType: { type: String, default: '' }
})
const emit = defineEmits(['close', 'preview'])
@ -88,14 +92,17 @@ const qrGridStyle = computed(() => {
const displayUrl = computed(() => buildReportH5FullUrl(props.reportToken))
const petLabel = computed(() => (props.petName && String(props.petName).trim()) || '毛孩子')
const storeLabel = computed(() => (props.storeName && String(props.storeName).trim()) || '门店')
const svcLabel = computed(() => (props.serviceType && String(props.serviceType).trim()) || '洗护服务')
const templateLines = computed(() => {
const link = displayUrl.value || '(请配置域名后显示链接)'
const name = petLabel.value
const pet = petLabel.value
const store = storeLabel.value
const svc = svcLabel.value
return [
`${name}】的洗护服务报告已生成,点链接查看:${link}`,
`您好,这是「${name}」本次服务报告,欢迎查阅:${link}`,
`毛孩子的服务记录在这里,方便时打开看看:${link}`
`您好,【${pet}】的「${svc}」报告已生成,门店:${store}。点击查看:${link}`,
`${store}${pet} 本次「${svc}」服务报告已完成,欢迎查阅:${link}`
]
})

View File

@ -1,7 +1,7 @@
{
"name": "宠伴生活馆",
"name": "宠小它",
"appid": "wx8ca2dfa89af72edf",
"description": "宠物服务小程序",
"description": "宠小它|智慧宠物门店服务系统",
"versionName": "1.0.0",
"versionCode": "100",
"transformPx": false,

View File

@ -23,7 +23,7 @@
"globalStyle": {
"navigationStyle": "custom",
"navigationBarTextStyle": "white",
"navigationBarTitleText": "宠伴生活馆",
"navigationBarTitleText": "宠小它",
"navigationBarBackgroundColor": "#2db96d",
"backgroundColor": "#fafaf8"
}

View File

@ -118,8 +118,8 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { ref, computed, onMounted, watch } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import {
createAppointment,
getServiceTypeList,
@ -580,6 +580,9 @@ const confirmNewAppt = async () => {
try {
uni.setStorageSync(lastServiceStorageKey(storeId), a.serviceType)
} catch (_) {}
try {
uni.removeStorageSync(apptDraftStorageKey())
} catch (_) {}
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 600)
} else {
@ -601,6 +604,77 @@ const loadMyPets = async () => {
}
}
/** P0 C3意外退出 / 返回栈恢复草稿7 天);与「未登录可填单」全量能力见 README */
const apptDraftStorageKey = () => `petstore_cust_appt_draft_v1_${userInfo.id || '0'}`
const saveApptDraft = () => {
if (!userInfo?.id) return
try {
const row = orderedStores.value[selectedStoreIndex.value]
uni.setStorageSync(
apptDraftStorageKey(),
JSON.stringify({
v: 1,
at: Date.now(),
petName: newAppt.value.petName,
petType: newAppt.value.petType,
serviceType: newAppt.value.serviceType,
appointmentTime: newAppt.value.appointmentTime,
remark: newAppt.value.remark,
selectedDateStr: selectedDateStr.value,
storeIndex: selectedStoreIndex.value,
storeId: row?.id
})
)
} catch (_) {}
}
const restoreApptDraft = () => {
if (!userInfo?.id) return
try {
const raw = uni.getStorageSync(apptDraftStorageKey())
if (!raw) return
const d = JSON.parse(raw)
if (!d || d.v !== 1) return
if (Date.now() - (d.at || 0) > 7 * 86400000) {
uni.removeStorageSync(apptDraftStorageKey())
return
}
if (typeof d.remark === 'string') newAppt.value.remark = d.remark
if (d.serviceType) newAppt.value.serviceType = d.serviceType
if (d.appointmentTime) newAppt.value.appointmentTime = d.appointmentTime
if (d.petName) newAppt.value.petName = d.petName
if (d.petType) newAppt.value.petType = d.petType
if (typeof d.selectedDateStr === 'string' && d.selectedDateStr) selectedDateStr.value = d.selectedDateStr
if (typeof d.storeIndex === 'number' && orderedStores.value[d.storeIndex]) {
selectedStoreIndex.value = d.storeIndex
applySelectedStoreToSession()
} else if (d.storeId && orderedStores.value.some((x) => String(x.id) === String(d.storeId))) {
selectedStoreIndex.value = orderedStores.value.findIndex((x) => String(x.id) === String(d.storeId))
applySelectedStoreToSession()
}
selectedPetId.value = null
} catch (_) {}
}
let apptDraftTimer = null
watch(
() =>
JSON.stringify({
n: newAppt.value,
si: selectedStoreIndex.value,
ds: selectedDateStr.value
}),
() => {
if (apptDraftTimer) clearTimeout(apptDraftTimer)
apptDraftTimer = setTimeout(saveApptDraft, 650)
}
)
onUnload(() => {
saveApptDraft()
})
onMounted(async () => {
selectedDateStr.value = ymdLocal()
await initStoresAndLocation()
@ -608,6 +682,10 @@ onMounted(async () => {
await loadServiceTypesForSelected()
}
await loadMyPets()
restoreApptDraft()
if (orderedStores.value.length) {
await loadServiceTypesForSelected()
}
await loadAvailableSlots()
})
</script>

View File

@ -3,7 +3,7 @@
<!-- C端服务预约列表 + 加号新建 -->
<view v-if="userInfo.role === 'customer'" class="c-home">
<view class="home-nav home-nav--center nav-gradient" :style="navSafeStyle">
<text class="nav-title">{{ storeInfo.name || '宠伴生活馆' }}</text>
<text class="nav-title">{{ storeInfo.name || '宠小它' }}</text>
</view>
<view class="c-hero">
@ -54,6 +54,11 @@
</view>
</view>
<view v-if="listMode === 'tab' && currentStatus === 'done'" class="done-filter-row">
<text class="done-filter-label">包含已取消</text>
<switch :checked="includeCancelledInDone" color="#2db96d" @change="onIncludeCancelledChange" />
</view>
<AppPageState
class="home-list-state"
:loading="appointmentsLoading"
@ -64,7 +69,9 @@
@retry="fetchAppointments"
>
<template v-if="custEmptyGuidance" #empty>
<text class="c-empty-emoji">📋</text>
<view class="c-empty-icon-wrap">
<AppIcon name="report" :size="44" color="#a8a89f" />
</view>
<text class="c-empty-title">当前没有可展示的预约</text>
<text class="c-empty-hint">请使用上方预约服务创建提交后会在本列表显示进度</text>
</template>
@ -94,7 +101,7 @@
<!-- B端看板新建预约改为右下角悬浮按钮避免与小程序胶囊重叠 -->
<view v-else>
<view class="home-nav home-nav--center nav-gradient" :style="navSafeStyle">
<text class="nav-title">{{ storeInfo.name || '宠伴生活馆' }}</text>
<text class="nav-title">{{ storeInfo.name || '宠小它' }}</text>
</view>
<view class="home-dash">
@ -142,6 +149,11 @@
</view>
</view>
<view v-if="listMode === 'tab' && currentStatus === 'done'" class="done-filter-row">
<text class="done-filter-label">包含已取消</text>
<switch :checked="includeCancelledInDone" color="#2db96d" @change="onIncludeCancelledChange" />
</view>
<!-- 列表 -->
<AppPageState
class="home-list-state"
@ -264,6 +276,7 @@ import {
} from '../../api/index.js'
import TabBar from '../../components/TabBar.vue'
import AppPageState from '../../components/AppPageState.vue'
import AppIcon from '../../components/AppIcon.vue'
import { messageFromApi, NETWORK_ERROR_MSG } from '../../utils/apiResult.js'
import { getUserSession, getStoreSession } from '../../utils/session.js'
import { useNavigator } from '../../composables/useNavigator.js'
@ -308,6 +321,13 @@ const navSafeStyle = (() => {
})()
const currentStatus = ref(userInfo.role === 'customer' ? 'new' : 'doing')
/** 「已完成」Tab默认不含已取消打开开关后含 cancelP0 B1 */
const includeCancelledInDone = ref(false)
const onIncludeCancelledChange = (e) => {
const v = e?.detail?.value
includeCancelledInDone.value = v === true || v === 'true'
}
const statusTabs = [
{ title: '待开始', name: 'new' },
{ title: '进行中', name: 'doing' },
@ -397,7 +417,10 @@ const staffSelectedSlotRangeLabel = computed(() =>
const filteredOrders = computed(() => orders.value.filter(o => {
if (currentStatus.value === 'new') return o.status === 'new'
if (currentStatus.value === 'doing') return o.status === 'doing'
if (currentStatus.value === 'done') return o.status === 'done' || o.status === 'cancel'
if (currentStatus.value === 'done') {
if (includeCancelledInDone.value) return o.status === 'done' || o.status === 'cancel'
return o.status === 'done'
}
return true
}))
@ -422,6 +445,7 @@ const selectTab = (name) => {
currentStatus.value = name
listMode.value = 'tab'
activeDashKey.value = null
if (name !== 'done') includeCancelledInDone.value = false
}
const onDashCardClick = (key) => {
@ -796,12 +820,22 @@ onShow(() => {
color: #3d5a4a;
line-height: 1.45;
}
.c-empty-emoji {
display: block;
font-size: 36px;
text-align: center;
margin-bottom: 10px;
opacity: 0.9;
.c-empty-icon-wrap {
display: flex;
justify-content: center;
margin-bottom: 12px;
}
.done-filter-row {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin: 8px 16px 4px;
padding: 0 4px;
}
.done-filter-label {
font-size: 13px;
color: #6b6b65;
}
.c-empty-title {
display: block;

View File

@ -1,8 +1,8 @@
<template>
<view class="login-page">
<view class="login-brand">
<text class="brand-name">伴生活馆</text>
<text class="brand-sub">宠物服务让爱更专业</text>
<text class="brand-name">小它</text>
<text class="brand-sub">用心宠小它温暖伴一生</text>
</view>
<!-- 登录 -->

View File

@ -15,15 +15,10 @@
</view>
</view>
<!-- 店铺管理老板 -->
<!-- 店铺管理老板分组见 P0 B4 -->
<view v-if="userInfo.role === 'boss'" class="menu-group">
<text class="menu-label">店铺管理</text>
<text class="menu-label module-title">店铺与经营</text>
<view class="menu-card">
<view class="menu-item" @click="goStaffPage">
<view class="menu-icon-wrap"><AppIcon name="staff" :size="16" color="#666660" /></view>
<text class="menu-text">员工管理</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goServiceTypePage">
<view class="menu-icon-wrap"><AppIcon name="service" :size="16" color="#666660" /></view>
<text class="menu-text">服务类型</text>
@ -47,6 +42,17 @@
</view>
</view>
<view v-if="userInfo.role === 'boss'" class="menu-group">
<text class="menu-label module-title">团队与账号</text>
<view class="menu-card">
<view class="menu-item" @click="goStaffPage">
<view class="menu-icon-wrap"><AppIcon name="staff" :size="16" color="#666660" /></view>
<text class="menu-text">员工管理</text>
<text class="menu-arrow"></text>
</view>
</view>
</view>
<!-- 员工仅门店设置含营业时间 -->
<view v-else-if="userInfo.role === 'staff'" class="menu-group">
<text class="menu-label">门店</text>

View File

@ -69,7 +69,7 @@
</div>
<!-- 底部信息 -->
<div class="footer-tip">伴生活馆 · {{ editionLabel }}</div>
<div class="footer-tip">小它 · {{ editionLabel }}</div>
<!-- 修改姓名弹窗 -->
<view v-if="showEditName" class="popup-mask profile-popup-mask" @click="showEditName = false">

View File

@ -110,7 +110,7 @@
<view class="store-actions-hint">保存后立即生效并写入本地缓存</view>
</view>
<view class="store-footer-tip">伴生活馆 · 商家版</view>
<view class="store-footer-tip">小它 · 商家版</view>
</view>
</view>
</template>

View File

@ -26,7 +26,7 @@
mode="aspectFit"
/>
<view class="brand-text-col">
<text class="brand-name-line">{{ reportData.store?.name || '宠伴生活馆' }}</text>
<text class="brand-name-line">{{ reportData.store?.name || '宠小它' }}</text>
<text class="brand-sub-line">服务报告 · {{ formatTime(reportData.appointmentTime) }}</text>
</view>
</view>
@ -34,6 +34,10 @@
</view>
</view>
<view class="report-privacy-bar">
<text class="report-privacy-bar-text">本页含宠物服务影像请勿公开转发或发群</text>
</view>
<!-- 技师情感卡片 H5 对齐 -->
<view v-if="reportData.staffName || staffAvatarDisplay" class="staff-card-mp">
<image
@ -185,8 +189,8 @@
<!-- 底部品牌 -->
<view class="footer-brand">
<text class="footer-line"></text>
<text class="footer-name">{{ reportData.store?.name || '宠伴生活馆' }}</text>
<text class="footer-sub">宠物服务 · 让爱更专业</text>
<text class="footer-name">{{ reportData.store?.name || '宠小它' }}</text>
<text class="footer-sub">用心宠小它温暖伴一生</text>
</view>
</view>
</AppPageState>
@ -212,6 +216,7 @@
import { ref, computed, onMounted, getCurrentInstance, nextTick } from 'vue'
import { onLoad, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { getReportByToken, imgUrl, submitReportTestimonial } from '../../api/index.js'
import { trackReportOpen, getReportByTokenWithTimeout } from '../../utils/reportOpenTrack.js'
import { formatDateTimeYMDHM } from '../../utils/datetime.js'
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../../utils/reportPosterDraw.js'
import AppIcon from '../../components/AppIcon.vue'
@ -335,9 +340,10 @@ const loadReport = async () => {
}
loading.value = true
try {
const res = await getReportByToken(token)
const res = await getReportByTokenWithTimeout(getReportByToken, token)
if (res && res.code === 200 && res.data) {
reportData.value = res.data
trackReportOpen(token)
} else if (res && res.code === 404) {
reportData.value = null
notFound.value = true
@ -347,7 +353,8 @@ const loadReport = async () => {
}
} catch (e) {
reportData.value = null
loadError.value = NETWORK_ERROR_MSG
loadError.value =
e && e.message === 'TIMEOUT' ? '加载超时,请检查网络后重试' : NETWORK_ERROR_MSG
} finally {
loading.value = false
}
@ -382,7 +389,7 @@ const navToStore = () => {
uni.openLocation({
latitude: Number(store.latitude),
longitude: Number(store.longitude),
name: store.name || '宠伴生活馆',
name: store.name || '宠小它',
address: store.address || ''
})
}
@ -1047,4 +1054,17 @@ onMounted(() => loadReport())
letter-spacing: 0.5px;
}
.report-privacy-bar {
margin: 0 16px 12px;
padding: 10px 12px;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 10px;
}
.report-privacy-bar-text {
font-size: 12px;
color: #92400e;
line-height: 1.45;
}
</style>

View File

@ -167,6 +167,9 @@
:class="{ 'submit-fixed--scrolled': hasScrolled }"
:style="submitFixedStyle"
>
<view class="report-lock-hint">
<text>提交后不可修改请确认照片与备注无误</text>
</view>
<button class="app-btn-main app-btn-primary submit-btn--fixed" :disabled="submitting" @click="submitReport">
{{ submitting ? '提交中…' : '生成报告' }}
</button>
@ -183,6 +186,8 @@
:show="shareModalOpen"
:report-token="shareToken"
:pet-name="sharePetName"
:store-name="shareStoreName"
:service-type="shareServiceType"
@close="onShareModalClose"
@preview="onSharePreview"
/>
@ -236,6 +241,8 @@ const submitting = ref(false)
const shareModalOpen = ref(false)
const shareToken = ref('')
const sharePetName = ref('')
const shareStoreName = ref('')
const shareServiceType = ref('')
const basicInfoCollapsed = ref(false)
const hasScrolled = ref(false)
const keyboardHeight = ref(0)
@ -701,6 +708,8 @@ const submitReport = async () => {
const token = res.data.reportToken
shareToken.value = token
sharePetName.value = report.value.petName || ''
shareStoreName.value = storeInfo?.name || ''
shareServiceType.value = report.value.serviceType || ''
report.value = { petName: '', serviceType: '', appointmentTime: '', beforeMedia: [], afterMedia: [], duringMedia: [], remark: '' }
currentAppointmentId.value = null
basicInfoCollapsed.value = false
@ -715,6 +724,8 @@ const onShareModalClose = () => {
shareModalOpen.value = false
shareToken.value = ''
sharePetName.value = ''
shareStoreName.value = ''
shareServiceType.value = ''
}
const onSharePreview = () => {
@ -722,6 +733,8 @@ const onSharePreview = () => {
shareModalOpen.value = false
shareToken.value = ''
sharePetName.value = ''
shareStoreName.value = ''
shareServiceType.value = ''
if (t) {
uni.navigateTo({ url: `/pages/report-view/reportView?token=${encodeURIComponent(t)}` })
}
@ -1014,6 +1027,14 @@ const onRemarkBlur = () => {
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.08);
background: rgba(250, 250, 248, 0.985);
}
.report-lock-hint {
font-size: 12px;
color: #8a8a84;
line-height: 1.45;
text-align: center;
margin-bottom: 10px;
padding: 0 8px;
}
.submit-btn--fixed {
box-shadow: 0 5px 16px rgba(0, 0, 0, 0.1);
}

4
src/utils/brand.js Normal file
View File

@ -0,0 +1,4 @@
/** 品牌与 Slogan单一来源供页面与分享兜底引用 */
export const BRAND_NAME = '宠小它'
export const BRAND_SLOGAN_MAIN = '用心宠小它,温暖伴一生'
export const BRAND_SLOGAN_SUB = '智慧宠物门店服务系统'

View File

@ -0,0 +1,39 @@
import { postReportOpenTrack } from '../api/index.js'
const STORAGE_PREFIX = 'petstore_report_seen_'
function storageKeyForToken(token) {
let h = 0
const s = String(token || '')
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0
return STORAGE_PREFIX + Math.abs(h).toString(36)
}
/**
* P0 埋点报告打开首次 / 再次失败静默不影响阅读
* @param {string} token report_token
*/
export function trackReportOpen(token) {
if (!token) return
const key = storageKeyForToken(token)
let visitType = 'first'
try {
const prev = uni.getStorageSync(key)
if (prev) visitType = 'repeat'
else uni.setStorageSync(key, '1')
} catch (_) {
visitType = 'repeat'
}
postReportOpenTrack(token, visitType).catch(() => {})
}
const REPORT_FETCH_MS = 10000
export function getReportByTokenWithTimeout(getReportByToken, token) {
return Promise.race([
getReportByToken(token),
new Promise((_, reject) => {
setTimeout(() => reject(new Error('TIMEOUT')), REPORT_FETCH_MS)
})
])
}

View File

@ -95,7 +95,7 @@ function drawBgGradient(ctx, height) {
export function drawReportPoster(ctx, data) {
const { storeName, petName, beforeImg, afterImg, qrImg, testimonial } = data
const name = storeName || '宠伴生活馆'
const name = storeName || '宠小它'
const PAD = 28
const pet = petName || '宝贝'

View File

@ -5,7 +5,7 @@ import { imgUrl } from '../api/index.js'
*/
export function buildReportShareTitle(report) {
if (!report) return '洗护前后对比'
const store = report.store?.name || '宠伴生活馆'
const store = report.store?.name || '宠小它'
const pet = report.petName || '爱宠'
const svc = (report.serviceType || '').trim()
const svcPart = svc ? `·${svc}` : ''

View File

@ -28,8 +28,8 @@
mode="aspectFit"
/>
<div class="brand-text">
<div class="brand-logo">{{ reportData.store?.name || '宠伴生活馆' }}</div>
<div class="brand-sub">宠物服务让爱更专业</div>
<div class="brand-logo">{{ reportData.store?.name || '宠小它' }}</div>
<div class="brand-sub">用心宠小它温暖伴一生</div>
</div>
</div>
<div class="brand-contact">
@ -38,6 +38,10 @@
</div>
</div>
<div class="report-privacy-bar">
本页含宠物服务影像请勿公开转发或发群
</div>
<!-- 报告标题 -->
<div class="report-title-wrap">
<div class="report-title">服务报告</div>
@ -134,6 +138,7 @@
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { getReportByToken, imgUrl, submitReportTestimonial } from '../api/index.js'
import { trackReportOpen, getReportByTokenWithTimeout } from '../utils/reportOpenTrack.js'
import { drawReportPoster, POSTER_W, computePosterLogicalHeight } from '../utils/reportPosterDraw.js'
import { formatDateTimeYMDHM } from '../utils/datetime.js'
import AppIcon from '../components/AppIcon.vue'
@ -235,9 +240,10 @@ const loadReport = async () => {
loading.value = true
try {
const res = await getReportByToken(token)
const res = await getReportByTokenWithTimeout(getReportByToken, token)
if (res && res.code === 200 && res.data) {
reportData.value = res.data
trackReportOpen(token)
} else if (res && res.code === 404) {
reportData.value = null
notFound.value = true
@ -247,7 +253,8 @@ const loadReport = async () => {
}
} catch (e) {
reportData.value = null
loadError.value = NETWORK_ERROR_MSG
loadError.value =
e && e.message === 'TIMEOUT' ? '加载超时,请检查网络后重试' : NETWORK_ERROR_MSG
} finally {
loading.value = false
}
@ -469,4 +476,14 @@ onMounted(() => loadReport())
.section-label { font-size: 15px; font-weight: 700; color: #1f2937; margin-bottom: 10px; }
.remark-content { background: #f8fafc; border: 1px solid #e8edf4; border-radius: 12px; padding: 14px; font-size: 14px; color: #64748b; line-height: 1.6; min-height: 60px; }
.action-section { margin: 0 16px 24px; }
.report-privacy-bar {
margin: 0 16px 12px;
padding: 10px 12px;
font-size: 12px;
color: #92400e;
line-height: 1.45;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 10px;
}
</style>