From f192d89534c488a44ded0a0821b65d615b31c988 Mon Sep 17 00:00:00 2001 From: malei <> Date: Sat, 1 Aug 2026 19:58:10 +0800 Subject: [PATCH] feat: align phase A customer flows --- .env.mp-weixin | 4 +- .env.production | 6 +- README.md | 41 ++++- package-lock.json | 25 +++ src/api/index.js | 67 ++++---- .../appointment/CustAppointmentCreate.vue | 147 ++++++++++++------ src/pages/login/Login.vue | 4 +- src/pages/mine/Leads.vue | 10 +- src/pages/report-view/reportView.vue | 22 ++- src/pages/report/Report.vue | 12 +- src/utils/session.js | 45 ++++++ 11 files changed, 291 insertions(+), 92 deletions(-) diff --git a/.env.mp-weixin b/.env.mp-weixin index 6819b97..c5f6da3 100644 --- a/.env.mp-weixin +++ b/.env.mp-weixin @@ -1 +1,3 @@ -VITE_API_ORIGIN=https://api.s-good.com +# 小程序构建时的 API 域名(api.s-good.com 已挪作他用,勿再指向) +# 提审/体验版前请改为宠小它专用域名,并在微信后台配置合法域名 +VITE_API_ORIGIN=http://localhost:8080 diff --git a/.env.production b/.env.production index 3384388..c0479dc 100644 --- a/.env.production +++ b/.env.production @@ -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 diff --git a/README.md b/README.md index 508d922..f051ff3 100644 --- a/README.md +++ b/README.md @@ -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 登录态仍显示留资/预约 CTA;boss/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 报告页域名(用于报告分享链接与二维码) | diff --git a/package-lock.json b/package-lock.json index 62d049f..a4eff53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": [ diff --git a/src/api/index.js b/src/api/index.js index e5b23e7..afebc9a 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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 (若存在) + 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) diff --git a/src/pages/appointment/CustAppointmentCreate.vue b/src/pages/appointment/CustAppointmentCreate.vue index edf1a45..26ed0af 100644 --- a/src/pages/appointment/CustAppointmentCreate.vue +++ b/src/pages/appointment/CustAppointmentCreate.vue @@ -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 (_) {} } diff --git a/src/pages/login/Login.vue b/src/pages/login/Login.vue index 059fbd0..07fc3da 100644 --- a/src/pages/login/Login.vue +++ b/src/pages/login/Login.vue @@ -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 || '登录失败') diff --git a/src/pages/mine/Leads.vue b/src/pages/mine/Leads.vue index 8a6637b..32b8ac6 100644 --- a/src/pages/mine/Leads.vue +++ b/src/pages/mine/Leads.vue @@ -76,13 +76,13 @@ 手机号 {{ item.phone }} - - openid - {{ item.wechatOpenid }} + + 微信 + 已绑定 {{ item.wechatOpenidMasked || '' }} - + unionid - {{ item.wechatUnionid }} + {{ item.wechatUnionidMasked }} 留资时间 diff --git a/src/pages/report-view/reportView.vue b/src/pages/report-view/reportView.vue index 8823945..23c49b0 100644 --- a/src/pages/report-view/reportView.vue +++ b/src/pages/report-view/reportView.vue @@ -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 { diff --git a/src/pages/report/Report.vue b/src/pages/report/Report.vue index cfec8d4..59b6a4c 100644 --- a/src/pages/report/Report.vue +++ b/src/pages/report/Report.vue @@ -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) => { diff --git a/src/utils/session.js b/src/utils/session.js index 5bca0c2..e61ac19 100644 --- a/src/utils/session.js +++ b/src/utils/session.js @@ -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) }