更新 2026-04-18 21:11

This commit is contained in:
MaDaLei 2026-04-18 21:11:20 +08:00
parent 757811081d
commit 07ad42ea3b
10 changed files with 581 additions and 25 deletions

View File

@ -1 +1,3 @@
VITE_API_ORIGIN=https://api.s-good.com
# 部署了 report.html 的 H5 站点根(无尾斜杠),用于「生成报告」后复制链接与二维码;与 API 域名可不同
# VITE_REPORT_PUBLIC_ORIGIN=https://www.example.com

View File

@ -90,7 +90,7 @@ npm run dev:h5
|------|------|------|
| 登录 | /pages/login/login | Tab切换员工登录/老板登录/注册老板/注册员工 |
| 首页 | /pages/home/home | 预约列表 Tab待开始/进行中/已完成)+ 新建预约 |
| 洗美报告 | /pages/report/report | 填写报告 + 提交后显示链接+二维码 |
| 洗美报告 | /pages/report/report | 填写报告;提交成功后弹出**发送给宠主**(复制链接 + 话术模板 + 二维码同屏) |
| 报告页 | /pages/report-view/reportView | 独立访问的报告页token=xxx |
| 我的 | /pages/mine/mine | 个人中心,老板有额外菜单 |
| 员工管理 | /pages/mine/staff | 老板功能:管理员工 |
@ -104,3 +104,27 @@ npm run dev:h5
- 登录成功后,`petstore_user` 和 `petstore_store` 存入本地存储
- 用户 ID 决定能看到哪些数据(员工看自己,老板看全店)
- role = 'boss' 时,显示员工管理/服务类型/店铺设置菜单
## 生产发版清单(小程序)
后端发版、只更新服务器代码**不会**自动更新用户手机上的小程序界面;**必须**单独走微信侧上传与提审(或发布体验版)。
建议每次发版按下面核对:
1. **本地构建小程序**
```bash
npm run build:mp-weixin
```
产物目录:`dist/build/mp-weixin/`(以构建日志为准)。
2. **用微信开发者工具打开上述目录**,确认能正常编译、关键页面自测通过。
3. **上传**:开发者工具 → 上传 → 填写版本号与项目备注(建议写本次前端变更摘要,便于回溯)。
4. **公众平台**:在小程序后台将本次上传设为**体验版**或提交**审核**/**发布**(按你们流程)。
5. **易漏项**:仅部署后端或 H5、忘记本步骤时线上小程序仍是旧包容易出现「代码已改、真机还是旧交互」的误判。
### 报告分享链接与二维码(小程序)
「生成报告」弹层里的链接与二维码指向 **H5** `report.html?token=`,需与线上部署的静态站点一致。请在对应环境的 `.env` / `.env.production` 中配置 **`VITE_REPORT_PUBLIC_ORIGIN`**(例如 `https://www.example.com`,无尾斜杠)。未配置时仍可复制话术,但链接为空、二维码不展示,并会在弹层内提示。

7
package-lock.json generated
View File

@ -12,6 +12,7 @@
"@dcloudio/uni-components": "3.0.0-4060620250520001",
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
"uqrcodejs": "^4.0.7",
"vue": "^3.4.21"
},
"devDependencies": {
@ -6930,6 +6931,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/uqrcodejs": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/uqrcodejs/-/uqrcodejs-4.0.7.tgz",
"integrity": "sha512-84+aZmD2godCVI+93lxE3YUAPNY8zAJvNA7xRS7R7U+q57KzMDepBSfNCwoRUhWOfR6eHFoAOcHRPwsP6ka1cA==",
"license": "Apache-2.0"
},
"node_modules/utif": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/utif/-/utif-2.0.1.tgz",

View File

@ -13,6 +13,7 @@
"@dcloudio/uni-components": "3.0.0-4060620250520001",
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
"uqrcodejs": "^4.0.7",
"vue": "^3.4.21"
},
"devDependencies": {

View File

@ -50,12 +50,15 @@
<!-- 成功状态留资 + 微信绑定闭环S3S4 -->
<view v-else class="rc-success">
<view class="rc-success-icon"></view>
<view class="rc-success-title">已为您设置提醒</view>
<view class="rc-success-title">{{ successHeadline }}</view>
<view class="rc-success-date">
{{ formatChineseDate(savedDate) }}
</view>
<view class="rc-success-desc">
到点我们会通过微信/短信告诉你无需惦记
{{ successDesc }}
</view>
<view v-if="repeatSubmit" class="rc-repeat-hint">
您此前已使用本手机号在本报告留资本次已按最新建议日期更新提醒
</view>
<!-- #ifdef MP-WEIXIN -->
<view v-if="wechatBound" class="rc-bind rc-bind--ok">
@ -76,6 +79,8 @@
<button class="rc-login-btn" @click="goLoginWithPhone">用该手机号登录</button>
<view class="rc-login-hint">登录后可在我的查看宠物档案与预约</view>
<button class="rc-change-phone" type="default" @click="changePhone">更换手机号</button>
<view class="rc-change-phone-hint">若需用其他号码接收提醒可更换后重新提交同一报告仅保留各号码一条记录</view>
</view>
</view>
</template>
@ -97,8 +102,20 @@ const submitting = ref(false)
const submitted = ref(false)
const savedDate = ref('')
const wechatBound = ref(false)
const repeatSubmit = ref(false)
const errorMsg = ref('')
const successHeadline = computed(() =>
repeatSubmit.value ? '已为您更新提醒' : '已为您设置提醒'
)
const successDesc = computed(() =>
repeatSubmit.value
? '提醒日期已同步为下方日期;到点我们会通过微信/短信联系你。'
: '到点我们会通过微信/短信告诉你,无需惦记。'
)
const storageKey = () => `petstore_reminder_${props.token}`
const titleText = computed(() => {
const name = suggestion.value.petName || props.petName || '毛孩子'
const srv = friendlyServiceLabel(suggestion.value.serviceType)
@ -132,6 +149,41 @@ function formatChineseDate(dateStr) {
return `${y}${parseInt(m, 10)}${parseInt(d, 10)}`
}
function tryRestoreFromStorage() {
try {
const raw = uni.getStorageSync(storageKey())
if (!raw) return
const o = typeof raw === 'string' ? JSON.parse(raw) : raw
if (o && o.remindDate) {
savedDate.value = o.remindDate
wechatBound.value = !!o.wechatBound
repeatSubmit.value = false
if (o.phone && /^1[3-9]\d{9}$/.test(String(o.phone))) {
phone.value = String(o.phone).trim()
}
submitted.value = true
visible.value = true
}
} catch (e) {
// ignore
}
}
function persistToStorage() {
try {
uni.setStorageSync(
storageKey(),
JSON.stringify({
remindDate: savedDate.value,
wechatBound: wechatBound.value,
phone: phone.value
})
)
} catch (e) {
// ignore
}
}
async function loadSuggestion() {
if (!props.token) return
try {
@ -170,6 +222,19 @@ function goLoginWithPhone() {
})
}
function changePhone() {
submitted.value = false
repeatSubmit.value = false
phone.value = ''
consent.value = false
errorMsg.value = ''
try {
uni.removeStorageSync(storageKey())
} catch (e) {
// ignore
}
}
async function onSubmit() {
if (!canSubmit.value) return
errorMsg.value = ''
@ -180,7 +245,9 @@ async function onSubmit() {
if (res && res.code === 200 && res.data && res.data.ok) {
savedDate.value = res.data.remindDate || suggestion.value.remindDate
wechatBound.value = !!res.data.wechatBound
repeatSubmit.value = !!res.data.repeatSubmit
submitted.value = true
persistToStorage()
} else {
errorMsg.value = (res && res.message) || '提交失败,请稍后再试'
}
@ -191,7 +258,10 @@ async function onSubmit() {
}
}
onMounted(loadSuggestion)
onMounted(async () => {
tryRestoreFromStorage()
await loadSuggestion()
})
</script>
<style scoped>
@ -398,4 +468,34 @@ onMounted(loadSuggestion)
color: #94a3b8;
line-height: 1.4;
}
.rc-repeat-hint {
margin-top: 10px;
padding: 10px 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 12px;
color: #475569;
line-height: 1.5;
text-align: left;
}
.rc-change-phone {
margin-top: 12px;
width: 100%;
height: 40px;
line-height: 40px;
font-size: 14px;
color: #15803d;
background: #fff;
border: 1px solid #86efac;
border-radius: 10px;
}
.rc-change-phone-hint {
margin-top: 6px;
font-size: 10px;
color: #94a3b8;
line-height: 1.4;
}
</style>

View File

@ -0,0 +1,337 @@
<template>
<view v-if="show" class="rsm-mask" @click.self="onMask">
<view class="rsm-panel" @click.stop>
<view class="rsm-head">
<text class="rsm-title">发送给宠主</text>
<text class="rsm-close" @click="emitClose"></text>
</view>
<text class="rsm-tip">复制下方链接或扫码打开也可点选话术后一键复制整段发给微信好友</text>
<view class="rsm-block">
<text class="rsm-label">报告链接</text>
<view class="rsm-url-box">
<text class="rsm-url" selectable>{{ displayUrl || '(未配置报告页域名,见下方说明)' }}</text>
</view>
<button class="rsm-btn rsm-btn--primary" :disabled="!displayUrl" @click="copyUrl">
{{ copiedUrl ? '已复制' : '复制链接' }}
</button>
</view>
<view v-if="!displayUrl" class="rsm-warn">
请在构建环境配置 <text class="rsm-code">VITE_REPORT_PUBLIC_ORIGIN</text> 为已部署 H5 的根地址 report.html 同域否则无法生成可扫码的链接
</view>
<view class="rsm-block">
<text class="rsm-label">话术模板点按复制整段</text>
<view
v-for="(t, idx) in templateLines"
:key="idx"
class="rsm-template"
@click="copyTemplate(idx)"
>
<text class="rsm-template-text">{{ t }}</text>
<text class="rsm-template-action">{{ copiedTplIdx === idx ? '已复制' : '复制' }}</text>
</view>
</view>
<view v-if="displayUrl && flatCells.length" class="rsm-block rsm-qr-wrap">
<text class="rsm-label">扫码打开报告</text>
<view class="rsm-qr" :style="qrGridStyle">
<view
v-for="(cell, i) in flatCells"
:key="'q' + i"
class="rsm-qr-dot"
:class="{ 'rsm-qr-dot--on': cell }"
/>
</view>
</view>
<view class="rsm-actions">
<button class="rsm-btn rsm-btn--ghost" @click="emitClose">稍后</button>
<button class="rsm-btn rsm-btn--primary" @click="previewReport">预览报告</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import UQRCode from 'uqrcodejs'
import { buildReportH5FullUrl } from '../../utils/reportPublicUrl.js'
const props = defineProps({
show: { type: Boolean, default: false },
reportToken: { type: String, default: '' },
petName: { type: String, default: '' }
})
const emit = defineEmits(['close', 'preview'])
const copiedUrl = ref(false)
const copiedTplIdx = ref(-1)
const qrGrid = ref([])
const qrN = computed(() => qrGrid.value.length)
const flatCells = computed(() => (qrGrid.value.length ? qrGrid.value.flat() : []))
const qrGridStyle = computed(() => {
const n = qrN.value
if (!n) return {}
return {
width: '200px',
height: '200px',
display: 'grid',
gridTemplateColumns: `repeat(${n}, 1fr)`,
gridTemplateRows: `repeat(${n}, 1fr)`
}
})
const displayUrl = computed(() => buildReportH5FullUrl(props.reportToken))
const petLabel = computed(() => (props.petName && String(props.petName).trim()) || '毛孩子')
const templateLines = computed(() => {
const link = displayUrl.value || '(请配置域名后显示链接)'
const name = petLabel.value
return [
`${name}】的洗护服务报告已生成,点链接查看:${link}`,
`您好,这是「${name}」本次服务报告,欢迎查阅:${link}`,
`毛孩子的服务记录在这里,方便时打开看看:${link}`
]
})
function buildQrGrid(text) {
if (!text) {
qrGrid.value = []
return
}
try {
const qr = new UQRCode()
qr.data = text
qr.size = 200
qr.make()
const n = qr.moduleCount
const grid = []
for (let r = 0; r < n; r++) {
const row = []
for (let c = 0; c < n; c++) {
row.push(qr.isBlack(r, c))
}
grid.push(row)
}
qrGrid.value = grid
} catch (e) {
qrGrid.value = []
}
}
watch(
() => [props.show, displayUrl.value],
([visible]) => {
if (visible && displayUrl.value) {
buildQrGrid(displayUrl.value)
copiedUrl.value = false
copiedTplIdx.value = -1
} else if (!visible) {
qrGrid.value = []
}
},
{ immediate: true }
)
function copyText(label, text) {
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({ title: label, icon: 'success' })
},
fail: () => uni.showToast({ title: '复制失败', icon: 'none' })
})
}
function copyUrl() {
if (!displayUrl.value) return
copyText('链接已复制', displayUrl.value)
copiedUrl.value = true
setTimeout(() => {
copiedUrl.value = false
}, 2000)
}
function copyTemplate(idx) {
const t = templateLines.value[idx]
if (!t) return
copyText('已复制话术', t)
copiedTplIdx.value = idx
setTimeout(() => {
copiedTplIdx.value = -1
}, 2000)
}
function emitClose() {
emit('close')
}
function onMask() {
emitClose()
}
function previewReport() {
emit('preview')
}
</script>
<style scoped>
.rsm-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(15, 23, 42, 0.45);
z-index: 10000;
display: flex;
align-items: flex-end;
justify-content: center;
padding: 0 0 env(safe-area-inset-bottom);
}
.rsm-panel {
width: 100%;
max-width: 430px;
max-height: 88vh;
overflow-y: auto;
background: #fff;
border-radius: 16px 16px 0 0;
padding: 16px 16px 20px;
box-sizing: border-box;
}
.rsm-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.rsm-title {
font-size: 17px;
font-weight: 700;
color: #0f172a;
}
.rsm-close {
font-size: 20px;
color: #94a3b8;
padding: 4px 8px;
}
.rsm-tip {
display: block;
font-size: 12px;
color: #64748b;
line-height: 1.5;
margin-bottom: 14px;
}
.rsm-block {
margin-bottom: 16px;
}
.rsm-label {
display: block;
font-size: 12px;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
}
.rsm-url-box {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 10px;
}
.rsm-url {
font-size: 12px;
color: #334155;
word-break: break-all;
line-height: 1.45;
}
.rsm-warn {
font-size: 11px;
color: #b45309;
background: #fffbeb;
border: 1px solid #fde68a;
border-radius: 10px;
padding: 10px 12px;
margin-bottom: 14px;
line-height: 1.5;
}
.rsm-code {
font-family: ui-monospace, monospace;
font-size: 10px;
}
.rsm-template {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 12px;
margin-bottom: 8px;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 10px;
}
.rsm-template-text {
flex: 1;
font-size: 13px;
color: #166534;
line-height: 1.45;
}
.rsm-template-action {
flex-shrink: 0;
font-size: 12px;
color: #15803d;
font-weight: 600;
}
.rsm-qr-wrap {
display: flex;
flex-direction: column;
align-items: center;
}
.rsm-qr {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 2px;
box-sizing: border-box;
}
.rsm-qr-dot {
min-width: 0;
min-height: 0;
background: #fff;
}
.rsm-qr-dot--on {
background: #0f172a;
}
.rsm-actions {
display: flex;
gap: 10px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f1f5f9;
}
.rsm-btn {
flex: 1;
height: 44px;
line-height: 44px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
border: none;
}
.rsm-btn--primary {
background: linear-gradient(135deg, #22c55e, #16a34a);
color: #fff;
}
.rsm-btn--ghost {
background: #f1f5f9;
color: #475569;
}
.rsm-btn[disabled] {
opacity: 0.45;
}
</style>

View File

@ -223,6 +223,7 @@ import TestimonialModal from '../../components/report/TestimonialModal.vue'
import ReminderCard from '../../components/report/ReminderCard.vue'
import ReportHighlightBlock from '../../components/report/ReportHighlightBlock.vue'
import { isLoggedIn } from '../../utils/session.js'
import { buildReportShareTitle, buildReportShareCoverUrl } from '../../utils/reportShare.js'
const loading = ref(true)
const loadError = ref(null)
@ -305,22 +306,15 @@ const staffInitial = computed(() => {
return n.slice(0, 1)
})
const shareTitle = computed(() => {
const r = reportData.value
if (!r) return '洗护前后对比'
const store = r.store?.name || '宠伴生活馆'
const pet = r.petName || '爱宠'
if (r.highlightVideoStatus === 'done' && r.highlightVideoUrl) {
return `${pet}的服务回顾短片|${store}`
}
return `${pet}的洗护蜕变|${store}`
})
const shareTitle = computed(() => buildReportShareTitle(reportData.value))
const shareCoverUrl = computed(() => {
if (afterPhotos.value.length > 0) return imgUrl(afterPhotos.value[0])
if (beforePhotos.value.length > 0) return imgUrl(beforePhotos.value[0])
return ''
})
const shareCoverUrl = computed(() =>
buildReportShareCoverUrl(reportData.value, {
afterFirst: afterPhotos.value[0],
beforeFirst: beforePhotos.value[0],
storeLogo: reportData.value?.store?.logo
})
)
const brandBarSafe = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20

View File

@ -179,6 +179,13 @@
</view>
</view>
<ReportShareModal
:show="shareModalOpen"
:report-token="shareToken"
:pet-name="sharePetName"
@close="onShareModalClose"
@preview="onSharePreview"
/>
</view>
</template>
@ -187,6 +194,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { onPageScroll } from '@dcloudio/uni-app'
import { createReport, getServiceTypeList, imgUrl, BASE_URL } from '../../api/index.js'
import AppIcon from '../../components/AppIcon.vue'
import ReportShareModal from '../../components/report/ReportShareModal.vue'
import { getUserSession, getStoreSession } from '../../utils/session.js'
import { useNavigator } from '../../composables/useNavigator.js'
import {
@ -225,6 +233,9 @@ const report = ref({
const serviceTypes = ref([])
const currentAppointmentId = ref(null)
const submitting = ref(false)
const shareModalOpen = ref(false)
const shareToken = ref('')
const sharePetName = ref('')
const basicInfoCollapsed = ref(false)
const hasScrolled = ref(false)
const keyboardHeight = ref(0)
@ -688,18 +699,34 @@ const submitReport = async () => {
submitting.value = false
if (res.code === 200) {
const token = res.data.reportToken
shareToken.value = token
sharePetName.value = report.value.petName || ''
report.value = { petName: '', serviceType: '', appointmentTime: '', beforeMedia: [], afterMedia: [], duringMedia: [], remark: '' }
currentAppointmentId.value = null
basicInfoCollapsed.value = false
uni.showToast({ title: '已生成报告', icon: 'success' })
setTimeout(() => {
report.value = { petName: '', serviceType: '', appointmentTime: '', beforeMedia: [], afterMedia: [], duringMedia: [], remark: '' }
currentAppointmentId.value = null
basicInfoCollapsed.value = false
uni.navigateTo({ url: `/pages/report-view/reportView?token=${token}` })
}, 800)
shareModalOpen.value = true
} else {
uni.showToast({ title: res.message || '提交失败', icon: 'none' })
}
}
const onShareModalClose = () => {
shareModalOpen.value = false
shareToken.value = ''
sharePetName.value = ''
}
const onSharePreview = () => {
const t = shareToken.value
shareModalOpen.value = false
shareToken.value = ''
sharePetName.value = ''
if (t) {
uni.navigateTo({ url: `/pages/report-view/reportView?token=${encodeURIComponent(t)}` })
}
}
const goBack = () => {
const pages = getCurrentPages()
if (pages.length > 1) {

View File

@ -0,0 +1,24 @@
/**
* 宠主打开的 H5 报告页完整 URLreport.html?token=用于复制 / 二维码
* 请在生产环境配置 VITE_REPORT_PUBLIC_ORIGIN部署了 report.html 的站点根无末尾斜杠
* 未配置时小程序内返回空字符串弹层会提示配置
*/
export function getReportPublicPageOrigin() {
const fromEnv = import.meta.env.VITE_REPORT_PUBLIC_ORIGIN
if (fromEnv && String(fromEnv).trim()) {
return String(fromEnv).replace(/\/$/, '')
}
// #ifdef H5
if (typeof window !== 'undefined' && window.location?.origin) {
return window.location.origin
}
// #endif
return ''
}
export function buildReportH5FullUrl(token) {
if (!token) return ''
const origin = getReportPublicPageOrigin()
if (!origin) return ''
return `${origin}/report.html?token=${encodeURIComponent(token)}`
}

40
src/utils/reportShare.js Normal file
View File

@ -0,0 +1,40 @@
import { imgUrl } from '../api/index.js'
/**
* 小程序转发标题宠物名·服务短片/报告门店本期主线传播素材统一
*/
export function buildReportShareTitle(report) {
if (!report) return '洗护前后对比'
const store = report.store?.name || '宠伴生活馆'
const pet = report.petName || '爱宠'
const svc = (report.serviceType || '').trim()
const svcPart = svc ? `·${svc}` : ''
if (report.highlightVideoStatus === 'done' && report.highlightVideoUrl) {
return `${pet}${svcPart}|服务回顾短片|${store}`
}
return `${pet}${svcPart}|洗护服务报告|${store}`
}
/**
* 转发封面图 URL 优先级
* 成片成功highlightShareCoverUrl若后端提供成片封面帧> 服务后首图 > 门店 Logo
* 无成片服务后首图 > 服务前首图 > 门店 Logo
*/
export function buildReportShareCoverUrl(report, paths) {
const afterFirst = paths?.afterFirst
const beforeFirst = paths?.beforeFirst
const storeLogo = paths?.storeLogo
const hlCover = report?.highlightShareCoverUrl || report?.highlightPosterUrl
const highlightDone = report?.highlightVideoStatus === 'done' && report?.highlightVideoUrl
if (highlightDone) {
if (hlCover) return imgUrl(hlCover)
if (afterFirst) return imgUrl(afterFirst)
if (storeLogo) return imgUrl(storeLogo)
return ''
}
if (afterFirst) return imgUrl(afterFirst)
if (beforeFirst) return imgUrl(beforeFirst)
if (storeLogo) return imgUrl(storeLogo)
return ''
}