更新 2026-04-18 21:11
This commit is contained in:
parent
757811081d
commit
07ad42ea3b
@ -1 +1,3 @@
|
|||||||
VITE_API_ORIGIN=https://api.s-good.com
|
VITE_API_ORIGIN=https://api.s-good.com
|
||||||
|
# 部署了 report.html 的 H5 站点根(无尾斜杠),用于「生成报告」后复制链接与二维码;与 API 域名可不同
|
||||||
|
# VITE_REPORT_PUBLIC_ORIGIN=https://www.example.com
|
||||||
|
|||||||
26
README.md
26
README.md
@ -90,7 +90,7 @@ npm run dev:h5
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 登录 | /pages/login/login | Tab切换:员工登录/老板登录/注册老板/注册员工 |
|
| 登录 | /pages/login/login | Tab切换:员工登录/老板登录/注册老板/注册员工 |
|
||||||
| 首页 | /pages/home/home | 预约列表 Tab(待开始/进行中/已完成)+ 新建预约 |
|
| 首页 | /pages/home/home | 预约列表 Tab(待开始/进行中/已完成)+ 新建预约 |
|
||||||
| 洗美报告 | /pages/report/report | 填写报告 + 提交后显示链接+二维码 |
|
| 洗美报告 | /pages/report/report | 填写报告;提交成功后弹出**发送给宠主**(复制链接 + 话术模板 + 二维码同屏) |
|
||||||
| 报告页 | /pages/report-view/reportView | 独立访问的报告页(token=xxx) |
|
| 报告页 | /pages/report-view/reportView | 独立访问的报告页(token=xxx) |
|
||||||
| 我的 | /pages/mine/mine | 个人中心,老板有额外菜单 |
|
| 我的 | /pages/mine/mine | 个人中心,老板有额外菜单 |
|
||||||
| 员工管理 | /pages/mine/staff | 老板功能:管理员工 |
|
| 员工管理 | /pages/mine/staff | 老板功能:管理员工 |
|
||||||
@ -104,3 +104,27 @@ npm run dev:h5
|
|||||||
- 登录成功后,`petstore_user` 和 `petstore_store` 存入本地存储
|
- 登录成功后,`petstore_user` 和 `petstore_store` 存入本地存储
|
||||||
- 用户 ID 决定能看到哪些数据(员工看自己,老板看全店)
|
- 用户 ID 决定能看到哪些数据(员工看自己,老板看全店)
|
||||||
- role = 'boss' 时,显示员工管理/服务类型/店铺设置菜单
|
- 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
7
package-lock.json
generated
@ -12,6 +12,7 @@
|
|||||||
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||||
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||||
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||||
|
"uqrcodejs": "^4.0.7",
|
||||||
"vue": "^3.4.21"
|
"vue": "^3.4.21"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -6930,6 +6931,12 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/utif": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/utif/-/utif-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/utif/-/utif-2.0.1.tgz",
|
||||||
|
|||||||
@ -13,6 +13,7 @@
|
|||||||
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
"@dcloudio/uni-components": "3.0.0-4060620250520001",
|
||||||
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
"@dcloudio/uni-h5": "3.0.0-4060620250520001",
|
||||||
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
"@dcloudio/uni-mp-weixin": "3.0.0-4060620250520001",
|
||||||
|
"uqrcodejs": "^4.0.7",
|
||||||
"vue": "^3.4.21"
|
"vue": "^3.4.21"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -50,12 +50,15 @@
|
|||||||
<!-- 成功状态:留资 + 微信绑定闭环(S3→S4) -->
|
<!-- 成功状态:留资 + 微信绑定闭环(S3→S4) -->
|
||||||
<view v-else class="rc-success">
|
<view v-else class="rc-success">
|
||||||
<view class="rc-success-icon">✓</view>
|
<view class="rc-success-icon">✓</view>
|
||||||
<view class="rc-success-title">已为您设置提醒</view>
|
<view class="rc-success-title">{{ successHeadline }}</view>
|
||||||
<view class="rc-success-date">
|
<view class="rc-success-date">
|
||||||
{{ formatChineseDate(savedDate) }}
|
{{ formatChineseDate(savedDate) }}
|
||||||
</view>
|
</view>
|
||||||
<view class="rc-success-desc">
|
<view class="rc-success-desc">
|
||||||
到点我们会通过微信/短信告诉你,无需惦记。
|
{{ successDesc }}
|
||||||
|
</view>
|
||||||
|
<view v-if="repeatSubmit" class="rc-repeat-hint">
|
||||||
|
您此前已使用本手机号在本报告留资;本次已按最新建议日期更新提醒。
|
||||||
</view>
|
</view>
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<view v-if="wechatBound" class="rc-bind rc-bind--ok">
|
<view v-if="wechatBound" class="rc-bind rc-bind--ok">
|
||||||
@ -76,6 +79,8 @@
|
|||||||
|
|
||||||
<button class="rc-login-btn" @click="goLoginWithPhone">用该手机号登录</button>
|
<button class="rc-login-btn" @click="goLoginWithPhone">用该手机号登录</button>
|
||||||
<view class="rc-login-hint">登录后可在「我的」查看宠物档案与预约</view>
|
<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>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@ -97,8 +102,20 @@ const submitting = ref(false)
|
|||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
const savedDate = ref('')
|
const savedDate = ref('')
|
||||||
const wechatBound = ref(false)
|
const wechatBound = ref(false)
|
||||||
|
const repeatSubmit = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
const successHeadline = computed(() =>
|
||||||
|
repeatSubmit.value ? '已为您更新提醒' : '已为您设置提醒'
|
||||||
|
)
|
||||||
|
const successDesc = computed(() =>
|
||||||
|
repeatSubmit.value
|
||||||
|
? '提醒日期已同步为下方日期;到点我们会通过微信/短信联系你。'
|
||||||
|
: '到点我们会通过微信/短信告诉你,无需惦记。'
|
||||||
|
)
|
||||||
|
|
||||||
|
const storageKey = () => `petstore_reminder_${props.token}`
|
||||||
|
|
||||||
const titleText = computed(() => {
|
const titleText = computed(() => {
|
||||||
const name = suggestion.value.petName || props.petName || '毛孩子'
|
const name = suggestion.value.petName || props.petName || '毛孩子'
|
||||||
const srv = friendlyServiceLabel(suggestion.value.serviceType)
|
const srv = friendlyServiceLabel(suggestion.value.serviceType)
|
||||||
@ -132,6 +149,41 @@ function formatChineseDate(dateStr) {
|
|||||||
return `${y}年${parseInt(m, 10)}月${parseInt(d, 10)}日`
|
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() {
|
async function loadSuggestion() {
|
||||||
if (!props.token) return
|
if (!props.token) return
|
||||||
try {
|
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() {
|
async function onSubmit() {
|
||||||
if (!canSubmit.value) return
|
if (!canSubmit.value) return
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
@ -180,7 +245,9 @@ async function onSubmit() {
|
|||||||
if (res && res.code === 200 && res.data && res.data.ok) {
|
if (res && res.code === 200 && res.data && res.data.ok) {
|
||||||
savedDate.value = res.data.remindDate || suggestion.value.remindDate
|
savedDate.value = res.data.remindDate || suggestion.value.remindDate
|
||||||
wechatBound.value = !!res.data.wechatBound
|
wechatBound.value = !!res.data.wechatBound
|
||||||
|
repeatSubmit.value = !!res.data.repeatSubmit
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
|
persistToStorage()
|
||||||
} else {
|
} else {
|
||||||
errorMsg.value = (res && res.message) || '提交失败,请稍后再试'
|
errorMsg.value = (res && res.message) || '提交失败,请稍后再试'
|
||||||
}
|
}
|
||||||
@ -191,7 +258,10 @@ async function onSubmit() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadSuggestion)
|
onMounted(async () => {
|
||||||
|
tryRestoreFromStorage()
|
||||||
|
await loadSuggestion()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -398,4 +468,34 @@ onMounted(loadSuggestion)
|
|||||||
color: #94a3b8;
|
color: #94a3b8;
|
||||||
line-height: 1.4;
|
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>
|
</style>
|
||||||
|
|||||||
337
src/components/report/ReportShareModal.vue
Normal file
337
src/components/report/ReportShareModal.vue
Normal 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>
|
||||||
@ -223,6 +223,7 @@ import TestimonialModal from '../../components/report/TestimonialModal.vue'
|
|||||||
import ReminderCard from '../../components/report/ReminderCard.vue'
|
import ReminderCard from '../../components/report/ReminderCard.vue'
|
||||||
import ReportHighlightBlock from '../../components/report/ReportHighlightBlock.vue'
|
import ReportHighlightBlock from '../../components/report/ReportHighlightBlock.vue'
|
||||||
import { isLoggedIn } from '../../utils/session.js'
|
import { isLoggedIn } from '../../utils/session.js'
|
||||||
|
import { buildReportShareTitle, buildReportShareCoverUrl } from '../../utils/reportShare.js'
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref(null)
|
const loadError = ref(null)
|
||||||
@ -305,22 +306,15 @@ const staffInitial = computed(() => {
|
|||||||
return n.slice(0, 1)
|
return n.slice(0, 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
const shareTitle = computed(() => {
|
const shareTitle = computed(() => buildReportShareTitle(reportData.value))
|
||||||
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 shareCoverUrl = computed(() => {
|
const shareCoverUrl = computed(() =>
|
||||||
if (afterPhotos.value.length > 0) return imgUrl(afterPhotos.value[0])
|
buildReportShareCoverUrl(reportData.value, {
|
||||||
if (beforePhotos.value.length > 0) return imgUrl(beforePhotos.value[0])
|
afterFirst: afterPhotos.value[0],
|
||||||
return ''
|
beforeFirst: beforePhotos.value[0],
|
||||||
})
|
storeLogo: reportData.value?.store?.logo
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
const brandBarSafe = (() => {
|
const brandBarSafe = (() => {
|
||||||
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
||||||
|
|||||||
@ -179,6 +179,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<ReportShareModal
|
||||||
|
:show="shareModalOpen"
|
||||||
|
:report-token="shareToken"
|
||||||
|
:pet-name="sharePetName"
|
||||||
|
@close="onShareModalClose"
|
||||||
|
@preview="onSharePreview"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -187,6 +194,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
|||||||
import { onPageScroll } from '@dcloudio/uni-app'
|
import { onPageScroll } from '@dcloudio/uni-app'
|
||||||
import { createReport, getServiceTypeList, imgUrl, BASE_URL } from '../../api/index.js'
|
import { createReport, getServiceTypeList, imgUrl, BASE_URL } from '../../api/index.js'
|
||||||
import AppIcon from '../../components/AppIcon.vue'
|
import AppIcon from '../../components/AppIcon.vue'
|
||||||
|
import ReportShareModal from '../../components/report/ReportShareModal.vue'
|
||||||
import { getUserSession, getStoreSession } from '../../utils/session.js'
|
import { getUserSession, getStoreSession } from '../../utils/session.js'
|
||||||
import { useNavigator } from '../../composables/useNavigator.js'
|
import { useNavigator } from '../../composables/useNavigator.js'
|
||||||
import {
|
import {
|
||||||
@ -225,6 +233,9 @@ const report = ref({
|
|||||||
const serviceTypes = ref([])
|
const serviceTypes = ref([])
|
||||||
const currentAppointmentId = ref(null)
|
const currentAppointmentId = ref(null)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const shareModalOpen = ref(false)
|
||||||
|
const shareToken = ref('')
|
||||||
|
const sharePetName = ref('')
|
||||||
const basicInfoCollapsed = ref(false)
|
const basicInfoCollapsed = ref(false)
|
||||||
const hasScrolled = ref(false)
|
const hasScrolled = ref(false)
|
||||||
const keyboardHeight = ref(0)
|
const keyboardHeight = ref(0)
|
||||||
@ -688,18 +699,34 @@ const submitReport = async () => {
|
|||||||
submitting.value = false
|
submitting.value = false
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
const token = res.data.reportToken
|
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' })
|
uni.showToast({ title: '已生成报告', icon: 'success' })
|
||||||
setTimeout(() => {
|
shareModalOpen.value = true
|
||||||
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)
|
|
||||||
} else {
|
} else {
|
||||||
uni.showToast({ title: res.message || '提交失败', icon: 'none' })
|
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 goBack = () => {
|
||||||
const pages = getCurrentPages()
|
const pages = getCurrentPages()
|
||||||
if (pages.length > 1) {
|
if (pages.length > 1) {
|
||||||
|
|||||||
24
src/utils/reportPublicUrl.js
Normal file
24
src/utils/reportPublicUrl.js
Normal file
@ -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)}`
|
||||||
|
}
|
||||||
40
src/utils/reportShare.js
Normal file
40
src/utils/reportShare.js
Normal 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 ''
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user