diff --git a/.env.production b/.env.production
index 6819b97..3384388 100644
--- a/.env.production
+++ b/.env.production
@@ -1 +1,3 @@
VITE_API_ORIGIN=https://api.s-good.com
+# 部署了 report.html 的 H5 站点根(无尾斜杠),用于「生成报告」后复制链接与二维码;与 API 域名可不同
+# VITE_REPORT_PUBLIC_ORIGIN=https://www.example.com
diff --git a/README.md b/README.md
index 82648d8..1f60fed 100644
--- a/README.md
+++ b/README.md
@@ -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`,无尾斜杠)。未配置时仍可复制话术,但链接为空、二维码不展示,并会在弹层内提示。
diff --git a/package-lock.json b/package-lock.json
index 246216c..a4eff53 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 128ecc7..ad87c80 100644
--- a/package.json
+++ b/package.json
@@ -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": {
diff --git a/src/components/report/ReminderCard.vue b/src/components/report/ReminderCard.vue
index e61db96..1785575 100644
--- a/src/components/report/ReminderCard.vue
+++ b/src/components/report/ReminderCard.vue
@@ -50,12 +50,15 @@
✓
- 已为您设置提醒
+ {{ successHeadline }}
{{ formatChineseDate(savedDate) }}
- 到点我们会通过微信/短信告诉你,无需惦记。
+ {{ successDesc }}
+
+
+ 您此前已使用本手机号在本报告留资;本次已按最新建议日期更新提醒。
@@ -76,6 +79,8 @@
登录后可在「我的」查看宠物档案与预约
+
+ 若需用其他号码接收提醒,可更换后重新提交(同一报告仅保留各号码一条记录)。
@@ -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()
+})
diff --git a/src/components/report/ReportShareModal.vue b/src/components/report/ReportShareModal.vue
new file mode 100644
index 0000000..f2584e5
--- /dev/null
+++ b/src/components/report/ReportShareModal.vue
@@ -0,0 +1,337 @@
+
+
+
+
+ 发送给宠主
+ ✕
+
+ 复制下方链接或扫码打开;也可点选话术后一键复制整段发给微信好友。
+
+
+ 报告链接
+
+ {{ displayUrl || '(未配置报告页域名,见下方说明)' }}
+
+
+
+
+
+ 请在构建环境配置 VITE_REPORT_PUBLIC_ORIGIN 为已部署 H5 的根地址(与 report.html 同域),否则无法生成可扫码的链接。
+
+
+
+ 话术模板(点按复制整段)
+
+ {{ t }}
+ {{ copiedTplIdx === idx ? '已复制' : '复制' }}
+
+
+
+
+ 扫码打开报告
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/report-view/reportView.vue b/src/pages/report-view/reportView.vue
index 9d4b4bc..aa2feb4 100644
--- a/src/pages/report-view/reportView.vue
+++ b/src/pages/report-view/reportView.vue
@@ -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
diff --git a/src/pages/report/Report.vue b/src/pages/report/Report.vue
index 6ac6ff3..380c562 100644
--- a/src/pages/report/Report.vue
+++ b/src/pages/report/Report.vue
@@ -179,6 +179,13 @@
+
@@ -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) {
diff --git a/src/utils/reportPublicUrl.js b/src/utils/reportPublicUrl.js
new file mode 100644
index 0000000..ac4570b
--- /dev/null
+++ b/src/utils/reportPublicUrl.js
@@ -0,0 +1,24 @@
+/**
+ * 宠主打开的 H5 报告页完整 URL(report.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)}`
+}
diff --git a/src/utils/reportShare.js b/src/utils/reportShare.js
new file mode 100644
index 0000000..6b7fb6d
--- /dev/null
+++ b/src/utils/reportShare.js
@@ -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 ''
+}