feat: align phase A customer flows

This commit is contained in:
malei 2026-08-01 19:58:10 +08:00
parent 2901e219be
commit 7a0d0e064a
11 changed files with 291 additions and 92 deletions

View File

@ -1 +1,3 @@
VITE_API_ORIGIN=https://api.s-good.com
# 小程序构建时的 API 域名api.s-good.com 已挪作他用,勿再指向)
# 提审/体验版前请改为宠小它专用域名,并在微信后台配置合法域名
VITE_API_ORIGIN=http://localhost:8080

View File

@ -1,3 +1,3 @@
VITE_API_ORIGIN=https://api.s-good.com
# 部署了 report.html 的 H5 站点根(无尾斜杠),用于「生成报告」后复制链接与二维码;与 API 域名可不同
# VITE_REPORT_PUBLIC_ORIGIN=https://www.example.com
# 宠小它生产 API 域名api.s-good.com 已挪作他用,勿再指向)
# 部署前请改为宠小它专用域名,例如 https://api.chongxiaota.example
VITE_API_ORIGIN=http://localhost:8080

View File

@ -115,7 +115,7 @@ npm run dev:h5
1. **本地构建小程序**
```bash
npm run build:mp-weixin
npm --prefix frontend run build:mp-weixin
```
产物目录:`dist/build/mp-weixin/`(以构建日志为准)。
@ -133,5 +133,40 @@ npm run dev:h5
### 宠主预约草稿P0 C3
当前实现:**已登录**用户在 `CustAppointmentCreate` 填写的内容会 debounce 写入本地 `uni.storage`7 天有效),离开页/重进可恢复;提交成功会清除草稿。
产品文档中的 **「未登录可先填单、提交时再登录并恢复草稿」** 需同时将「新建预约」页加入公开路由(如 `App.vue``isPublicRoute`)并在登录回跳后合并草稿;若未做公开路由,未登录用户仍会在进入预约页前被拦截到登录。
当前实现:**提交时登录 + guest 草稿恢复**。未登录用户可填写预约表单,提交时跳转登录页(带 `redirect` 回预约页并保留 `storeId`),登录成功后回到预约页并恢复 guest 草稿(草稿 key 不依赖 `userInfo.id`,避免 `userId: undefined`)。登录态用户继续按 `userId` 维度保存草稿。草稿有效期 7 天。
## 前端 RC 发布门禁
发布前必须依次执行并全部通过:
```bash
# 1. 安装依赖
npm --prefix frontend install
# 2. H5 构建
npm --prefix frontend run build:h5
# 期望DONE Build complete.
# 3. 微信小程序构建
npm --prefix frontend run build:mp-weixin
# 期望DONE Build complete. + [verify-mp-app-json] OK
```
### 前端 RC 检查项
- [ ] `VITE_API_ORIGIN` 已配置(宠小它专用后端 API 域名;**勿再用** `api.s-good.com`
- [ ] `VITE_REPORT_PUBLIC_ORIGIN` 已配置(宠小它 H5 报告页域名,无尾斜杠;**勿再用** `www.s-good.com` 除非仍归属本项目)
- [ ] 微信小程序后台 **request 合法域名** 已加入 `VITE_API_ORIGIN`
- [ ] 微信小程序后台 **downloadFile 合法域名** 已加入 `VITE_API_ORIGIN`(成片下载)与 `VITE_REPORT_PUBLIC_ORIGIN`H5 报告页资源,若需)
- [ ] 报告二维码链接检查:扫码后 H5 报告页可正常打开token 匿名可访问
- [ ] 公开报告页 customer 登录态仍显示留资/预约 CTAboss/staff 才显示内部分享/保存
- [ ] 未登录预约:填写 → 提交跳登录 → 登录后草稿恢复(门店、宠物、服务、时段、备注)
- [ ] 报告提交无前图提示「请至少上传1张服务前照片」无后图提示「请至少上传1张服务后照片」
- [ ] 小程序码 scene 解码:`scene=storeId%3D1` 可预填门店
## 环境变量
| 变量 | 必填 | 默认 | 说明 |
|------|------|------|------|
| `VITE_API_ORIGIN` | 生产必填 | `http://localhost:8080` | 后端 API 域名(用于 `uni.request` 与图片 URL 拼接)。**勿使用已挪作他用的 `api.s-good.com`** |
| `VITE_REPORT_PUBLIC_ORIGIN` | 生产必填 | 空 | H5 报告页域名(用于报告分享链接与二维码) |

25
package-lock.json generated
View File

@ -3260,6 +3260,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3273,6 +3274,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3286,6 +3288,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3299,6 +3302,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3312,6 +3316,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3325,6 +3330,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3338,6 +3344,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3351,6 +3358,7 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3364,6 +3372,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3377,6 +3386,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3390,6 +3400,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3403,6 +3414,7 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3416,6 +3428,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3429,6 +3442,7 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3442,6 +3456,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3455,6 +3470,7 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3468,6 +3484,7 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3481,6 +3498,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3494,6 +3512,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3507,6 +3526,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3520,6 +3540,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3533,6 +3554,7 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3546,6 +3568,7 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3559,6 +3582,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -3572,6 +3596,7 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [

View File

@ -1,13 +1,21 @@
const API_ORIGIN = (import.meta.env.VITE_API_ORIGIN || 'http://localhost:8080').replace(/\/$/, '')
const BASE_URL = `${API_ORIGIN}/api`
import { getSessionToken } from '../utils/session.js'
// uniapp request 封装
const request = (options) => new Promise((resolve, reject) => {
// 自动附加 Authorization: Bearer <sessionToken>(若存在)
const headers = { ...(options.header || {}) }
const token = getSessionToken()
if (token && !headers.Authorization) {
headers.Authorization = `Bearer ${token}`
}
uni.request({
url: BASE_URL + options.url,
method: options.method || 'GET',
data: options.data || {},
header: options.header || {},
header: headers,
success: (res) => resolve(res.data),
fail: (err) => reject(err)
})
@ -57,42 +65,42 @@ export const registerBoss = (data) => post('/user/register-boss', data)
// 注册员工
export const registerStaff = (data) => post('/user/register-staff', data)
// 宠物档案(客户ownerUserId门店storeId
export const getPetList = (params) => get('/pet/list', params)
// 宠物档案(身份由后端从 session token 派生;保留旧签名兼容,不再传 ownerUserId/storeId 给后端
export const getPetList = (/* params */) => get('/pet/list', {})
export const createPet = (data) => post('/pet/create', data)
export const updatePet = (data) => put('/pet/update', data)
export const deletePet = (id, operatorUserId, role) =>
del(`/pet/delete?id=${id}&operatorUserId=${operatorUserId}&role=${encodeURIComponent(role)}`)
export const deletePet = (id/*, operatorUserId, role */) =>
del(`/pet/delete?id=${id}`)
/** 按宠物:预约 + 报告时间线(需 operatorUserId + role 鉴权 */
export const getPetHistory = (petId, operatorUserId, role) =>
get('/pet/history', { petId, operatorUserId, role })
/** 按宠物:预约 + 报告时间线(身份由后端从 session token 派生 */
export const getPetHistory = (petId/*, operatorUserId, role */) =>
get('/pet/history', { petId })
// 预约列表
export const getAppointmentList = (userId, storeId, options = {}) =>
get('/appointment/list', { userId, storeId, ...options })
// 预约列表(身份由后端从 session token 派生;保留旧签名兼容,不传 userId/storeId
export const getAppointmentList = (/* userId, storeId, */ options = {}) =>
get('/appointment/list', { status: options.status, page: options.page, pageSize: options.pageSize })
// 预约详情
export const getAppointmentDetail = (id) => get('/appointment/detail', { id })
// 创建预约
// 创建预约userId/storeId 由后端从上下文派生;前端仍传 storeId 供 customer 选店场景,后端会按 role 决定是否采纳)
export const createAppointment = (data) => post('/appointment/create', data)
/** 门店某日可预约半小时档(可约 / 已满 / 已过) */
/** 门店某日可预约半小时档(可约 / 已满 / 已过);公开接口 */
export const getAppointmentAvailableSlots = (storeId, date) =>
get('/appointment/available-slots', { storeId, date })
// 开始服务
export const startAppointment = (appointmentId, staffUserId) =>
post('/appointment/start', { appointmentId, staffUserId })
// 开始服务staffUserId 由后端从上下文派生;保留旧签名兼容,不传 staffUserId
export const startAppointment = (appointmentId/*, staffUserId */) =>
post('/appointment/start', { appointmentId })
// 取消预约
// 取消预约(身份由后端从上下文派生)
export const cancelAppointment = (id) => put(`/appointment/status?id=${id}&status=cancel`)
// 提交报告
export const createReport = (data) => post('/report/create', data)
/** 触发服务回顾短片生成异步。composeMode: preset | interleave,时长由后端按素材计算 */
/** 触发服务回顾短片生成异步。composeMode: preset | interleave;身份由后端从上下文派生 */
export const postReportHighlightStart = (data) => post('/report/highlight/start', data)
/**
@ -105,8 +113,9 @@ export const getReportByToken = (token) => get('/report/get', { token })
export const postReportOpenTrack = (token, visitType) =>
post('/report/open-track', { token, visitType: visitType || 'unknown' })
/** 报告列表;元素含与 getReportByToken 相同的短片字段(见 docs/API-报告成片字段契约.md */
export const getReportList = (params) => get('/report/list', params)
/** 报告列表身份由后端从上下文派生boss/staff 查本店customer 查自己) */
export const getReportList = (/* params */ options = {}) =>
get('/report/list', { page: options.page, pageSize: options.pageSize })
// 报告页:下次服务建议日期
export const getReportSuggestion = (token) => get(`/report/${encodeURIComponent(token)}/suggestion`)
@ -123,9 +132,9 @@ export const submitReportReminder = (token, phone, loginCode) =>
export const unsubscribeReportReminder = (unsubscribeToken) =>
post('/report/unsubscribe', { unsubscribeToken })
// 老板后台:回访池列表
export const getReportLeads = (storeId, status = 'pending') =>
get('/report/leads', { storeId, status })
// 老板后台:回访池列表storeId 由后端从上下文派生;保留旧签名兼容)
export const getReportLeads = (/* storeId, */ status = 'pending') =>
get('/report/leads', { status })
// 报告页:宠主寄语(生成海报前提交,供口碑墙)
export const submitReportTestimonial = (token, content, isPublic = true) =>
@ -144,16 +153,16 @@ export const createServiceType = (storeId, name) => post('/service-type/create',
// 删除服务类型
export const deleteServiceType = (id) => del(`/service-type/delete?id=${id}`)
// 员工列表
export const getStaffList = (storeId) => get('/user/staff-list', { storeId })
// 员工列表storeId 由后端从上下文派生;保留旧签名兼容)
export const getStaffList = (/* storeId */) => get('/user/staff-list', {})
// 添加员工
// 添加员工storeId 由后端从上下文派生)
export const createStaff = (data) => post('/user/create-staff', data)
// 删除员工
export const deleteStaff = (staffId) => del(`/user/staff?staffId=${staffId}`)
// 更新用户信息
// 更新当前登录用户信息id 由后端从上下文派生)
export const updateUser = (data) => put('/user/update', data)
// 门店列表(客户预约选店)
@ -176,10 +185,14 @@ export const deleteScheduleBlock = (id) => del(`/schedule/block?id=${id}`)
// 上传图片uniapp 版:使用 uni.uploadFile
export const uploadImage = (filePath) =>
new Promise((resolve, reject) => {
const header = {}
const token = getSessionToken()
if (token) header.Authorization = `Bearer ${token}`
uni.uploadFile({
url: `${BASE_URL}/upload/image`,
filePath: filePath,
name: 'file',
header,
success: (res) => {
try {
const data = JSON.parse(res.data)

View File

@ -129,7 +129,7 @@ import {
getAppointmentAvailableSlots
} from '../../api/index.js'
import AppIcon from '../../components/AppIcon.vue'
import { getUserSession, getStoreSession, setStoreSession } from '../../utils/session.js'
import { getUserSession, getStoreSession, setStoreSession, getGuestApptDraft, saveGuestApptDraft, clearGuestApptDraft, apptDraftUserKey } from '../../utils/session.js'
import { haversineKm } from '../../utils/geo.js'
import { normalizeToHalfHour, ymdLocal, formatHalfHourSlotRange } from '../../utils/halfHourTime.js'
@ -146,14 +146,28 @@ const lastServiceStorageKey = (storeId) =>
const routeStoreId = ref('')
const storeExpanded = ref(false)
/**
* 解析小程序码 scene 字段中的 storeId
* 支持形如 `storeId=1``sid=1``?storeId=1` query 也兼容被 encodeURIComponent 编码过的字符串
*/
function parseSceneStoreId(scene) {
if (!scene) return ''
let decoded = String(scene)
try { decoded = decodeURIComponent(decoded) } catch (_) {}
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
return params.get('storeId') || params.get('sid') || ''
}
const orderedStores = ref([])
const selectedStoreIndex = ref(0)
const hasUserLocation = ref(false)
onLoad((options) => {
if (options?.storeId != null && String(options.storeId).trim() !== '') {
routeStoreId.value = String(options.storeId).trim()
}
// storeId query > scene
const directStoreId = options?.storeId != null && String(options.storeId).trim() !== ''
? String(options.storeId).trim()
: ''
routeStoreId.value = directStoreId || parseSceneStoreId(options?.scene)
})
const storeLockedCompact = computed(() => {
@ -564,6 +578,14 @@ const confirmNewAppt = async () => {
uni.showToast({ title: '请先选择预约门店', icon: 'none' })
return
}
// guest 稿稿
if (!userInfo?.id) {
saveApptDraft()
const target = `/pages/appointment/CustAppointmentCreate?storeId=${encodeURIComponent(storeId)}`
const redirect = encodeURIComponent(target)
uni.navigateTo({ url: `/pages/login/Login?redirect=${redirect}` })
return
}
creatingAppt.value = true
const ensuredPet = await ensurePetProfile(a.petName, a.petType)
if (!ensuredPet?.id) {
@ -581,7 +603,9 @@ const confirmNewAppt = async () => {
uni.setStorageSync(lastServiceStorageKey(storeId), a.serviceType)
} catch (_) {}
try {
uni.removeStorageSync(apptDraftStorageKey())
const key = apptDraftStorageKey()
if (key) uni.removeStorageSync(key)
clearGuestApptDraft()
} catch (_) {}
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 600)
@ -597,6 +621,11 @@ const goBack = () => {
}
const loadMyPets = async () => {
// 稿
if (!userInfo?.id) {
myPets.value = []
return
}
const res = await getPetList({ ownerUserId: userInfo.id })
if (res.code === 200) {
myPets.value = res.data || []
@ -604,56 +633,86 @@ const loadMyPets = async () => {
}
}
/** P0 C3意外退出 / 返回栈恢复草稿7 天);与「未登录可填单」全量能力见 README */
const apptDraftStorageKey = () => `petstore_cust_appt_draft_v1_${userInfo.id || '0'}`
/**
* P0 C3草稿策略冻结为提交时登录 + guest 草稿恢复
* - 未登录写入 guest 草稿 key不依赖 userId
* - 登录后优先读 guest 草稿恢复恢复成功后删除 guest 草稿同时按 userId 维度保存草稿
* 草稿有效期 7
*/
const apptDraftStorageKey = () => {
const uid = userInfo?.id
return uid ? apptDraftUserKey(uid) : ''
}
const buildDraftPayload = () => {
const row = orderedStores.value[selectedStoreIndex.value]
return {
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
}
}
const applyDraft = (d) => {
if (!d || d.v !== 1) 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
}
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
})
)
const payload = buildDraftPayload()
// guest 稿 userId
if (!userInfo?.id) {
saveGuestApptDraft(payload)
return
}
const key = apptDraftStorageKey()
if (key) uni.setStorageSync(key, JSON.stringify(payload))
} 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())
// guest 稿 guest 稿
const guest = getGuestApptDraft()
if (guest) {
if (Date.now() - (guest.at || 0) <= 7 * 86400000) {
applyDraft(guest)
}
clearGuestApptDraft()
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()
if (!userInfo?.id) return
const key = apptDraftStorageKey()
if (!key) return
const raw = uni.getStorageSync(key)
if (!raw) return
const d = JSON.parse(raw)
if (Date.now() - (d.at || 0) > 7 * 86400000) {
uni.removeStorageSync(key)
return
}
selectedPetId.value = null
applyDraft(d)
} catch (_) {}
}

View File

@ -97,7 +97,7 @@
import { ref, reactive, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { sendSms, login, wxPhoneLogin, registerBoss, registerStaff } from '../../api/index.js'
import { setStoreSession, setUserSession } from '../../utils/session.js'
import { setStoreSession, setUserSession, setSessionToken } from '../../utils/session.js'
import { openAppPage } from '../../utils/globalState.js'
const view = ref('login')
@ -269,6 +269,7 @@ const handleLogin = async () => {
if (res.code === 200) {
setUserSession(res.data.user)
setStoreSession(res.data.store)
if (res.data.sessionToken) setSessionToken(res.data.sessionToken)
navigateAfterLogin()
} else {
showToast(res.message || '登录失败')
@ -313,6 +314,7 @@ const onWxGetPhoneNumber = async (e) => {
if (res.code === 200) {
setUserSession(res.data.user)
setStoreSession(res.data.store)
if (res.data.sessionToken) setSessionToken(res.data.sessionToken)
navigateAfterLogin()
} else {
showToast(res.message || '登录失败')

View File

@ -76,13 +76,13 @@
<text class="row-label">手机号</text>
<text class="row-value phone">{{ item.phone }}</text>
</view>
<view v-if="item.wechatOpenid" class="lead-row">
<text class="row-label">openid</text>
<text class="row-value phone">{{ item.wechatOpenid }}</text>
<view v-if="item.wechatBound" class="lead-row">
<text class="row-label">微信</text>
<text class="row-value phone">已绑定 {{ item.wechatOpenidMasked || '' }}</text>
</view>
<view v-if="item.wechatUnionid" class="lead-row">
<view v-if="item.wechatBound && item.wechatUnionidMasked" class="lead-row">
<text class="row-label">unionid</text>
<text class="row-value phone">{{ item.wechatUnionid }}</text>
<text class="row-value phone">{{ item.wechatUnionidMasked }}</text>
</view>
<view class="lead-row">
<text class="row-label">留资时间</text>

View File

@ -227,7 +227,7 @@ import DuringMediaGrid from '../../components/report/DuringMediaGrid.vue'
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 { getUserSession, isStaffRole } from '../../utils/session.js'
import { buildReportShareTitle, buildReportShareCoverUrl } from '../../utils/reportShare.js'
const loading = ref(true)
@ -237,7 +237,8 @@ const reportData = ref(null)
const posterCanvas = ref(null)
const posterModalOpen = ref(false)
const routeToken = ref('')
const isStaff = computed(() => isLoggedIn())
// boss / staff customer CTA
const isStaff = computed(() => isStaffRole(getUserSession()?.role))
const hasHighlightSource = computed(() => {
const r = reportData.value
@ -256,8 +257,11 @@ const getRouteToken = () => {
return token || ''
}
/** 报告 token接口字段优先兼容 H5 地址栏 */
const reminderToken = computed(() => reportData.value?.reportToken || getRouteToken())
/** 报告 token只用路由 token不依赖回包 reportToken避免公开页暴露内部 token 字段。 */
const reminderToken = computed(() => getRouteToken())
/** 公开报告页可返回的门店 id用于「我也要预约」深链不读 top-level storeId/userId。 */
const bookingStoreId = computed(() => reportData.value?.store?.id || reportData.value?.bookingStoreId || '')
const beforePhotos = computed(() => {
if (!reportData.value?.beforePhotos) return []
@ -360,7 +364,15 @@ const loadReport = async () => {
}
}
const goHome = () => uni.reLaunch({ url: '/pages/home/Home' })
const goHome = () => {
// id 退
const sid = bookingStoreId.value
if (sid) {
uni.navigateTo({ url: `/pages/appointment/CustAppointmentCreate?storeId=${encodeURIComponent(sid)}` })
return
}
uni.reLaunch({ url: '/pages/home/Home' })
}
const goBackOrHome = () => {
try {

View File

@ -678,14 +678,20 @@ const submitReport = async () => {
if (!report.value.petName) return uni.showToast({ title: '请输入宠物名字', icon: 'none' })
if (!report.value.serviceType) return uni.showToast({ title: '请选择服务类型', icon: 'none' })
if (!report.value.appointmentTime) return uni.showToast({ title: '请选择服务时间', icon: 'none' })
// / 1
const validBefore = report.value.beforeMedia.filter((item) => !item.uploading && !item.failed)
const validAfter = report.value.afterMedia.filter((item) => !item.uploading && !item.failed)
if (validBefore.length < 1) return uni.showToast({ title: '请至少上传1张服务前照片', icon: 'none' })
if (validAfter.length < 1) return uni.showToast({ title: '请至少上传1张服务后照片', icon: 'none' })
submitting.value = true
//
// filter
const images = []
report.value.beforeMedia.filter((item) => !item.uploading && !item.failed).forEach((item, i) => {
validBefore.forEach((item, i) => {
images.push({ photoUrl: item.url, photoType: 'before', mediaType: item.mediaType || 'photo', sortOrder: i })
})
report.value.afterMedia.filter((item) => !item.uploading && !item.failed).forEach((item, i) => {
validAfter.forEach((item, i) => {
images.push({ photoUrl: item.url, photoType: 'after', mediaType: item.mediaType || 'photo', sortOrder: i })
})
report.value.duringMedia.filter((item) => !item.uploading && !item.failed).forEach((item, i) => {

View File

@ -16,6 +16,18 @@ export const isLoggedIn = () => {
return !!(u && u.id)
}
/** 当前登录用户角色(未登录返回空串) */
export const getUserRole = () => {
const u = getUserSession()
return (u && u.role) || ''
}
/** 是否为员工态(仅 boss / staff。customer 登录态返回 false避免误判为内部员工。 */
export const isStaffRole = (role) => role === 'boss' || role === 'staff'
/** 当前登录用户是否为员工态boss / staff。 */
export const isCurrentUserStaff = () => isStaffRole(getUserRole())
export const setUserSession = (user) => {
uni.setStorageSync('petstore_user', JSON.stringify(user || {}))
}
@ -24,7 +36,40 @@ export const setStoreSession = (store) => {
uni.setStorageSync('petstore_store', JSON.stringify(store || {}))
}
/** 后端签发的 HMAC session token登录成功返回受保护接口自动作为 Bearer Authorization 附加。 */
export const getSessionToken = () => uni.getStorageSync('petstore_session_token') || ''
export const setSessionToken = (token) => {
if (token) uni.setStorageSync('petstore_session_token', token)
else uni.removeStorageSync('petstore_session_token')
}
/**
* guest 草稿未登录预约草稿工具
* 草稿 key 不依赖 userId避免未登录时出现 userId: undefined
*/
const GUEST_APPT_DRAFT_KEY = 'petstore_appt_draft_guest'
export const getGuestApptDraft = () => parseJSON(uni.getStorageSync(GUEST_APPT_DRAFT_KEY), null)
export const saveGuestApptDraft = (draft) => {
if (draft == null) {
uni.removeStorageSync(GUEST_APPT_DRAFT_KEY)
return
}
uni.setStorageSync(GUEST_APPT_DRAFT_KEY, JSON.stringify(draft))
}
export const clearGuestApptDraft = () => {
uni.removeStorageSync(GUEST_APPT_DRAFT_KEY)
}
/** 登录态用户维度的草稿 key登录后可继续按 userId 维度保存草稿)。 */
export const apptDraftUserKey = (userId) => `petstore_appt_draft_${userId}`
export const clearSession = () => {
uni.removeStorageSync('petstore_user')
uni.removeStorageSync('petstore_store')
uni.removeStorageSync('petstore_session_token')
uni.removeStorageSync(GUEST_APPT_DRAFT_KEY)
}