Compare commits

...

6 Commits

Author SHA1 Message Date
malei
a595ea8fa1 feat: add merchant onboarding and staff invite flow 2026-08-02 01:02:10 +08:00
malei
d9dfb8b7f1 feat: confirm report delivery after sharing 2026-08-02 00:15:00 +08:00
malei
e11ce1aaaa chore: guard frontend production builds 2026-08-01 23:30:32 +08:00
malei
d29ece3a85 feat: book against service duration 2026-08-01 22:56:31 +08:00
malei
9a12ee07b0 docs: update report tracking contract 2026-08-01 22:29:20 +08:00
malei
ec4015aaba feat: collect customer identity for assisted bookings 2026-08-01 20:31:53 +08:00
20 changed files with 1020 additions and 911 deletions

View File

@ -1,3 +1,3 @@
# 小程序构建时的 API 域名api.s-good.com 已挪作他用,勿再指向) # 安全占位符:提审/体验版构建前必须用受控环境文件覆盖,并运行发布门禁。
# 提审/体验版前请改为宠小它专用域名,并在微信后台配置合法域名 VITE_API_ORIGIN=https://api.petstore.invalid
VITE_API_ORIGIN=http://localhost:8080 VITE_REPORT_PUBLIC_ORIGIN=https://report.petstore.invalid

View File

@ -1,3 +1,4 @@
# 宠小它生产 API 域名api.s-good.com 已挪作他用,勿再指向) # 安全占位符:正式构建必须通过 PETSTORE_FRONTEND_ENV_FILE 指向服务器/CI 的受控环境文件,
# 部署前请改为宠小它专用域名,例如 https://api.chongxiaota.example # 并先执行 npm run preflight:release.invalid 会被门禁拒绝。
VITE_API_ORIGIN=http://localhost:8080 VITE_API_ORIGIN=https://api.petstore.invalid
VITE_REPORT_PUBLIC_ORIGIN=https://report.petstore.invalid

View File

@ -76,9 +76,7 @@ npm run dev:h5
开发阶段:在微信开发者工具中勾选「不校验合法域名」(设置 → 项目设置) 开发阶段:在微信开发者工具中勾选「不校验合法域名」(设置 → 项目设置)
生产阶段:在微信公众平台后台添加以下合法域名: 生产阶段:仅在微信公众平台后台添加本项目实际 HTTPS 后端域名localhost 只用于开发,不能进入体验版/正式版。
- `http://localhost:8080`(开发用)
- 你的实际后端域名
## API 配置 ## API 配置
@ -135,6 +133,10 @@ npm run dev:h5
当前实现:**提交时登录 + guest 草稿恢复**。未登录用户可填写预约表单,提交时跳转登录页(带 `redirect` 回预约页并保留 `storeId`),登录成功后回到预约页并恢复 guest 草稿(草稿 key 不依赖 `userInfo.id`,避免 `userId: undefined`)。登录态用户继续按 `userId` 维度保存草稿。草稿有效期 7 天。 当前实现:**提交时登录 + guest 草稿恢复**。未登录用户可填写预约表单,提交时跳转登录页(带 `redirect` 回预约页并保留 `storeId`),登录成功后回到预约页并恢复 guest 草稿(草稿 key 不依赖 `userInfo.id`,避免 `userId: undefined`)。登录态用户继续按 `userId` 维度保存草稿。草稿有效期 7 天。
### 门店代客预约身份
老板/员工在首页新建预约时必须填写宠主手机号,可选填宠主称呼。后端按手机号关联或创建 customer并分别记录 `customerUserId``createdByUserId`;前端不得再把当前员工 `userId` 当作宠主提交。预约详情优先读取 `assignedStaffId`,兼容旧字段 `assignedUserId`
## 前端 RC 发布门禁 ## 前端 RC 发布门禁
发布前必须依次执行并全部通过: 发布前必须依次执行并全部通过:
@ -143,15 +145,23 @@ npm run dev:h5
# 1. 安装依赖 # 1. 安装依赖
npm --prefix frontend install npm --prefix frontend install
# 2. H5 构建 # 2. 用权限受控的真实生产环境文件做门禁(不会打印变量值)
PETSTORE_FRONTEND_ENV_FILE=/secure/path/petstore-h5.env npm --prefix frontend run preflight:release
# 3. 让 Vite 使用同一份已通过门禁的配置并构建 H5
cp /secure/path/petstore-h5.env frontend/.env.production.local
npm --prefix frontend run build:h5 npm --prefix frontend run build:h5
# 期望DONE Build complete. # 期望DONE Build complete.
# 3. 微信小程序构建 # 4. 小程序配置单独门禁并构建
PETSTORE_FRONTEND_ENV_FILE=/secure/path/petstore-mp.env npm --prefix frontend run preflight:release
cp /secure/path/petstore-mp.env frontend/.env.mp-weixin.local
npm --prefix frontend run build:mp-weixin npm --prefix frontend run build:mp-weixin
# 期望DONE Build complete. + [verify-mp-app-json] OK # 期望DONE Build complete. + [verify-mp-app-json] OK
``` ```
仓库内 `.env.production` / `.env.mp-weixin` 只含 `.invalid` 安全占位符,会被门禁主动拒绝。真实域名文件不得提交;发布结束后删除临时 `.env.*.local`
### 前端 RC 检查项 ### 前端 RC 检查项
- [ ] `VITE_API_ORIGIN` 已配置(宠小它专用后端 API 域名;**勿再用** `api.s-good.com` - [ ] `VITE_API_ORIGIN` 已配置(宠小它专用后端 API 域名;**勿再用** `api.s-good.com`

View File

@ -4,9 +4,10 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev:mp-weixin": "uni -p mp-weixin", "dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin && node scripts/verify-mp-app-json.cjs", "build:mp-weixin": "uni build -p mp-weixin --mode mp-weixin && node scripts/verify-mp-app-json.cjs",
"dev:h5": "uni", "dev:h5": "uni",
"build:h5": "uni build" "build:h5": "uni build",
"preflight:release": "node scripts/release-preflight.cjs"
}, },
"dependencies": { "dependencies": {
"@dcloudio/uni-app": "3.0.0-4060620250520001", "@dcloudio/uni-app": "3.0.0-4060620250520001",

View File

@ -0,0 +1,59 @@
const fs = require('node:fs')
const path = require('node:path')
const envFile = process.env.PETSTORE_FRONTEND_ENV_FILE || '.env.production'
const absoluteEnvFile = path.resolve(process.cwd(), envFile)
function fail(message) {
console.error(`[release-preflight] FAIL: ${message}`)
process.exit(1)
}
if (!fs.existsSync(absoluteEnvFile)) {
fail('environment file does not exist')
}
const values = {}
for (const rawLine of fs.readFileSync(absoluteEnvFile, 'utf8').split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const separator = line.indexOf('=')
if (separator < 1) continue
const key = line.slice(0, separator).trim().replace(/^export\s+/, '')
const value = line.slice(separator + 1).trim().replace(/^(['"])(.*)\1$/, '$2')
values[key] = value
}
function requireProductionOrigin(name, forbiddenHosts) {
const value = values[name]
if (!value) fail(`${name} is required`)
let url
try {
url = new URL(value)
} catch {
fail(`${name} must be a valid URL`)
}
const hostname = url.hostname.toLowerCase()
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
const isPlaceholder = hostname.endsWith('.invalid')
|| hostname.endsWith('.test')
|| hostname.endsWith('.example')
|| ['example.com', 'example.net', 'example.org'].some(
(reserved) => hostname === reserved || hostname.endsWith(`.${reserved}`),
)
const hasExtraParts = Boolean(url.username || url.password || url.search || url.hash)
|| (url.pathname !== '' && url.pathname !== '/')
if (url.protocol !== 'https:' || isLocal || isPlaceholder || hasExtraParts) {
fail(`${name} must be an explicit production HTTPS origin`)
}
if (forbiddenHosts.includes(hostname)) {
fail(`${name} uses a domain reserved by another product`)
}
console.log(`[release-preflight] ${name}: PASS`)
}
requireProductionOrigin('VITE_API_ORIGIN', ['api.s-good.com'])
requireProductionOrigin('VITE_REPORT_PUBLIC_ORIGIN', ['www.s-good.com'])
console.log('[release-preflight] frontend release configuration PASS')

View File

@ -59,11 +59,21 @@ export const login = (phone, code) => post('/user/login', { phone, code })
export const wxPhoneLogin = (phoneCode, loginCode) => export const wxPhoneLogin = (phoneCode, loginCode) =>
post('/user/wx-phone-login', { phoneCode, loginCode: loginCode || '' }) post('/user/wx-phone-login', { phoneCode, loginCode: loginCode || '' })
// 注册老板 // 微信核验手机号后创建门店与老板账号(不接收明文密码或手填手机号)
export const registerBoss = (data) => post('/user/register-boss', data) export const registerBoss = (data) => post('/onboarding/register-boss', data)
// 注册员工 // 门店开通清单
export const registerStaff = (data) => post('/user/register-staff', data) export const getOnboardingStatus = () => get('/admin/onboarding', {})
export const completeOnboarding = () => post('/admin/onboarding/complete', {})
// 员工邀请:预览/接受为公开接口;创建/列表/撤销仅老板
export const previewStaffInvitation = (token) => get('/staff-invitations/preview', { token })
export const acceptStaffInvitation = (token, phoneCode, loginCode) =>
post('/staff-invitations/accept', { token, phoneCode, loginCode: loginCode || '' })
export const getStaffInvitations = () => get('/admin/staff-invitations', {})
export const createStaffInvitation = (data) => post('/admin/staff-invitations', data)
export const revokeStaffInvitation = (invitationId) =>
del(`/admin/staff-invitations?invitationId=${encodeURIComponent(invitationId)}`)
// 宠物档案(身份由后端从 session token 派生;保留旧签名兼容,不再传 ownerUserId/storeId 给后端) // 宠物档案(身份由后端从 session token 派生;保留旧签名兼容,不再传 ownerUserId/storeId 给后端)
export const getPetList = (/* params */) => get('/pet/list', {}) export const getPetList = (/* params */) => get('/pet/list', {})
@ -83,12 +93,12 @@ export const getAppointmentList = (/* userId, storeId, */ options = {}) =>
// 预约详情 // 预约详情
export const getAppointmentDetail = (id) => get('/appointment/detail', { id }) export const getAppointmentDetail = (id) => get('/appointment/detail', { id })
// 创建预约userId/storeId 由后端从上下文派生;前端仍传 storeId 供 customer 选店场景,后端会按 role 决定是否采纳) // 创建预约登录人身份始终由后端派生customer 传选中门店,门店代客预约传 customerUserId 或 customerPhone
export const createAppointment = (data) => post('/appointment/create', data) export const createAppointment = (data) => post('/appointment/create', data)
/** 门店某日可预约半小时档(可约 / 已满 / 已过);公开接口 */ /** 门店某日可预约开始时段;按服务时长与门店容量计算,公开接口。 */
export const getAppointmentAvailableSlots = (storeId, date) => export const getAppointmentAvailableSlots = (storeId, date, serviceType) =>
get('/appointment/available-slots', { storeId, date }) get('/appointment/available-slots', { storeId, date, serviceType })
// 开始服务staffUserId 由后端从上下文派生;保留旧签名兼容,不传 staffUserId // 开始服务staffUserId 由后端从上下文派生;保留旧签名兼容,不传 staffUserId
export const startAppointment = (appointmentId/*, staffUserId */) => export const startAppointment = (appointmentId/*, staffUserId */) =>
@ -100,6 +110,10 @@ export const cancelAppointment = (id) => put(`/appointment/status?id=${id}&statu
// 提交报告 // 提交报告
export const createReport = (data) => post('/report/create', data) export const createReport = (data) => post('/report/create', data)
/** 员工在真实发送完成后显式确认;复制链接、二维码和预览均不得自动调用。 */
export const confirmReportSent = (reportId, channel) =>
post('/report/confirm-sent', { reportId, channel })
/** 触发服务回顾短片生成异步。composeMode: preset | interleave身份由后端从上下文派生 */ /** 触发服务回顾短片生成异步。composeMode: preset | interleave身份由后端从上下文派生 */
export const postReportHighlightStart = (data) => post('/report/highlight/start', data) export const postReportHighlightStart = (data) => post('/report/highlight/start', data)
@ -109,7 +123,7 @@ export const postReportHighlightStart = (data) => post('/report/highlight/start'
*/ */
export const getReportByToken = (token) => get('/report/get', { token }) export const getReportByToken = (token) => get('/report/get', { token })
/** P0报告打开埋点服务端记日志可对接日志平台 */ /** 报告打开埋点(服务端落低敏 BusinessEvent失败不影响公开报告阅读 */
export const postReportOpenTrack = (token, visitType) => export const postReportOpenTrack = (token, visitType) =>
post('/report/open-track', { token, visitType: visitType || 'unknown' }) post('/report/open-track', { token, visitType: visitType || 'unknown' })
@ -147,8 +161,9 @@ export const getServiceTypeList = (storeId) => {
return get('/service-type/list', params) return get('/service-type/list', params)
} }
// 创建服务类型 // 创建服务类型storeId 仅保留旧签名兼容,后端从登录态派生)
export const createServiceType = (storeId, name) => post('/service-type/create', { storeId, name }) export const createServiceType = (storeId, name, durationMinutes) =>
post('/service-type/create', { storeId, name, durationMinutes })
// 删除服务类型 // 删除服务类型
export const deleteServiceType = (id) => del(`/service-type/delete?id=${id}`) export const deleteServiceType = (id) => del(`/service-type/delete?id=${id}`)
@ -156,9 +171,6 @@ export const deleteServiceType = (id) => del(`/service-type/delete?id=${id}`)
// 员工列表storeId 由后端从上下文派生;保留旧签名兼容) // 员工列表storeId 由后端从上下文派生;保留旧签名兼容)
export const getStaffList = (/* storeId */) => get('/user/staff-list', {}) 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}`) export const deleteStaff = (staffId) => del(`/user/staff?staffId=${staffId}`)

View File

@ -6,6 +6,10 @@
<text class="rsm-close" @click="emitClose"></text> <text class="rsm-close" @click="emitClose"></text>
</view> </view>
<text class="rsm-tip">复制下方链接或扫码打开也可点选话术后一键复制整段发给微信好友</text> <text class="rsm-tip">复制下方链接或扫码打开也可点选话术后一键复制整段发给微信好友</text>
<view class="rsm-send-note" :class="{ 'rsm-send-note--done': sent }">
<text v-if="sent">已确认发送门店工作台不会再提醒</text>
<text v-else>复制扫码或预览都不代表已经发送请在宠主实际收到后再确认</text>
</view>
<view class="rsm-block"> <view class="rsm-block">
<text class="rsm-label">报告链接</text> <text class="rsm-label">报告链接</text>
@ -50,6 +54,14 @@
<button class="rsm-btn rsm-btn--ghost" @click="emitClose">稍后</button> <button class="rsm-btn rsm-btn--ghost" @click="emitClose">稍后</button>
<button class="rsm-btn rsm-btn--primary" @click="previewReport">预览报告</button> <button class="rsm-btn rsm-btn--primary" @click="previewReport">预览报告</button>
</view> </view>
<button
class="rsm-btn rsm-confirm"
:class="{ 'rsm-confirm--done': sent }"
:disabled="sent || confirming"
@click="confirmAlreadySent"
>
{{ sent ? '已确认发送' : (confirming ? '确认中…' : '确认已发送给宠主') }}
</button>
</view> </view>
</view> </view>
</template> </template>
@ -62,6 +74,9 @@ import { buildReportH5FullUrl } from '../../utils/reportPublicUrl.js'
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
reportToken: { type: String, default: '' }, reportToken: { type: String, default: '' },
reportId: { type: [Number, String], default: '' },
sent: { type: Boolean, default: false },
confirming: { type: Boolean, default: false },
petName: { type: String, default: '' }, petName: { type: String, default: '' },
/** 门店名(话术变量) */ /** 门店名(话术变量) */
storeName: { type: String, default: '' }, storeName: { type: String, default: '' },
@ -69,7 +84,7 @@ const props = defineProps({
serviceType: { type: String, default: '' } serviceType: { type: String, default: '' }
}) })
const emit = defineEmits(['close', 'preview']) const emit = defineEmits(['close', 'preview', 'confirm-sent'])
const copiedUrl = ref(false) const copiedUrl = ref(false)
const copiedTplIdx = ref(-1) const copiedTplIdx = ref(-1)
@ -185,6 +200,19 @@ function onMask() {
function previewReport() { function previewReport() {
emit('preview') emit('preview')
} }
function confirmAlreadySent() {
if (props.sent || props.confirming || !props.reportId) return
const channels = ['wechat', 'qr', 'other']
uni.showActionSheet({
title: '选择实际发送方式',
itemList: ['微信消息', '当面扫码', '其他方式'],
success: ({ tapIndex }) => {
const channel = channels[Number(tapIndex)]
if (channel) emit('confirm-sent', channel)
}
})
}
</script> </script>
<style scoped> <style scoped>
@ -234,6 +262,21 @@ function previewReport() {
line-height: 1.5; line-height: 1.5;
margin-bottom: 14px; margin-bottom: 14px;
} }
.rsm-send-note {
font-size: 12px;
color: #92400e;
line-height: 1.5;
padding: 10px 12px;
margin: 0 0 14px;
border: 1px solid #fde68a;
border-radius: 10px;
background: #fffbeb;
}
.rsm-send-note--done {
color: #166534;
border-color: #bbf7d0;
background: #f0fdf4;
}
.rsm-block { .rsm-block {
margin-bottom: 16px; margin-bottom: 16px;
} }
@ -338,6 +381,16 @@ function previewReport() {
background: var(--c-slate-100); background: var(--c-slate-100);
color: var(--c-slate-600); color: var(--c-slate-600);
} }
.rsm-confirm {
width: 100%;
margin-top: 10px;
background: var(--c-brand-heading);
color: #fff;
}
.rsm-confirm--done {
background: #dcfce7;
color: #166534;
}
.rsm-btn[disabled] { .rsm-btn[disabled] {
opacity: 0.45; opacity: 0.45;
} }

View File

@ -6,6 +6,7 @@
{"path": "pages/appointment/CustAppointmentCreate"}, {"path": "pages/appointment/CustAppointmentCreate"},
{"path": "pages/report/Report"}, {"path": "pages/report/Report"},
{"path": "pages/mine/Mine"}, {"path": "pages/mine/Mine"},
{"path": "pages/mine/Onboarding"},
{"path": "pages/mine/Staff"}, {"path": "pages/mine/Staff"},
{"path": "pages/mine/ServiceType"}, {"path": "pages/mine/ServiceType"},
{"path": "pages/mine/Store"}, {"path": "pages/mine/Store"},

View File

@ -38,13 +38,17 @@
<text class="label">预约时间</text> <text class="label">预约时间</text>
<text class="value time">{{ timeDisplay }}</text> <text class="value time">{{ timeDisplay }}</text>
</view> </view>
<view v-if="detail.endTime" class="detail-row">
<text class="label">预计结束</text>
<text class="value time">{{ endTimeDisplay }} {{ detail.durationMinutes || 60 }} 分钟</text>
</view>
<view v-if="detail.remark" class="detail-row block"> <view v-if="detail.remark" class="detail-row block">
<text class="label">备注</text> <text class="label">备注</text>
<text class="value remark">{{ detail.remark }}</text> <text class="value remark">{{ detail.remark }}</text>
</view> </view>
<view v-if="detail.assignedUserId" class="detail-row"> <view v-if="detail.assignedStaffId || detail.assignedUserId" class="detail-row">
<text class="label">服务技师</text> <text class="label">服务技师</text>
<text class="value">#{{ detail.assignedUserId }}</text> <text class="value">#{{ detail.assignedStaffId || detail.assignedUserId }}</text>
</view> </view>
<view class="detail-meta"> <view class="detail-meta">
<text v-if="detail.id">预约编号 {{ detail.id }}</text> <text v-if="detail.id">预约编号 {{ detail.id }}</text>
@ -84,6 +88,7 @@ const navSafeStyle = (() => {
const statusText = computed(() => (detail.value ? getAppointmentStatusText(detail.value.status) : '')) const statusText = computed(() => (detail.value ? getAppointmentStatusText(detail.value.status) : ''))
const timeDisplay = computed(() => formatDateTimeCN(detail.value?.appointmentTime)) const timeDisplay = computed(() => formatDateTimeCN(detail.value?.appointmentTime))
const endTimeDisplay = computed(() => formatDateTimeCN(detail.value?.endTime))
const tagClass = (status) => getAppointmentTagClass(status) const tagClass = (status) => getAppointmentTagClass(status)

View File

@ -73,7 +73,7 @@
</view> </view>
<view class="c-field"> <view class="c-field">
<text class="app-form-label">服务类型</text> <text class="app-form-label">服务类型</text>
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value"> <picker mode="selector" :range="serviceTypes" range-key="label" @change="onServiceTypeChange">
<view class="app-form-input app-form-picker"> <view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text> <text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
<text class="app-form-arrow"></text> <text class="app-form-arrow"></text>
@ -330,8 +330,9 @@ const creatingAppt = ref(false)
/** 与日期选择器同步,避免无号源时丢失已选日期 */ /** 与日期选择器同步,避免无号源时丢失已选日期 */
const selectedDateStr = ref(ymdLocal()) const selectedDateStr = ref(ymdLocal())
const availableTimes = ref([]) const availableTimes = ref([])
const availableSlotEnds = ref({})
const loadingSlots = ref(false) const loadingSlots = ref(false)
const bookingWindowHint = ref('请选择半小时时间段;同一时段仅接待一单,已满或已过期不可选') const bookingWindowHint = ref('请选择服务后查看可预约开始时间;系统会按服务时长校验完整容量')
const normalizeText = (s) => (s || '').toString().trim().toLowerCase() const normalizeText = (s) => (s || '').toString().trim().toLowerCase()
@ -397,11 +398,13 @@ const slotPickerIndex = computed(() => {
/** 滚轮选项展示为「09:0009:30」 */ /** 滚轮选项展示为「09:0009:30」 */
const availableSlotLabels = computed(() => const availableSlotLabels = computed(() =>
availableTimes.value.map((t) => formatHalfHourSlotRange(t)) availableTimes.value.map((t) => `${t}${availableSlotEnds.value[t] || formatHalfHourSlotRange(t).slice(6)}`)
) )
const selectedSlotRangeLabel = computed(() => const selectedSlotRangeLabel = computed(() =>
appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : '' appointmentTime.value
? `${appointmentTime.value}${availableSlotEnds.value[appointmentTime.value] || formatHalfHourSlotRange(appointmentTime.value).slice(6)}`
: ''
) )
const showTryTomorrow = computed( const showTryTomorrow = computed(
@ -433,20 +436,24 @@ const loadAvailableSlots = async () => {
} }
loadingSlots.value = true loadingSlots.value = true
try { try {
const res = await getAppointmentAvailableSlots(storeId, date) const res = await getAppointmentAvailableSlots(storeId, date, newAppt.value.serviceType || undefined)
loadingSlots.value = false loadingSlots.value = false
if (res.code !== 200 || !res.data?.slots) { if (res.code !== 200 || !res.data?.slots) {
availableTimes.value = [] availableTimes.value = []
availableSlotEnds.value = {}
uni.showToast({ title: res.message || '号源加载失败', icon: 'none' }) uni.showToast({ title: res.message || '号源加载失败', icon: 'none' })
return return
} }
const ds = res.data.dayStart const ds = res.data.dayStart
const le = res.data.lastSlotStart const closing = res.data.closingTime
if (ds != null && le != null) { if (ds != null && closing != null) {
const a = String(ds).slice(0, 5) const a = String(ds).slice(0, 5)
const b = String(le).slice(0, 5) const b = String(closing).slice(0, 5)
bookingWindowHint.value = `本店可约 ${a}${b};以下为半小时时段,同一时段仅一单,已满或已过不可选` const mins = Number(res.data.durationMinutes || 60)
const capacity = Number(res.data.bookingCapacity || 1)
bookingWindowHint.value = `营业容量 ${a}${b};本服务约 ${mins} 分钟,门店并发接待 ${capacity}`
} }
availableSlotEnds.value = Object.fromEntries(res.data.slots.map((s) => [s.time, s.endTime]))
availableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time) availableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time)
if (!availableTimes.value.length) { if (!availableTimes.value.length) {
newAppt.value.appointmentTime = '' newAppt.value.appointmentTime = ''
@ -466,6 +473,7 @@ const loadAvailableSlots = async () => {
} catch (_) { } catch (_) {
loadingSlots.value = false loadingSlots.value = false
availableTimes.value = [] availableTimes.value = []
availableSlotEnds.value = {}
uni.showToast({ title: '号源加载失败', icon: 'none' }) uni.showToast({ title: '号源加载失败', icon: 'none' })
} }
} }
@ -494,12 +502,25 @@ const onAppointmentTimeOnlyChange = (e) => {
newAppt.value.appointmentTime = `${date}T${time}` newAppt.value.appointmentTime = `${date}T${time}`
} }
const onServiceTypeChange = (e) => {
const selected = serviceTypes.value[Number(e.detail.value)]
newAppt.value.serviceType = selected?.value || ''
loadAvailableSlots()
}
const loadServiceTypesForSelected = async () => { const loadServiceTypesForSelected = async () => {
const row = orderedStores.value[selectedStoreIndex.value] const row = orderedStores.value[selectedStoreIndex.value]
const sid = row?.id const sid = row?.id
const res = await getServiceTypeList(sid ?? undefined) const res = await getServiceTypeList(sid ?? undefined)
if (res.code === 200 && Array.isArray(res.data)) { if (res.code === 200 && Array.isArray(res.data)) {
serviceTypes.value = res.data.map((s) => ({ label: s.name, value: s.name })) serviceTypes.value = res.data.map((s) => ({
label: `${s.name} · 约${Number(s.durationMinutes || 60)}分钟`,
value: s.name,
durationMinutes: Number(s.durationMinutes || 60)
}))
if (newAppt.value.serviceType && !serviceTypes.value.some((s) => s.value === newAppt.value.serviceType)) {
newAppt.value.serviceType = ''
}
if (sid) { if (sid) {
try { try {
const last = uni.getStorageSync(lastServiceStorageKey(sid)) const last = uni.getStorageSync(lastServiceStorageKey(sid))
@ -593,7 +614,7 @@ const confirmNewAppt = async () => {
uni.showToast({ title: '宠物档案保存失败,请重试', icon: 'none' }) uni.showToast({ title: '宠物档案保存失败,请重试', icon: 'none' })
return return
} }
const payload = { ...a, storeId, userId: userInfo.id } const payload = { ...a, storeId }
payload.petId = ensuredPet.id payload.petId = ensuredPet.id
const res = await createAppointment(payload) const res = await createAppointment(payload)
creatingAppt.value = false creatingAppt.value = false

View File

@ -199,6 +199,20 @@
<text class="popup-close" @click="showNewAppt = false"></text> <text class="popup-close" @click="showNewAppt = false"></text>
</view> </view>
<view class="popup-body"> <view class="popup-body">
<view class="c-field">
<text class="app-form-label">宠主手机号</text>
<input
v-model="newAppt.customerPhone"
class="app-form-input"
type="number"
maxlength="11"
placeholder="用于关联宠主与服务报告"
/>
</view>
<view class="c-field">
<text class="app-form-label">宠主称呼可选</text>
<input v-model="newAppt.customerName" class="app-form-input" maxlength="64" placeholder="例如:小白妈妈" />
</view>
<view class="c-field"> <view class="c-field">
<text class="app-form-label">宠物名字</text> <text class="app-form-label">宠物名字</text>
<input v-model="newAppt.petName" class="app-form-input" placeholder="请输入" /> <input v-model="newAppt.petName" class="app-form-input" placeholder="请输入" />
@ -214,7 +228,7 @@
</view> </view>
<view class="c-field"> <view class="c-field">
<text class="app-form-label">服务类型</text> <text class="app-form-label">服务类型</text>
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value"> <picker mode="selector" :range="serviceTypes" range-key="label" @change="onStaffServiceTypeChange">
<view class="app-form-input app-form-picker"> <view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text> <text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
<text class="app-form-arrow"></text> <text class="app-form-arrow"></text>
@ -242,7 +256,7 @@
</view> </view>
</picker> </picker>
</view> </view>
<text class="c-store-hint home-slot-hint">每行半小时时段约满不可选</text> <text class="c-store-hint home-slot-hint">按服务时长与门店并发容量计算约满不可选</text>
</view> </view>
<view class="c-field"> <view class="c-field">
<text class="app-form-label">备注可选</text> <text class="app-form-label">备注可选</text>
@ -387,7 +401,16 @@ const petTypes = [
{ label: '其他', value: '其他' } { label: '其他', value: '其他' }
] ]
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' }) const emptyStaffAppointment = () => ({
customerPhone: '',
customerName: '',
petName: '',
petType: '',
serviceType: '',
appointmentTime: '',
remark: ''
})
const newAppt = ref(emptyStaffAppointment())
const appointmentTime = computed(() => { const appointmentTime = computed(() => {
const raw = newAppt.value.appointmentTime || '' const raw = newAppt.value.appointmentTime || ''
if (!raw.includes('T')) return '' if (!raw.includes('T')) return ''
@ -398,6 +421,7 @@ const appointmentTime = computed(() => {
/** B 端弹窗:按半小时档选可约时段 */ /** B 端弹窗:按半小时档选可约时段 */
const staffApptDateStr = ref('') const staffApptDateStr = ref('')
const staffAvailableTimes = ref([]) const staffAvailableTimes = ref([])
const staffAvailableSlotEnds = ref({})
const staffLoadingSlots = ref(false) const staffLoadingSlots = ref(false)
const staffSlotPickerIndex = computed(() => { const staffSlotPickerIndex = computed(() => {
@ -407,11 +431,13 @@ const staffSlotPickerIndex = computed(() => {
}) })
const staffAvailableSlotLabels = computed(() => const staffAvailableSlotLabels = computed(() =>
staffAvailableTimes.value.map((t) => formatHalfHourSlotRange(t)) staffAvailableTimes.value.map((t) => `${t}${staffAvailableSlotEnds.value[t] || formatHalfHourSlotRange(t).slice(6)}`)
) )
const staffSelectedSlotRangeLabel = computed(() => const staffSelectedSlotRangeLabel = computed(() =>
appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : '' appointmentTime.value
? `${appointmentTime.value}${staffAvailableSlotEnds.value[appointmentTime.value] || formatHalfHourSlotRange(appointmentTime.value).slice(6)}`
: ''
) )
const filteredOrders = computed(() => orders.value.filter(o => { const filteredOrders = computed(() => orders.value.filter(o => {
@ -502,12 +528,14 @@ const loadStaffAvailableSlots = async () => {
staffApptDateStr.value = date staffApptDateStr.value = date
staffLoadingSlots.value = true staffLoadingSlots.value = true
try { try {
const res = await getAppointmentAvailableSlots(storeInfo.id, date) const res = await getAppointmentAvailableSlots(storeInfo.id, date, newAppt.value.serviceType || undefined)
staffLoadingSlots.value = false staffLoadingSlots.value = false
if (res.code !== 200 || !res.data?.slots) { if (res.code !== 200 || !res.data?.slots) {
staffAvailableTimes.value = [] staffAvailableTimes.value = []
staffAvailableSlotEnds.value = {}
return return
} }
staffAvailableSlotEnds.value = Object.fromEntries(res.data.slots.map((s) => [s.time, s.endTime]))
staffAvailableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time) staffAvailableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time)
if (!staffAvailableTimes.value.length) { if (!staffAvailableTimes.value.length) {
newAppt.value.appointmentTime = '' newAppt.value.appointmentTime = ''
@ -527,6 +555,7 @@ const loadStaffAvailableSlots = async () => {
} catch (_) { } catch (_) {
staffLoadingSlots.value = false staffLoadingSlots.value = false
staffAvailableTimes.value = [] staffAvailableTimes.value = []
staffAvailableSlotEnds.value = {}
} }
} }
@ -536,6 +565,12 @@ const openStaffNewApptDialog = async () => {
await loadStaffAvailableSlots() await loadStaffAvailableSlots()
} }
const onStaffServiceTypeChange = (e) => {
const selected = serviceTypes.value[Number(e.detail.value)]
newAppt.value.serviceType = selected?.value || ''
loadStaffAvailableSlots()
}
const onStaffApptDateChange = (e) => { const onStaffApptDateChange = (e) => {
const date = e?.detail?.value || '' const date = e?.detail?.value || ''
if (!date) { if (!date) {
@ -631,7 +666,11 @@ const loadServiceTypes = async () => {
if (!storeInfo.id) return if (!storeInfo.id) return
const res = await getServiceTypeList(storeInfo.id) const res = await getServiceTypeList(storeInfo.id)
if (res.code === 200) { if (res.code === 200) {
serviceTypes.value = res.data.map(s => ({ label: s.name, value: s.name })) serviceTypes.value = res.data.map(s => ({
label: `${s.name} · 约${Number(s.durationMinutes || 60)}分钟`,
value: s.name,
durationMinutes: Number(s.durationMinutes || 60)
}))
} }
} }
@ -671,6 +710,10 @@ const goReport = (item) => {
const confirmNewAppt = async () => { const confirmNewAppt = async () => {
const a = newAppt.value const a = newAppt.value
if (!/^1[3-9]\d{9}$/.test(a.customerPhone || '')) {
uni.showToast({ title: '请输入正确的宠主手机号', icon: 'none' })
return
}
if (!a.petName) { uni.showToast({ title: '请输入宠物名字', icon: 'none' }); return } if (!a.petName) { uni.showToast({ title: '请输入宠物名字', icon: 'none' }); return }
if (!a.petType) { uni.showToast({ title: '请选择宠物类型', icon: 'none' }); return } if (!a.petType) { uni.showToast({ title: '请选择宠物类型', icon: 'none' }); return }
if (!a.serviceType) { uni.showToast({ title: '请选择服务类型', icon: 'none' }); return } if (!a.serviceType) { uni.showToast({ title: '请选择服务类型', icon: 'none' }); return }
@ -681,11 +724,11 @@ const confirmNewAppt = async () => {
return return
} }
creatingAppt.value = true creatingAppt.value = true
const res = await createAppointment({ ...a, storeId: storeInfo.id, userId: userInfo.id }) const res = await createAppointment({ ...a, storeId: storeInfo.id })
creatingAppt.value = false creatingAppt.value = false
if (res.code === 200) { if (res.code === 200) {
uni.showToast({ title: '预约成功', icon: 'success' }) uni.showToast({ title: '预约成功', icon: 'success' })
newAppt.value = { petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' } newAppt.value = emptyStaffAppointment()
showNewAppt.value = false showNewAppt.value = false
fetchAppointments() fetchAppointments()
} else { } else {

View File

@ -5,610 +5,359 @@
<text class="brand-sub">用心宠小它温暖伴一生</text> <text class="brand-sub">用心宠小它温暖伴一生</text>
</view> </view>
<!-- 登录 -->
<view v-if="view === 'login'" class="card"> <view v-if="view === 'login'" class="card">
<text class="card-title">登录</text> <text class="card-title">登录</text>
<input v-model="loginForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" /> <input v-model="loginForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" />
<view class="sms-row"> <view class="sms-row">
<input v-model="loginForm.code" type="digit" class="field sms-input" placeholder="验证码" maxlength="6" /> <input v-model="loginForm.code" type="digit" class="field sms-input" placeholder="验证码" maxlength="6" />
<button class="app-btn-code app-btn-code--neutral sms-code-btn" :disabled="smsCountdown > 0" @click="handleSendSms">{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}</button> <button class="app-btn-code app-btn-code--neutral sms-code-btn" :disabled="smsCountdown > 0" @click="handleSendSms">
{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}
</button>
</view> </view>
<button class="app-btn-main app-btn-primary login-main-btn" :disabled="loginLoading" @click="handleLogin">{{ loginLoading ? '登录中' : '登录' }}</button> <button class="app-btn-main app-btn-primary login-main-btn" :disabled="loading" @click="handleLogin">
{{ loading ? '登录中…' : '验证码登录' }}
</button>
<view class="divider"> <view class="divider"><view class="divider-line"/><text class="divider-text"></text><view class="divider-line"/></view>
<view class="divider-line"></view>
<text class="divider-text"></text>
<view class="divider-line"></view>
</view>
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
<button <button class="app-btn-main app-btn-soft" open-type="getPhoneNumber" :disabled="loading" @getphonenumber="onWxLogin">
class="app-btn-main app-btn-soft" {{ loading ? '登录中…' : '微信手机号登录' }}
open-type="getPhoneNumber" </button>
:disabled="loginLoading"
@getphonenumber="onWxGetPhoneNumber"
>{{ loginLoading ? '登录中…' : '微信授权登录' }}</button>
<!-- #endif --> <!-- #endif -->
<!-- #ifndef MP-WEIXIN --> <!-- #ifndef MP-WEIXIN -->
<button class="app-btn-main app-btn-soft" @click="onWechatLoginNotInMp">微信授权登录</button> <button class="app-btn-main app-btn-soft" @click="wechatOnly">微信手机号登录</button>
<!-- #endif --> <!-- #endif -->
<view class="footer-links"> <view class="footer-links">
<text class="link-btn" @click="view = 'staff-reg'">员工注册</text> <text class="link-btn" @click="openStaffInvite">员工受邀加入</text>
<view class="link-dot"></view> <view class="link-dot"/>
<text class="link-btn" @click="view = 'boss-reg'">商家入驻</text> <text class="link-btn" @click="view = 'boss-reg'">商家入驻</text>
</view> </view>
</view> </view>
<!-- 商家入驻 -->
<view v-else-if="view === 'boss-reg'" class="card"> <view v-else-if="view === 'boss-reg'" class="card">
<text class="card-title">商家入驻</text> <text class="card-title">创建门店</text>
<input v-model="bossForm.storeName" class="field" placeholder="店铺名称" /> <view class="hint-box">手机号必须由微信核验创建后继续补全门店资料预约容量和服务项目</view>
<input v-model="bossForm.bossName" class="field" placeholder="您的姓名" /> <input v-model="bossForm.storeName" class="field" placeholder="门店名称" maxlength="64" />
<input v-model="bossForm.bossName" class="field" placeholder="老板姓名" maxlength="64" />
<!-- #ifdef MP-WEIXIN --> <!-- #ifdef MP-WEIXIN -->
<view class="phone-row"> <button
<input v-model="bossForm.phone" type="tel" class="field phone-input" placeholder="手机号" maxlength="11" /> class="app-btn-main app-btn-primary login-main-btn"
<button class="app-btn-code app-btn-code--brand phone-auth-btn" open-type="getPhoneNumber" @getphonenumber="onWxGetPhoneNumberBoss">授权手机号</button> open-type="getPhoneNumber"
</view> :disabled="loading"
@getphonenumber="onRegisterBoss"
>{{ loading ? '创建中…' : '微信核验手机号并创建' }}</button>
<!-- #endif --> <!-- #endif -->
<!-- #ifndef MP-WEIXIN --> <!-- #ifndef MP-WEIXIN -->
<input v-model="bossForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" /> <button class="app-btn-main app-btn-primary login-main-btn" @click="wechatOnly">请在微信小程序完成入驻</button>
<!-- #endif --> <!-- #endif -->
<!-- 手机号手动填写时需要验证码 --> <text class="back-link" @click="backToLogin"> 返回登录</text>
<view v-if="!bossForm.wxPhoneAuto" class="sms-row">
<input v-model="bossForm.smsCode" type="digit" class="field sms-input" placeholder="验证码" maxlength="6" />
<button class="app-btn-code app-btn-code--neutral sms-code-btn" :disabled="bossSmsCountdown > 0" @click="handleSendSmsBoss">{{ bossSmsCountdown > 0 ? bossSmsCountdown + 's' : '获取验证码' }}</button>
</view>
<view v-else class="wx-verified-hint"> 微信已认证手机号</view>
<input v-model="bossForm.password" type="password" class="field" placeholder="登录密码至少6位" />
<button class="app-btn-main app-btn-primary login-main-btn" :disabled="regLoading" @click="handleRegisterBoss">{{ regLoading ? '提交中' : '提交申请' }}</button>
<text class="back-link" @click="backToLoginForm"> 返回登录</text>
</view> </view>
<!-- 员工注册 --> <view v-else class="card invitation-card">
<view v-else-if="view === 'staff-reg'" class="card"> <text class="card-title">接受员工邀请</text>
<text class="card-title">员工注册</text> <view v-if="!invitation" class="hint-box">粘贴老板发来的邀请口令或直接从邀请卡片进入</view>
<view class="hint-box">请输入店长提供的邀请码加入团队</view> <view v-if="!invitation" class="token-row">
<input v-model="staffForm.inviteCode" class="field" placeholder="8位邀请码" maxlength="8" /> <input v-model="inviteToken" class="field token-input" placeholder="邀请口令" maxlength="512" />
<input v-model="staffForm.name" class="field" placeholder="您的姓名" /> <button class="app-btn-code app-btn-code--brand" :disabled="previewLoading" @click="loadInvitation">
<!-- #ifdef MP-WEIXIN --> {{ previewLoading ? '查询中' : '查询' }}
<view class="phone-row"> </button>
<input v-model="staffForm.phone" type="tel" class="field phone-input" placeholder="手机号" maxlength="11" />
<button class="app-btn-code app-btn-code--brand phone-auth-btn" open-type="getPhoneNumber" @getphonenumber="onWxGetPhoneNumberStaff">授权手机号</button>
</view> </view>
<view v-if="invitation" class="invitation-preview">
<text class="preview-kicker">受邀门店</text>
<text class="preview-store">{{ invitation.storeName }}</text>
<view class="preview-row"><text>受邀员工</text><text>{{ invitation.invitedName }}</text></view>
<view class="preview-row"><text>核验手机号</text><text>{{ invitation.maskedPhone }}</text></view>
<view class="preview-row"><text>有效期至</text><text>{{ formatDate(invitation.expiresAt) }}</text></view>
<view class="status-pill" :class="`status-${invitation.status}`">{{ invitationStatus }}</view>
</view>
<!-- #ifdef MP-WEIXIN -->
<button
v-if="invitation?.canAccept"
class="app-btn-main app-btn-primary login-main-btn"
open-type="getPhoneNumber"
:disabled="loading"
@getphonenumber="onAcceptInvitation"
>{{ loading ? '核验中…' : invitation.status === 'accepted' ? '微信核验手机号并登录' : '微信核验手机号并加入' }}</button>
<!-- #endif --> <!-- #endif -->
<!-- #ifndef MP-WEIXIN --> <!-- #ifndef MP-WEIXIN -->
<input v-model="staffForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" /> <view v-if="invitation?.canAccept" class="non-mp-notice">请在微信小程序内完成手机号核验与加入</view>
<!-- #endif --> <!-- #endif -->
<!-- 手机号手动填写时需要验证码 --> <button v-if="invitation" class="app-btn-main app-btn-soft secondary-btn" @click="resetInvitation">更换邀请口令</button>
<view v-if="!staffForm.wxPhoneAuto" class="sms-row"> <text class="back-link" @click="backToLogin"> 返回登录</text>
<input v-model="staffForm.smsCode" type="digit" class="field sms-input" placeholder="验证码" maxlength="6" />
<button class="app-btn-code app-btn-code--neutral sms-code-btn" :disabled="staffSmsCountdown > 0" @click="handleSendSmsStaff">{{ staffSmsCountdown > 0 ? staffSmsCountdown + 's' : '获取验证码' }}</button>
</view>
<view v-else class="wx-verified-hint"> 微信已认证手机号</view>
<input v-model="staffForm.password" type="password" class="field" placeholder="登录密码至少6位" />
<button class="app-btn-main app-btn-primary login-main-btn" :disabled="regLoading" @click="handleRegisterStaff">{{ regLoading ? '提交中' : '注册' }}</button>
<text class="back-link" @click="backToLoginForm"> 返回登录</text>
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, reactive, onUnmounted } from 'vue' import { computed, onUnmounted, reactive, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { sendSms, login, wxPhoneLogin, registerBoss, registerStaff } from '../../api/index.js' import {
import { setStoreSession, setUserSession, setSessionToken } from '../../utils/session.js' acceptStaffInvitation,
login,
previewStaffInvitation,
registerBoss,
sendSms,
wxPhoneLogin
} from '../../api/index.js'
import { setSessionToken, setStoreSession, setUserSession } from '../../utils/session.js'
import { openAppPage } from '../../utils/globalState.js' import { openAppPage } from '../../utils/globalState.js'
const view = ref('login') const view = ref('login')
const loginForm = reactive({ phone: '13800138001', code: '123456' }) const loginForm = reactive({ phone: '', code: '' })
const bossForm = reactive({ storeName: '', bossName: '', phone: '', password: '', smsCode: '', wxPhoneAuto: false }) const bossForm = reactive({ storeName: '', bossName: '' })
const staffForm = reactive({ inviteCode: '', name: '', phone: '', password: '', smsCode: '', wxPhoneAuto: false }) const loading = ref(false)
const loginLoading = ref(false) const previewLoading = ref(false)
const regLoading = ref(false)
const smsCountdown = ref(0) const smsCountdown = ref(0)
const bossSmsCountdown = ref(0) const inviteToken = ref('')
const staffSmsCountdown = ref(0) const invitation = ref(null)
let bossSmsTimer = null const redirectAfterLogin = ref('')
let staffSmsTimer = null
let smsTimer = null let smsTimer = null
const parseBossRegData = (data) => { const showToast = (title) => uni.showToast({ title, icon: 'none' })
if (!data || typeof data !== 'object') return null const isOk = (response) => response && Number(response.code) === 200
const st = data.store
const u = data.user
if (!st || typeof st !== 'object' || Array.isArray(st)) return null
if (!u || typeof u !== 'object' || Array.isArray(u)) return null
return {
storeName: uString(st.name),
inviteCode: uString(st.inviteCode),
phone: uString(u.phone),
password: uString(u.password)
}
}
const parseStaffRegData = (data) => { const invitationStatus = computed(() => ({
if (!data || typeof data !== 'object') return null pending: '邀请待接受',
const st = data.store accepted: '已加入,可核验本人手机号登录',
const u = data.user revoked: '邀请已撤销',
if (!st || typeof st !== 'object' || Array.isArray(st)) return null expired: '邀请已过期'
if (!u || typeof u !== 'object' || Array.isArray(u)) return null }[invitation.value?.status] || '状态未知'))
return {
storeName: uString(st.name),
phone: uString(u.phone)
}
}
const uString = (v) => { function parseOption(value) {
if (v == null) return '' if (value == null) return ''
if (typeof v === 'boolean') return ''
if (typeof v === 'string' || typeof v === 'number') return String(v)
return ''
}
const safeLine = (s, fallback = '—') => {
const t = (s == null ? '' : String(s)).trim()
return t || fallback
}
const isHttpOk = (res) => res && (res.code === 200 || res.code === '200')
function parseRedirectParam(raw) {
if (raw == null || raw === '') return ''
let s = String(raw)
try { try {
s = decodeURIComponent(s) return decodeURIComponent(String(value)).trim()
if (/%[0-9A-Fa-f]{2}/.test(s)) s = decodeURIComponent(s)
} catch (_) { } catch (_) {
return '' return ''
} }
return s
} }
function isSafeLoginRedirect(path) { function safeRedirect(path) {
return ( return typeof path === 'string' && path.startsWith('/pages/') && !path.includes('..') && !path.includes('//')
typeof path === 'string' &&
path.startsWith('/pages/') &&
!path.includes('..') &&
!path.includes('//')
)
} }
const redirectAfterLogin = ref('') onLoad((options) => {
redirectAfterLogin.value = parseOption(options?.redirect)
const token = parseOption(options?.staffInvite || options?.token)
if (token) {
inviteToken.value = token
view.value = 'staff-invite'
loadInvitation()
}
})
function navigateAfterLogin() { const fetchWxLoginCode = () => new Promise((resolve) => {
const r = redirectAfterLogin.value // #ifdef MP-WEIXIN
if (isSafeLoginRedirect(r)) { uni.login({ success: (result) => resolve(result?.code || ''), fail: () => resolve('') })
uni.reLaunch({ url: r }) // #endif
// #ifndef MP-WEIXIN
resolve('')
// #endif
})
function saveSession(data) {
if (!data?.user || !data?.sessionToken) return false
setUserSession(data.user)
setStoreSession(data.store || {})
setSessionToken(data.sessionToken)
return true
}
function goAfterLogin() {
if (safeRedirect(redirectAfterLogin.value)) {
uni.reLaunch({ url: redirectAfterLogin.value })
} else { } else {
openAppPage('home') openAppPage('home')
} }
} }
onLoad((options) => { async function handleSendSms() {
view.value = 'login' if (!/^1[3-9]\d{9}$/.test(loginForm.phone)) return showToast('请输入正确手机号')
const raw = options && (options.phone ?? options.prefillPhone) const response = await sendSms(loginForm.phone)
const p = raw == null ? '' : String(raw).trim() if (!isOk(response)) return showToast(response.message || '验证码发送失败')
if (/^1[3-9]\d{9}$/.test(p)) {
loginForm.phone = p
}
const red = options && options.redirect != null ? options.redirect : ''
redirectAfterLogin.value = parseRedirectParam(red)
})
const backToLoginForm = () => { view.value = 'login' }
const showToast = (msg) => uni.showToast({ title: msg, icon: 'none' })
/** 微信小程序 wx.login用于服务端 jscode2session 换 openid与手机号授权 code 无关) */
const fetchWxLoginCode = () =>
new Promise((resolve) => {
// #ifdef MP-WEIXIN
uni.login({
success: (res) => resolve((res && res.code) || ''),
fail: () => resolve('')
})
// #endif
// #ifndef MP-WEIXIN
resolve('')
// #endif
})
const handleSendSms = async () => {
if (!loginForm.phone || loginForm.phone.length !== 11) return showToast('请输入正确的手机号')
const res = await sendSms(loginForm.phone)
if (res.code === 200) {
showToast('验证码已发送') showToast('验证码已发送')
smsCountdown.value = 60 smsCountdown.value = 60
smsTimer = setInterval(() => { smsTimer = setInterval(() => {
smsCountdown.value-- smsCountdown.value -= 1
if (smsCountdown.value <= 0) clearInterval(smsTimer) if (smsCountdown.value <= 0 && smsTimer) clearInterval(smsTimer)
}, 1000) }, 1000)
} else {
showToast(res.message || '发送失败')
}
} }
const handleSendSmsBoss = async () => { async function handleLogin() {
if (!bossForm.phone || bossForm.phone.length !== 11) return showToast('请输入正确的手机号') if (!/^1[3-9]\d{9}$/.test(loginForm.phone) || loginForm.code.length !== 6) {
const res = await sendSms(bossForm.phone) return showToast('请填写手机号和 6 位验证码')
if (res.code === 200) {
showToast('验证码已发送')
bossSmsCountdown.value = 60
bossSmsTimer = setInterval(() => {
bossSmsCountdown.value--
if (bossSmsCountdown.value <= 0) clearInterval(bossSmsTimer)
}, 1000)
} else {
showToast(res.message || '发送失败')
} }
} loading.value = true
const handleSendSmsStaff = async () => {
if (!staffForm.phone || staffForm.phone.length !== 11) return showToast('请输入正确的手机号')
const res = await sendSms(staffForm.phone)
if (res.code === 200) {
showToast('验证码已发送')
staffSmsCountdown.value = 60
staffSmsTimer = setInterval(() => {
staffSmsCountdown.value--
if (staffSmsCountdown.value <= 0) clearInterval(staffSmsTimer)
}, 1000)
} else {
showToast(res.message || '发送失败')
}
}
const handleLogin = async () => {
if (!loginForm.phone || loginForm.phone.length !== 11) return showToast('请输入正确的手机号')
if (!loginForm.code || loginForm.code.length !== 6) return showToast('请输入6位验证码')
loginLoading.value = true
try { try {
const res = await login(loginForm.phone, loginForm.code) const response = await login(loginForm.phone, loginForm.code)
if (res.code === 200) { if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '登录失败')
setUserSession(res.data.user) goAfterLogin()
setStoreSession(res.data.store) } catch (_) {
if (res.data.sessionToken) setSessionToken(res.data.sessionToken) showToast('网络异常,请稍后重试')
navigateAfterLogin()
} else {
showToast(res.message || '登录失败')
}
} catch (e) {
showToast('网络异常,请检查服务是否启动')
} finally { } finally {
loginLoading.value = false loading.value = false
} }
} }
/** 非微信小程序:无法调起手机号授权 */ function validPhoneEvent(event) {
const onWechatLoginNotInMp = () => { const detail = event?.detail || {}
showToast('微信授权登录仅支持在微信小程序内使用,请使用验证码登录') const error = String(detail.errMsg || '')
if (error.includes('deny') || error.includes('cancel')) {
showToast('需要授权本人手机号才能继续')
return ''
}
if (!detail.code) showToast('未获取到手机号授权码,请重试')
return detail.code || ''
} }
const getWxLoginPhone = (res) => { async function onWxLogin(event) {
if (!isHttpOk(res)) return '' const phoneCode = validPhoneEvent(event)
return uString(res?.data?.user?.phone || res?.data?.phone) if (!phoneCode) return
} loading.value = true
/**
* 微信小程序用户同意授权后携带 code服务端换手机号并登录
* @see https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
*/
const onWxGetPhoneNumber = async (e) => {
const d = e.detail || {}
const errMsg = d.errMsg || ''
if (errMsg.includes('deny') || errMsg.includes('cancel')) {
showToast('需要授权手机号才能完成登录')
return
}
const code = d.code
if (!code) {
showToast('未获取到授权,请使用验证码登录')
return
}
loginLoading.value = true
try { try {
const loginCode = await fetchWxLoginCode() const loginCode = await fetchWxLoginCode()
const res = await wxPhoneLogin(code, loginCode) const response = await wxPhoneLogin(phoneCode, loginCode)
if (res.code === 200) { if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '登录失败')
setUserSession(res.data.user) goAfterLogin()
setStoreSession(res.data.store) } catch (_) {
if (res.data.sessionToken) setSessionToken(res.data.sessionToken) showToast('网络异常,请稍后重试')
navigateAfterLogin()
} else {
showToast(res.message || '登录失败')
}
} catch (err) {
showToast('网络异常,请检查服务是否启动')
} finally { } finally {
loginLoading.value = false loading.value = false
} }
} }
/** 商家入驻:微信获取手机号并填入 */ async function onRegisterBoss(event) {
const onWxGetPhoneNumberBoss = async (e) => { if (!bossForm.storeName.trim()) return showToast('请填写门店名称')
const d = e.detail || {} if (!bossForm.bossName.trim()) return showToast('请填写老板姓名')
const errMsg = d.errMsg || '' const phoneCode = validPhoneEvent(event)
if (errMsg.includes('deny') || errMsg.includes('cancel')) return if (!phoneCode) return
const code = d.code loading.value = true
if (!code) { showToast('未获取到授权,请手动输入'); return }
loginLoading.value = true
try { try {
const loginCode = await fetchWxLoginCode() const loginCode = await fetchWxLoginCode()
const res = await wxPhoneLogin(code, loginCode) const response = await registerBoss({
const phone = getWxLoginPhone(res) storeName: bossForm.storeName.trim(),
if (phone) { bossName: bossForm.bossName.trim(),
bossForm.phone = phone phoneCode,
bossForm.wxPhoneAuto = true loginCode
showToast('手机号已获取')
} else {
showToast('未获取到手机号,请手动输入')
}
} catch {
showToast('网络异常')
} finally {
loginLoading.value = false
}
}
/** 员工注册:微信获取手机号并填入 */
const onWxGetPhoneNumberStaff = async (e) => {
const d = e.detail || {}
const errMsg = d.errMsg || ''
if (errMsg.includes('deny') || errMsg.includes('cancel')) return
const code = d.code
if (!code) { showToast('未获取到授权,请手动输入'); return }
loginLoading.value = true
try {
const loginCode = await fetchWxLoginCode()
const res = await wxPhoneLogin(code, loginCode)
const phone = getWxLoginPhone(res)
if (phone) {
staffForm.phone = phone
staffForm.wxPhoneAuto = true
showToast('手机号已获取')
} else {
showToast('未获取到手机号,请手动输入')
}
} catch {
showToast('网络异常')
} finally {
loginLoading.value = false
}
}
const handleRegisterBoss = async () => {
const f = bossForm
if (!f.storeName) return showToast('请输入店铺名称')
if (!f.bossName) return showToast('请输入您的姓名')
if (!f.phone || f.phone.length !== 11) return showToast('请输入正确的手机号')
if (!f.wxPhoneAuto && (!f.smsCode || f.smsCode.length !== 6)) return showToast('请输入6位验证码')
if (!f.password || f.password.length < 6) return showToast('密码至少6位')
const payload = { ...f }
if (f.wxPhoneAuto) delete payload.smsCode
regLoading.value = true
const res = await registerBoss(payload)
regLoading.value = false
if (isHttpOk(res)) {
const parsed = parseBossRegData(res.data)
if (parsed) {
const n = safeLine(parsed.storeName, '—')
const p = safeLine(parsed.phone, '—')
const pw = safeLine(parsed.password, '—')
const code = safeLine(parsed.inviteCode, '—')
const content = `店铺:${n}\n账号${p}\n密码${pw}\n邀请码${code}\n\n请妥善保存邀请码。`
uni.showModal({
title: '入驻成功',
content,
cancelText: '前往登录',
confirmText: '复制邀请码',
success: (r) => {
if (r.confirm && code && code !== '—') {
uni.setClipboardData({ data: code, success: () => uni.showToast({ title: '已复制', icon: 'none' }) })
}
backToLoginForm()
}
}) })
} else { if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '门店创建失败')
showToast('注册成功,请用手机号登录')
}
} else {
showToast(res.message || '注册失败')
}
}
const handleRegisterStaff = async () => {
const f = staffForm
if (!f.inviteCode || f.inviteCode.length !== 8) return showToast('请输入8位邀请码')
if (!f.name) return showToast('请输入您的姓名')
if (!f.phone || f.phone.length !== 11) return showToast('请输入正确的手机号')
if (!f.wxPhoneAuto && (!f.smsCode || f.smsCode.length !== 6)) return showToast('请输入6位验证码')
if (!f.password || f.password.length < 6) return showToast('密码至少6位')
const payload = { ...f }
if (f.wxPhoneAuto) delete payload.smsCode
regLoading.value = true
const res = await registerStaff(payload)
regLoading.value = false
if (isHttpOk(res)) {
const parsed = parseStaffRegData(res.data)
if (parsed) {
const shop = safeLine(parsed.storeName, '未知店铺')
const phone = safeLine(parsed.phone, '—')
uni.showModal({ uni.showModal({
title: '注册成功', title: '门店已创建',
content: `已加入「${shop}\n账号${phone}`, content: '接下来补全门店资料、确认预约容量和服务项目,即可完成开通。',
showCancel: false, showCancel: false,
confirmText: '前往登录', confirmText: '继续开通',
complete: () => backToLoginForm() success: () => uni.reLaunch({ url: '/pages/mine/Onboarding' })
}) })
} else { } catch (_) {
showToast('注册成功,请用手机号登录') showToast('网络异常,请稍后重试')
} finally {
loading.value = false
} }
} else { }
showToast(res.message || '注册失败')
function openStaffInvite() {
view.value = 'staff-invite'
}
async function loadInvitation() {
const token = inviteToken.value.trim()
if (!token) return showToast('请粘贴邀请口令')
previewLoading.value = true
try {
const response = await previewStaffInvitation(token)
if (!isOk(response)) {
invitation.value = null
return showToast(response.message || '邀请不存在或已失效')
} }
invitation.value = response.data
} catch (_) {
showToast('邀请查询失败,请检查网络')
} finally {
previewLoading.value = false
}
}
async function onAcceptInvitation(event) {
const phoneCode = validPhoneEvent(event)
if (!phoneCode || !invitation.value?.canAccept) return
loading.value = true
try {
const loginCode = await fetchWxLoginCode()
const response = await acceptStaffInvitation(inviteToken.value.trim(), phoneCode, loginCode)
if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '加入门店失败')
uni.showModal({
title: '已加入门店',
content: `你已加入「${invitation.value.storeName}」,现在可以开始处理门店工作。`,
showCancel: false,
confirmText: '进入工作台',
success: () => openAppPage('home')
})
} catch (_) {
showToast('网络异常,请稍后重试')
} finally {
loading.value = false
}
}
function resetInvitation() {
invitation.value = null
inviteToken.value = ''
}
function backToLogin() {
view.value = 'login'
}
function wechatOnly() {
showToast('请在微信小程序内完成此操作')
}
function formatDate(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value).replace('T', ' ')
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
} }
onUnmounted(() => { onUnmounted(() => {
if (smsTimer) clearInterval(smsTimer) if (smsTimer) clearInterval(smsTimer)
if (bossSmsTimer) clearInterval(bossSmsTimer)
if (staffSmsTimer) clearInterval(staffSmsTimer)
}) })
</script> </script>
<style scoped> <style scoped>
.login-page { .login-page { min-height: 100vh; padding: 0 24px 40px; background: #fafaf8; box-sizing: border-box; }
min-height: 100vh; .login-brand { padding: 76px 0 36px; text-align: center; }
background: #fafaf8; .brand-name { display: block; color: #1a1a1a; font-size: 28px; font-weight: 800; letter-spacing: 1px; }
display: flex; .brand-sub { display: block; margin-top: 6px; color: var(--c-text-3); font-size: 13px; }
flex-direction: column; .card { padding: 28px 24px; background: #fff; border-radius: 20px; box-shadow: 0 2px 12px rgba(0,0,0,.04); }
padding: 0 24px; .card-title { display: block; margin-bottom: 20px; color: #1a1a1a; font-size: 20px; font-weight: 700; }
} .field { display: block; width: 100%; height: 48px; min-height: 48px; margin-bottom: 12px; padding: 0 14px; color: #1a1a1a; font-size: 16px; line-height: 48px; background: #f5f5f2; border: 1.5px solid transparent; border-radius: 12px; box-sizing: border-box; }
.sms-row,.token-row { display: flex; align-items: stretch; gap: 10px; margin-bottom: 12px; }
/* 品牌 */ .sms-input,.token-input { flex: 1; min-width: 0; margin-bottom: 0; }
.login-brand { .sms-code-btn { flex-shrink: 0; align-self: center; }
padding: 80px 0 40px;
text-align: center;
}
.brand-name {
display: block;
font-size: 28px;
font-weight: 800;
color: #1a1a1a;
letter-spacing: 1px;
}
.brand-sub {
display: block;
font-size: 13px;
color: var(--c-text-3);
margin-top: 6px;
}
/* 卡片 */
.card {
background: #fff;
border-radius: 20px;
padding: 28px 24px;
box-shadow: 0 2px 12px rgba(0,0,0,0.04);
}
.card-title {
display: block;
font-size: 20px;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 20px;
}
/*
* 小程序 input 需固定 height + line-height padding 配合否则文字易被上下裁切 */
.field {
display: block;
background: #f5f5f2;
border: 1.5px solid transparent;
border-radius: 12px;
padding: 0 14px;
font-size: 16px;
color: #1a1a1a;
width: 100%;
box-sizing: border-box;
margin-bottom: 12px;
height: 48px;
min-height: 48px;
line-height: 48px;
}
.field:focus { border-color: var(--c-brand); background: #fff; }
/* 验证码行 */
.sms-row {
display: flex;
align-items: stretch;
gap: 10px;
margin-bottom: 12px;
}
.sms-input {
flex: 1;
margin-bottom: 0;
min-width: 0;
}
.sms-code-btn {
flex-shrink: 0;
align-self: center;
}
/* 微信获取手机号按钮 */
.phone-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.phone-input { flex: 1; margin-bottom: 0; }
.phone-auth-btn {
flex-shrink: 0;
}
.wx-verified-hint {
font-size: 13px;
color: var(--c-brand);
font-weight: 600;
margin-bottom: 12px;
padding: 8px 12px;
background: var(--c-brand-light);
border-radius: 10px;
}
.login-main-btn { margin-top: 4px !important; } .login-main-btn { margin-top: 4px !important; }
.secondary-btn { margin-top: 10px !important; }
/* 分割线 */ .divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; }
.divider {
display: flex;
align-items: center;
margin: 20px 0;
gap: 12px;
}
.divider-line { flex: 1; height: 1px; background: #ebebea; } .divider-line { flex: 1; height: 1px; background: #ebebea; }
.divider-text { font-size: 12px; color: #bbbbb5; } .divider-text { color: #bbbbb5; font-size: 12px; }
.footer-links { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 28px; padding-top: 20px; border-top: 1px solid #f0f0ee; }
/* 底部链接 */ .link-btn { padding: 8px 4px; color: var(--c-brand); font-size: 14px; font-weight: 600; }
.footer-links { .link-dot { width: 3px; height: 3px; background: #ddddd8; border-radius: 50%; }
display: flex; .back-link { display: block; padding: 8px; margin-top: 20px; color: var(--c-text-3); font-size: 14px; text-align: center; }
justify-content: center; .hint-box { padding: 11px 14px; margin-bottom: 14px; color: var(--c-brand); font-size: 13px; line-height: 1.5; background: var(--c-brand-light); border-radius: 10px; }
align-items: center; .invitation-preview { position: relative; padding: 18px; margin-bottom: 16px; overflow: hidden; background: linear-gradient(145deg,#f5fff8,#fff); border: 1px solid #d7f0e0; border-radius: 14px; }
gap: 16px; .preview-kicker { display: block; color: var(--c-text-3); font-size: 11px; font-weight: 700; letter-spacing: .12em; }
margin-top: 28px; .preview-store { display: block; margin: 6px 0 16px; color: #193326; font-size: 21px; font-weight: 800; }
padding-top: 20px; .preview-row { display: flex; justify-content: space-between; padding: 8px 0; color: #6b756f; font-size: 13px; border-top: 1px solid #edf3ef; }
border-top: 1px solid #f0f0ee; .preview-row text:last-child { color: #25342c; font-weight: 600; }
} .status-pill { display: inline-flex; padding: 5px 9px; margin-top: 12px; font-size: 12px; font-weight: 700; border-radius: 999px; }
.link-btn { .status-pending { color: #9a6500; background: #fff1c7; }
font-size: 14px; .status-accepted { color: #147340; background: #dff6e9; }
color: var(--c-brand); .status-revoked,.status-expired { color: #7c8189; background: #eef0f2; }
font-weight: 600; .non-mp-notice { padding: 12px; color: #7b6a3d; font-size: 13px; text-align: center; background: #fff8e6; border-radius: 10px; }
padding: 8px 4px;
}
.link-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: #ddddd8;
}
/* 返回 */
.back-link {
display: block;
text-align: center;
margin-top: 20px;
font-size: 14px;
color: var(--c-text-3);
padding: 8px;
}
/* 提示框 */
.hint-box {
background: var(--c-brand-light);
border-radius: 10px;
padding: 10px 14px;
font-size: 13px;
color: var(--c-brand);
margin-bottom: 14px;
}
</style> </style>

View File

@ -19,6 +19,11 @@
<view v-if="userInfo.role === 'boss'" class="menu-group"> <view v-if="userInfo.role === 'boss'" class="menu-group">
<text class="menu-label module-title">店铺与经营</text> <text class="menu-label module-title">店铺与经营</text>
<view class="menu-card"> <view class="menu-card">
<view class="menu-item" @click="goOnboardingPage">
<view class="menu-icon-wrap"><AppIcon name="store" :size="16" color="#666660" /></view>
<text class="menu-text">门店开通清单</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goServiceTypePage"> <view class="menu-item" @click="goServiceTypePage">
<view class="menu-icon-wrap"><AppIcon name="service" :size="16" color="#666660" /></view> <view class="menu-icon-wrap"><AppIcon name="service" :size="16" color="#666660" /></view>
<text class="menu-text">服务类型</text> <text class="menu-text">服务类型</text>
@ -115,6 +120,7 @@ const userInfo = getUserSession()
const { goPage, openAppPage } = useNavigator() const { goPage, openAppPage } = useNavigator()
const goStaffPage = () => uni.navigateTo({ url: '/pages/mine/Staff' }) const goStaffPage = () => uni.navigateTo({ url: '/pages/mine/Staff' })
const goOnboardingPage = () => uni.navigateTo({ url: '/pages/mine/Onboarding' })
const goServiceTypePage = () => uni.navigateTo({ url: '/pages/mine/ServiceType' }) const goServiceTypePage = () => uni.navigateTo({ url: '/pages/mine/ServiceType' })
const goStorePage = () => uni.navigateTo({ url: '/pages/mine/Store' }) const goStorePage = () => uni.navigateTo({ url: '/pages/mine/Store' })
const goSchedulePage = () => uni.navigateTo({ url: '/pages/mine/Schedule' }) const goSchedulePage = () => uni.navigateTo({ url: '/pages/mine/Schedule' })

View File

@ -0,0 +1,175 @@
<template>
<view class="page-shell onboarding-page">
<view class="top-nav nav-gradient" :style="navSafeStyle">
<view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">门店开通</text>
<view class="nav-placeholder" />
</view>
<view class="page-body">
<view class="hero-card">
<text class="hero-kicker">PILOT READINESS</text>
<view class="hero-title-row">
<text class="hero-title">准备好真实接待</text>
<text class="status-tag" :class="`status-${status?.status || 'loading'}`">{{ statusLabel }}</text>
</view>
<text class="hero-desc">资料容量和服务项目是必填项单人门店可以稍后邀请员工</text>
<view class="progress-track"><view class="progress-fill" :style="`width:${progress}%`" /></view>
<text class="progress-text">{{ status?.completedStepCount || 0 }}/{{ status?.totalStepCount || 4 }} 步完成</text>
</view>
<view class="step-list">
<view
v-for="step in status?.steps || []"
:key="step.id"
class="step-card"
:class="{ done: step.completed }"
@click="openStep(step.id)"
>
<view class="step-icon">{{ step.completed ? '✓' : step.required ? '!' : '+' }}</view>
<view class="step-copy">
<view class="step-title-row">
<text class="step-title">{{ step.title }}</text>
<text class="step-kind">{{ step.required ? '必填' : '选填' }}</text>
</view>
<text class="step-hint">{{ step.hint }}</text>
</view>
<text class="step-arrow"></text>
</view>
</view>
<view v-if="status" class="finish-card">
<text v-if="status.status === 'completed'" class="finish-copy success-copy">门店已完成开通可以进入真实试点</text>
<text v-else-if="status.readyToComplete" class="finish-copy">必填项已齐请确认后完成开通</text>
<text v-else class="finish-copy">尚缺{{ status.missingRequiredSteps.join('、') }}</text>
<button
v-if="status.status !== 'completed'"
class="van-button van-button--primary van-button--block finish-btn"
:disabled="!status.readyToComplete || completing"
@click="finish"
>{{ completing ? '确认中…' : '完成门店开通' }}</button>
<button v-else class="van-button van-button--primary van-button--block finish-btn" @click="goHome">进入工作台</button>
</view>
</view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import AppIcon from '../../components/AppIcon.vue'
import { completeOnboarding, getOnboardingStatus } from '../../api/index.js'
import { openAppPage } from '../../utils/globalState.js'
const status = ref(null)
const completing = ref(false)
const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44
const menuRect = uni.getMenuButtonBoundingClientRect?.()
if (menuRect?.top && menuRect?.height) {
const gap = Math.max(menuRect.top - statusBarHeight, 4)
navHeight = statusBarHeight + gap * 2 + menuRect.height
}
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
})()
const progress = computed(() => {
if (!status.value?.totalStepCount) return 0
return Math.round(status.value.completedStepCount / status.value.totalStepCount * 100)
})
const statusLabel = computed(() => {
if (status.value?.status === 'completed') return '已开通'
if (status.value?.status === 'unknown') return '待确认'
if (!status.value) return '加载中'
return '开通中'
})
async function load() {
const response = await getOnboardingStatus()
if (response.code === 200) {
status.value = response.data
} else {
uni.showToast({ title: response.message || '开通清单加载失败', icon: 'none' })
}
}
function openStep(stepId) {
const routes = {
profile: '/pages/mine/Store',
booking: '/pages/mine/Store',
services: '/pages/mine/ServiceType',
team: '/pages/mine/Staff'
}
uni.navigateTo({ url: routes[stepId] || '/pages/mine/Mine' })
}
function finish() {
uni.showModal({
title: '完成门店开通',
content: '确认门店资料、号源容量与服务项目已可用于真实接待?',
confirmText: '确认完成',
success: async (result) => {
if (!result.confirm) return
completing.value = true
try {
const response = await completeOnboarding()
if (response.code !== 200) {
uni.showToast({ title: response.message || '暂不能完成开通', icon: 'none' })
return
}
status.value = response.data
uni.showToast({ title: '门店已开通', icon: 'success' })
} finally {
completing.value = false
}
}
})
}
function goBack() {
const pages = getCurrentPages()
if (pages.length > 1) uni.navigateBack()
else openAppPage('mine')
}
function goHome() {
openAppPage('home')
}
onShow(load)
</script>
<style scoped>
.onboarding-page { min-height: 100vh; padding-bottom: 80rpx; background: #f6f8f7; }
.top-nav { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; }
.nav-back,.nav-placeholder { width: 32px; }
.nav-title { color: #fff; font-size: 18px; font-weight: 700; }
.page-body { padding: 14px; }
.hero-card { padding: 20px; color: #173b29; background: radial-gradient(circle at 88% 4%,rgba(46,185,109,.18),transparent 35%),#fff; border: 1px solid #d8eee1; border-radius: 18px; box-shadow: 0 8px 24px rgba(20,70,43,.06); }
.hero-kicker { display: block; color: var(--c-brand); font-size: 11px; font-weight: 800; letter-spacing: .14em; }
.hero-title-row,.step-title-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.hero-title { margin: 8px 0 5px; font-size: 23px; font-weight: 800; }
.hero-desc { display: block; color: #6d7e75; font-size: 13px; line-height: 1.55; }
.status-tag { flex-shrink: 0; padding: 5px 9px; color: #8a6518; font-size: 11px; font-weight: 700; background: #fff3cd; border-radius: 999px; }
.status-completed { color: #16733f; background: #dff6e9; }
.progress-track { height: 8px; margin-top: 18px; overflow: hidden; background: #edf1ee; border-radius: 99px; }
.progress-fill { height: 100%; background: linear-gradient(90deg,#28a961,var(--c-brand)); border-radius: 99px; transition: width .25s ease; }
.progress-text { display: block; margin-top: 7px; color: #7b887f; font-size: 11px; text-align: right; }
.step-list { display: grid; gap: 10px; margin-top: 14px; }
.step-card { display: flex; align-items: center; gap: 12px; padding: 15px; background: #fff; border: 1px solid #e8ece9; border-radius: 14px; }
.step-card.done { background: #f7fdf9; border-color: #d6eddf; }
.step-icon { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 34px; height: 34px; color: #a17016; font-size: 14px; font-weight: 800; background: #fff2cd; border-radius: 10px; }
.step-card.done .step-icon { color: #fff; background: var(--c-brand); }
.step-copy { flex: 1; min-width: 0; }
.step-title { color: #27342d; font-size: 15px; font-weight: 700; }
.step-kind { color: #9aa49e; font-size: 10px; }
.step-hint { display: block; margin-top: 4px; color: #879189; font-size: 12px; line-height: 1.4; }
.step-arrow { color: #a9b1ac; font-size: 24px; }
.finish-card { padding: 16px; margin-top: 14px; background: #fff; border: 1px solid #e8ece9; border-radius: 14px; }
.finish-copy { display: block; margin-bottom: 12px; color: #7c6a3d; font-size: 13px; line-height: 1.5; }
.success-copy { color: #16733f; }
.finish-btn { margin: 0 !important; }
</style>

View File

@ -17,7 +17,7 @@
</view> </view>
<view class="sched-hero-text"> <view class="sched-hero-text">
<text class="sched-hero-title">时段一览</text> <text class="sched-hero-title">时段一览</text>
<text class="sched-hero-desc">绿色为客户预约橙色为到店占用灰色为暂停线上预约点空白格可手动占用点占用可取消</text> <text class="sched-hero-desc">绿色为客户预约橙色为到店占用灰色为暂停线上预约每行显示已用/总容量</text>
</view> </view>
</view> </view>
@ -48,7 +48,7 @@
<view class="sched-main"> <view class="sched-main">
<template v-if="row.kind === 'appointment' && row.appointment"> <template v-if="row.kind === 'appointment' && row.appointment">
<text class="sched-line-title">{{ row.appointment.petName || '宠物' }} · {{ row.appointment.serviceType || '服务' }}</text> <text class="sched-line-title">{{ row.appointment.petName || '宠物' }} · {{ row.appointment.serviceType || '服务' }}</text>
<text class="sched-line-sub">{{ apptStatusText(row.appointment.status) }}</text> <text class="sched-line-sub">{{ apptStatusText(row.appointment.status) }} · 容量 {{ row.usedCapacity || 0 }}/{{ row.totalCapacity || 1 }}</text>
</template> </template>
<template v-else-if="row.kind === 'block' && row.block"> <template v-else-if="row.kind === 'block' && row.block">
<text class="sched-line-title">{{ blockTitle(row.block) }}</text> <text class="sched-line-title">{{ blockTitle(row.block) }}</text>
@ -61,7 +61,7 @@
</view> </view>
</view> </view>
<view class="sched-foot-hint">营业时间一致每半小时一档占用后客户端该档不可再约</view> <view class="sched-foot-hint">营业容量时段一致服务和到店占用会跨半小时桶消耗名额暂停预约会关闭全部名额</view>
</view> </view>
</view> </view>
</template> </template>
@ -212,6 +212,7 @@ function onRowTap(row) {
const res = await createScheduleBlock({ const res = await createScheduleBlock({
storeId: storeInfo.id, storeId: storeInfo.id,
slotStart, slotStart,
durationMinutes: 30,
blockType, blockType,
note: '', note: '',
createdByUserId: userInfo.id createdByUserId: userInfo.id

View File

@ -37,6 +37,7 @@
<view class="st-name-row"> <view class="st-name-row">
<span class="st-dot"><AppIcon name="service" :size="14" /></span> <span class="st-dot"><AppIcon name="service" :size="14" /></span>
<text class="st-name">{{ s.name }}</text> <text class="st-name">{{ s.name }}</text>
<text class="st-duration"> {{ s.durationMinutes || 60 }} 分钟</text>
<view v-if="!s.storeId" class="van-tag van-tag--success van-tag--small system-tag">系统默认</view> <view v-if="!s.storeId" class="van-tag van-tag--success van-tag--small system-tag">系统默认</view>
</view> </view>
</view> </view>
@ -58,6 +59,13 @@
<view class="popup-desc">建议使用简洁明确的命名方便前台下单与报告展示</view> <view class="popup-desc">建议使用简洁明确的命名方便前台下单与报告展示</view>
<view class="field-label">服务类型名称</view> <view class="field-label">服务类型名称</view>
<input v-model="newName" class="van-field" placeholder="请输入" /> <input v-model="newName" class="van-field" placeholder="请输入" />
<view class="field-label st-duration-label">预计服务时长</view>
<picker mode="selector" :range="DURATION_LABELS" :value="durationPickerIndex" @change="onDurationChange">
<view class="app-form-input app-form-picker">
<text>{{ newDuration }} 分钟</text>
<text class="app-form-arrow"></text>
</view>
</picker>
</view> </view>
<view class="popup-footer"> <view class="popup-footer">
<view class="popup-actions"> <view class="popup-actions">
@ -71,7 +79,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import { navigateTo } from '../../utils/globalState.js' import { navigateTo } from '../../utils/globalState.js'
import { getServiceTypeList, createServiceType, deleteServiceType } from '../../api/index.js' import { getServiceTypeList, createServiceType, deleteServiceType } from '../../api/index.js'
@ -102,6 +110,10 @@ const navSafeStyle = (() => {
const serviceTypes = ref([]) const serviceTypes = ref([])
const showNew = ref(false) const showNew = ref(false)
const newName = ref('') const newName = ref('')
const DURATION_OPTIONS = Array.from({ length: 16 }, (_, i) => (i + 1) * 30)
const DURATION_LABELS = DURATION_OPTIONS.map((minutes) => `${minutes} 分钟`)
const newDuration = ref(60)
const durationPickerIndex = computed(() => Math.max(0, DURATION_OPTIONS.indexOf(newDuration.value)))
const swipedId = ref(null) const swipedId = ref(null)
const touchStartX = ref(0) const touchStartX = ref(0)
const touchCurrentX = ref(0) const touchCurrentX = ref(0)
@ -111,12 +123,17 @@ const load = async () => {
if (res.code === 200) serviceTypes.value = res.data if (res.code === 200) serviceTypes.value = res.data
} }
const onDurationChange = (e) => {
newDuration.value = DURATION_OPTIONS[Number(e.detail.value)] || 60
}
const confirmAdd = async () => { const confirmAdd = async () => {
if (!newName.value) { uni.showToast({ title: '请输入服务类型名称', icon: 'none' }); return } if (!newName.value) { uni.showToast({ title: '请输入服务类型名称', icon: 'none' }); return }
const res = await createServiceType(storeInfo.id, newName.value) const res = await createServiceType(storeInfo.id, newName.value, newDuration.value)
if (res.code === 200) { if (res.code === 200) {
uni.showToast({ title: '添加成功', icon: 'success' }) uni.showToast({ title: '添加成功', icon: 'success' })
newName.value = '' newName.value = ''
newDuration.value = 60
showNew.value = false showNew.value = false
load() load()
} else { } else {
@ -193,6 +210,8 @@ onMounted(() => load())
background: var(--c-slate-100); background: var(--c-slate-100);
} }
.st-name { font-size: 15px; font-weight: 600; color: var(--c-gray-800); } .st-name { font-size: 15px; font-weight: 600; color: var(--c-gray-800); }
.st-duration { margin-left: auto; font-size: 12px; color: var(--c-slate-500); }
.st-duration-label { margin-top: 14px; }
.system-tag { margin-left: 8px; } .system-tag { margin-left: 8px; }
/* 与员工管理页一致:删除区绝对贴底,主内容盖住;左滑才露出 */ /* 与员工管理页一致:删除区绝对贴底,主内容盖住;左滑才露出 */
.st-swipe-wrap { .st-swipe-wrap {

View File

@ -1,392 +1,290 @@
<template> <template>
<view class="page-shell staff-page"> <view class="page-shell staff-page">
<view class="staff-nav nav-gradient" :style="navSafeStyle"> <view class="staff-nav nav-gradient" :style="navSafeStyle">
<view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view> <view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<view class="nav-title">员工管理</view> <view class="nav-title">员工与邀请</view>
<view class="nav-placeholder"></view> <view class="nav-placeholder" />
</view> </view>
<!-- 邀请码标题与码+按钮分行同一行内码与复制垂直居中 --> <view class="page-body">
<view class="page-section invite-wrap"> <view class="invite-hero">
<text class="invite-kicker">员工邀请码</text> <text class="invite-kicker">ONE-TIME INVITE</text>
<view class="invite-code-row"> <text class="invite-title">邀请员工本人加入</text>
<text class="invite-code-text" selectable>{{ inviteCodeText }}</text> <text class="invite-desc">邀请绑定微信手机号限时且只能使用一次原始口令只在创建后显示不再使用永久门店邀请码</text>
<button class="invite-copy-btn" hover-class="invite-copy-btn--hover" @click="copyCode">复制</button> <button class="van-button van-button--primary van-button--block hero-btn" @click="showCreate = true">创建员工邀请</button>
</view>
<text class="invite-tip">新员工注册时填写此码即可加入本店</text>
</view> </view>
<view class="page-section add-btn-wrap"> <view class="section-head"><text>门店成员</text><text>{{ staffList.length }} </text></view>
<button class="van-button van-button--primary van-button--block" @click="showAddStaff = true">新增员工</button> <view class="list-card">
<view v-for="staff in staffList" :key="staff.id" class="staff-row">
<view class="avatar" :style="avatarStyle(staff)">{{ staffInitial(staff) }}</view>
<view class="staff-copy">
<text class="staff-name">{{ staff.name }}</text>
<text class="staff-meta">{{ staff.phone }} · {{ staff.role === 'boss' ? '老板' : '员工' }}</text>
</view>
<text v-if="staff.role === 'staff'" class="delete-link" @click="removeStaff(staff)">删除</text>
</view>
<view v-if="staffList.length === 0" class="empty-row">暂无门店成员</view>
</view> </view>
<!-- 员工列表 --> <view class="section-head invitation-head"><text>邀请记录</text><text>{{ invitations.length }} </text></view>
<view class="page-section section-gap"> <view class="list-card">
<view class="van-cell-group card-section"> <view v-for="item in invitations" :key="item.invitationId" class="invitation-row">
<view <view class="invitation-main">
v-for="(s, idx) in staffList" <view class="invitation-title-row">
:key="s.id != null ? s.id : 'row-' + idx" <text class="staff-name">{{ item.invitedName }}</text>
class="staff-swipe-wrap" <text class="status-pill" :class="`status-${item.status}`">{{ statusLabel(item.status) }}</text>
:class="{ 'is-open': swipedId === s.id && s.role !== 'boss' }"
@click="onRowTap(s)"
>
<view
v-show="s.role !== 'boss'"
class="swipe-delete-btn"
@click.stop="deleteStaff(s.id)"
>
删除
</view>
<view
class="staff-main"
@touchstart="onSwipeStart($event, s)"
@touchmove.stop="onSwipeMove($event)"
@touchend="onSwipeEnd($event, s)"
>
<view class="van-cell staff-cell">
<view class="staff-info-row">
<view class="avatar" :style="avatarStyle(s)">
<image v-if="s.avatar" :src="s.avatar" class="avatar-img" mode="aspectFill" />
<text v-else class="avatar-initials">{{ staffInitial(s) }}</text>
</view>
<view>
<view class="staff-name">{{ s.name }}</view>
<view class="staff-meta">{{ s.phone }}</view>
<view class="staff-meta">{{ s.role === 'boss' ? '店长' : '员工' }}</view>
</view>
</view>
</view> </view>
<text class="staff-meta">{{ item.maskedPhone }}</text>
<text class="staff-meta">有效期至 {{ formatDate(item.expiresAt) }}</text>
</view> </view>
<text v-if="item.status === 'pending'" class="delete-link" @click="revoke(item)">撤销</text>
</view> </view>
<view v-if="invitations.length === 0" class="empty-row">尚未创建员工邀请</view>
</view> </view>
</view> </view>
<view v-if="staffList.length === 0" class="empty"><text>暂无员工</text></view> <view v-if="showCreate" class="popup-mask" @click="showCreate = false">
<view v-if="showAddStaff" class="popup-mask" @click="showAddStaff = false">
<view class="popup-content" @click.stop> <view class="popup-content" @click.stop>
<view class="popup-header"> <view class="popup-header"><text class="popup-title">创建员工邀请</text><text class="popup-close" @click="showCreate = false"></text></view>
<view class="popup-title">新增员工</view>
<view class="popup-close" @click="showAddStaff = false"></view>
</view>
<view class="popup-body"> <view class="popup-body">
<view class="popup-desc">创建后会自动生成初始密码员工可使用手机号登录</view> <text class="popup-desc">请填写员工本人用于微信授权的手机号手机号不一致时系统会拒绝加入</text>
<view class="field-label">员工姓名</view> <text class="field-label">员工姓名</text>
<input v-model="newStaff.name" class="van-field" placeholder="请输入" /> <input v-model="form.name" class="van-field" placeholder="请输入" maxlength="64" />
<view class="field-label">手机号</view> <text class="field-label">本人微信手机号</text>
<input v-model="newStaff.phone" type="tel" class="van-field" placeholder="请输入" maxlength="11" /> <input v-model="form.phone" type="tel" class="van-field" placeholder="请输入" maxlength="11" />
<text class="field-label">有效期</text>
<picker mode="selector" :range="validDayLabels" :value="validDayIndex" @change="onValidDayChange">
<view class="van-field picker-field">{{ form.validDays }} <text></text></view>
</picker>
</view> </view>
<view class="popup-footer"> <view class="popup-footer">
<view class="popup-actions"> <button class="van-button btn-ghost" @click="showCreate = false">取消</button>
<button class="van-button btn-ghost" @click="showAddStaff = false">取消</button> <button class="van-button van-button--primary btn-confirm" :disabled="creating" @click="createInvite">
<button class="van-button van-button--primary btn-confirm" @click="confirmAddStaff">确认创建</button> {{ creating ? '创建中…' : '创建邀请' }}
</button>
</view> </view>
</view> </view>
</view> </view>
<view v-if="createdInvite" class="popup-mask" @click="closeCreated">
<view class="popup-content share-popup" @click.stop>
<view class="popup-header"><text class="popup-title">邀请已创建</text><text class="popup-close" @click="closeCreated"></text></view>
<view class="popup-body">
<view class="security-alert">原始邀请口令只显示这一次现在转发给员工关闭后只能撤销并重新创建</view>
<text class="share-store">{{ createdInvite.storeName }}</text>
<text class="share-name">邀请 {{ createdInvite.invitedName }} · {{ createdInvite.maskedPhone }}</text>
<text class="share-expiry">有效期至 {{ formatDate(createdInvite.expiresAt) }}</text>
</view>
<view class="share-actions">
<button class="van-button btn-ghost" @click="copyInvite">复制口令</button>
<!-- #ifdef MP-WEIXIN -->
<button class="van-button van-button--primary btn-confirm" open-type="share">转发给员工</button>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<button class="van-button van-button--primary btn-confirm" @click="copyInvite">复制邀请</button>
<!-- #endif -->
</view>
</view>
</view> </view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { computed, reactive, ref } from 'vue'
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
import AppIcon from '../../components/AppIcon.vue' import AppIcon from '../../components/AppIcon.vue'
import {
createStaffInvitation,
deleteStaff,
getStaffInvitations,
getStaffList,
revokeStaffInvitation
} from '../../api/index.js'
import { openAppPage } from '../../utils/globalState.js' import { openAppPage } from '../../utils/globalState.js'
import { getStaffList, createStaff, deleteStaff as delStaff } from '../../api/index.js'
import { getStoreSession } from '../../utils/session.js'
const emit = defineEmits(['change-page']) const staffList = ref([])
const storeInfo = getStoreSession() const invitations = ref([])
const inviteCodeText = computed(() => (storeInfo && storeInfo.inviteCode) ? String(storeInfo.inviteCode) : '—') const showCreate = ref(false)
const creating = ref(false)
const createdInvite = ref(null)
const form = reactive({ name: '', phone: '', validDays: 7 })
const validDays = [3, 7, 14, 30]
const validDayLabels = validDays.map((day) => `${day}`)
const validDayIndex = computed(() => Math.max(0, validDays.indexOf(form.validDays)))
const navSafeStyle = (() => { const navSafeStyle = (() => {
let statusBarHeight = 20 const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
try {
const win = typeof uni.getWindowInfo === 'function' ? uni.getWindowInfo() : null
if (win && win.statusBarHeight != null) statusBarHeight = win.statusBarHeight
else {
const sys = uni.getSystemInfoSync()
statusBarHeight = sys.statusBarHeight || 20
}
} catch (_) {
statusBarHeight = 20
}
let navHeight = statusBarHeight + 44 let navHeight = statusBarHeight + 44
const menuRect = uni.getMenuButtonBoundingClientRect?.() const menuRect = uni.getMenuButtonBoundingClientRect?.()
if (menuRect && menuRect.top && menuRect.height) { if (menuRect?.top && menuRect?.height) {
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4) const gap = Math.max(menuRect.top - statusBarHeight, 4)
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height navHeight = statusBarHeight + gap * 2 + menuRect.height
} }
return `padding-top:${statusBarHeight}px;height:${navHeight}px;` return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
})() })()
const goBackToMine = () => { async function load() {
const pages = getCurrentPages() const [staffResponse, invitationResponse] = await Promise.all([getStaffList(), getStaffInvitations()])
if (pages.length > 1) { if (staffResponse.code === 200) staffList.value = Array.isArray(staffResponse.data) ? staffResponse.data : []
uni.navigateBack() if (invitationResponse.code === 200) invitations.value = Array.isArray(invitationResponse.data) ? invitationResponse.data : []
return
}
openAppPage('mine')
} }
const staffInitial = (s) => { function onValidDayChange(event) {
const n = s && s.name form.validDays = validDays[Number(event.detail.value)] || 7
return (typeof n === 'string' && n.length > 0) ? n[0] : '?'
} }
const staffList = ref([]) async function createInvite() {
const showAddStaff = ref(false) if (!form.name.trim()) return uni.showToast({ title: '请输入员工姓名', icon: 'none' })
const newStaff = ref({ name: '', phone: '' }) if (!/^1[3-9]\d{9}$/.test(form.phone.trim())) return uni.showToast({ title: '请输入正确手机号', icon: 'none' })
const swipedId = ref(null) creating.value = true
const touchStartX = ref(0) try {
const touchCurrentX = ref(0) const response = await createStaffInvitation({
name: form.name.trim(),
const COLORS = ['#f5913e', 'var(--c-brand)', '#8b6914', '#e06050', '#5090d0', '#9b59b6'] phone: form.phone.trim(),
const avatarStyle = (s) => { validDays: form.validDays
if (s.avatar) return { background: 'transparent' }
const idx = (s.name?.charCodeAt(0) || 0) % COLORS.length
return { background: COLORS[idx] }
}
const loadStaff = async () => {
const res = await getStaffList(storeInfo.id)
if (res.code === 200) staffList.value = res.data
}
const copyCode = () => {
const code = inviteCodeText.value === '—' ? '' : inviteCodeText.value
if (!code) return
uni.setClipboardData({
data: code,
success: () => uni.showToast({ title: '邀请码已复制', icon: 'none' })
}) })
} if (response.code !== 200 || !response.data?.inviteToken) {
uni.showToast({ title: response.message || '邀请创建失败', icon: 'none' })
const confirmAddStaff = async () => {
if (!newStaff.value.name) { uni.showToast({ title: '请输入员工姓名', icon: 'none' }); return }
if (!newStaff.value.phone || newStaff.value.phone.length !== 11) { uni.showToast({ title: '请输入正确的手机号', icon: 'none' }); return }
const res = await createStaff({ storeId: storeInfo.id, name: newStaff.value.name, phone: newStaff.value.phone })
if (res.code === 200) {
uni.showToast({ title: `添加成功,密码:${res.data.password}`, icon: 'none', duration: 3000 })
showAddStaff.value = false
newStaff.value = { name: '', phone: '' }
loadStaff()
} else {
uni.showToast({ title: res.message || '添加失败', icon: 'none' })
}
}
const getTouchX = (e) => {
if (e?.touches?.[0]) return e.touches[0].pageX
if (e?.changedTouches?.[0]) return e.changedTouches[0].pageX
return 0
}
const onSwipeStart = (e, staff) => {
const x = getTouchX(e)
touchStartX.value = x
touchCurrentX.value = x
if (swipedId.value && swipedId.value !== staff.id) {
swipedId.value = null
}
}
const onSwipeMove = (e) => {
touchCurrentX.value = getTouchX(e)
}
const onSwipeEnd = (e, staff) => {
if (staff.role === 'boss') return
const endX = getTouchX(e) || touchCurrentX.value
const deltaX = endX - touchStartX.value
if (deltaX < -40) {
swipedId.value = staff.id
return return
} }
if (deltaX > 24 && swipedId.value === staff.id) { createdInvite.value = response.data
swipedId.value = null showCreate.value = false
form.name = ''
form.phone = ''
await load()
} finally {
creating.value = false
} }
} }
const onRowTap = (staff) => { function closeCreated() {
if (swipedId.value && swipedId.value !== staff.id) { createdInvite.value = null
swipedId.value = null
}
} }
const deleteStaff = async (staffId) => { function copyInvite() {
if (!createdInvite.value?.inviteToken) return
const message = [
`【宠小它员工邀请】${createdInvite.value.storeName} 邀请 ${createdInvite.value.invitedName} 加入门店。`,
`邀请口令:${createdInvite.value.inviteToken}`,
`小程序路径:${createdInvite.value.invitePath}`,
`有效期至:${formatDate(createdInvite.value.expiresAt)}`,
'请使用受邀手机号在宠小它小程序完成微信核验。'
].join('\n')
uni.setClipboardData({ data: message, success: () => uni.showToast({ title: '邀请已复制', icon: 'none' }) })
}
function revoke(item) {
uni.showModal({ uni.showModal({
title: '提示', title: '撤销邀请',
content: '确定删除该员工?', content: `确认撤销发给「${item.invitedName}」的邀请?`,
success: async (res) => { success: async (result) => {
if (!res.confirm) return if (!result.confirm) return
const r = await delStaff(staffId) const response = await revokeStaffInvitation(item.invitationId)
if (r.code === 200) { if (response.code === 200) {
swipedId.value = null uni.showToast({ title: '邀请已撤销', icon: 'success' })
uni.showToast({ title: '已删除', icon: 'success' }) load()
loadStaff() } else {
uni.showToast({ title: response.message || '撤销失败', icon: 'none' })
} }
} }
}) })
} }
onMounted(() => loadStaff()) function removeStaff(staff) {
uni.showModal({
title: '删除员工',
content: `确认删除「${staff.name}」?删除后其门店访问权限将失效。`,
success: async (result) => {
if (!result.confirm) return
const response = await deleteStaff(staff.id)
if (response.code === 200) {
uni.showToast({ title: '已删除', icon: 'success' })
load()
} else {
uni.showToast({ title: response.message || '删除失败', icon: 'none' })
}
}
})
}
function statusLabel(status) {
return { pending: '待接受', accepted: '已加入', revoked: '已撤销', expired: '已过期' }[status] || '未知'
}
function formatDate(value) {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return String(value).replace('T', ' ')
return `${date.getMonth() + 1}${date.getDate()}${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
}
function staffInitial(staff) {
return String(staff?.name || '?').slice(0, 1)
}
function avatarStyle(staff) {
const colors = ['#f5913e', 'var(--c-brand)', '#5090d0', '#e06050', '#8b6914']
return { background: colors[(staff?.name?.charCodeAt(0) || 0) % colors.length] }
}
function goBack() {
const pages = getCurrentPages()
if (pages.length > 1) uni.navigateBack()
else openAppPage('mine')
}
onShareAppMessage(() => ({
title: createdInvite.value
? `${createdInvite.value.storeName} 邀请你加入门店`
: '宠小它员工邀请',
path: createdInvite.value?.invitePath || '/pages/login/Login'
}))
onShow(load)
</script> </script>
<style scoped> <style scoped>
.staff-page { padding-bottom: 120rpx; } .staff-page { min-height: 100vh; padding-bottom: 100rpx; background: #f6f8f7; }
.nav-placeholder { width: 32px; } .staff-nav { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; }
.add-btn-wrap { padding: 0; } .nav-back,.nav-placeholder { width: 32px; }
.card-section { margin: 0; } .nav-title { color: #fff; font-size: 18px; font-weight: 700; }
.staff-cell { min-height: 58px; } .page-body { padding: 14px; }
.invite-hero { padding: 19px; background: radial-gradient(circle at 90% 0,rgba(45,185,109,.17),transparent 36%),#fff; border: 1px solid #d7eee0; border-radius: 18px; box-shadow: 0 8px 24px rgba(24,75,46,.05); }
/* 邀请码卡片 */ .invite-kicker { display: block; color: var(--c-brand); font-size: 10px; font-weight: 800; letter-spacing: .14em; }
.invite-wrap { .invite-title { display: block; margin: 7px 0 5px; color: #1d3327; font-size: 21px; font-weight: 800; }
background: #fff; .invite-desc { display: block; color: #738078; font-size: 13px; line-height: 1.55; }
border-radius: 14px; .hero-btn { margin: 16px 0 0 !important; }
padding: 16px 16px 14px; .section-head { display: flex; justify-content: space-between; padding: 20px 4px 8px; color: #657269; font-size: 13px; font-weight: 700; }
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); .section-head text:last-child { color: #9aa39d; font-weight: 500; }
border: 1px solid #f0f0ee; .list-card { overflow: hidden; background: #fff; border: 1px solid #e7ebe8; border-radius: 14px; }
} .staff-row,.invitation-row { display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 12px 14px; }
.invite-kicker { .staff-row + .staff-row,.invitation-row + .invitation-row { border-top: 1px solid #eff2f0; }
display: block; .avatar { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 40px; height: 40px; color: #fff; font-weight: 700; border-radius: 12px; }
font-size: 12px; .staff-copy,.invitation-main { display: grid; flex: 1; gap: 3px; min-width: 0; }
font-weight: 700; .staff-name { color: #28342d; font-size: 15px; font-weight: 700; }
color: var(--c-text-3); .staff-meta { color: #8a948e; font-size: 12px; }
letter-spacing: 0.5px; .delete-link { flex-shrink: 0; padding: 8px 0 8px 10px; color: #dc5b5b; font-size: 12px; }
margin-bottom: 10px; .invitation-head { margin-top: 4px; }
} .invitation-title-row { display: flex; align-items: center; gap: 8px; }
.invite-code-row { .status-pill { padding: 3px 7px; color: #946700; font-size: 10px; font-weight: 700; background: #fff1c5; border-radius: 999px; }
display: flex; .status-accepted { color: #177440; background: #dcf5e7; }
flex-direction: row; .status-revoked { color: #a14b4b; background: #fde7e7; }
align-items: center; .status-expired { color: #737d77; background: #edf0ee; }
justify-content: space-between; .empty-row { padding: 28px; color: #a1aaa4; font-size: 13px; text-align: center; }
gap: 12px; .popup-mask { position: fixed; inset: 0; z-index: 100; display: flex; align-items: flex-end; justify-content: center; background: rgba(0,0,0,.48); }
min-height: 44px; .popup-content { width: 100%; max-width: 430px; background: #fff; border-radius: 18px 18px 0 0; }
} .popup-header { display: flex; align-items: center; justify-content: space-between; padding: 17px 20px; border-bottom: 1px solid #f0f2f0; }
.invite-code-text { .popup-title { font-size: 17px; font-weight: 700; }
flex: 1; .popup-close { color: #9ca39f; }
min-width: 0; .popup-body { padding: 18px 20px; }
font-size: 20px; .popup-desc { display: block; margin-bottom: 14px; color: #77817b; font-size: 12px; line-height: 1.5; }
font-weight: 700; .field-label { display: block; margin: 12px 0 6px; color: #626d66; font-size: 12px; font-weight: 700; }
font-family: ui-monospace, Menlo, Monaco, Consolas, monospace; .picker-field { display: flex; align-items: center; justify-content: space-between; }
color: #1a1a1a; .popup-footer,.share-actions { display: flex; gap: 10px; padding: 12px 20px 22px; }
letter-spacing: 0.12em; .btn-ghost { width: 104px; color: #647168 !important; background: #fff !important; border: 1px solid #dfe5e1 !important; }
line-height: 1.3;
}
.invite-copy-btn {
flex-shrink: 0;
margin: 0;
padding: 0 18px;
height: 36px;
line-height: 36px;
font-size: 14px;
font-weight: 600;
color: var(--c-brand);
background: var(--c-brand-light);
border: none;
border-radius: 10px;
box-sizing: border-box;
}
.invite-copy-btn::after {
border: none;
}
.invite-copy-btn--hover {
opacity: 0.88;
}
.invite-tip {
display: block;
margin-top: 10px;
font-size: 12px;
color: #bbbbb5;
line-height: 1.4;
}
.staff-info-row { display: flex; align-items: center; gap: 12px; flex: 1; }
.staff-name { font-weight: 700; font-size: 15px; color: var(--c-gray-800); }
.staff-meta { font-size: 12px; color: var(--c-gray-500); }
.staff-swipe-wrap {
position: relative;
overflow: hidden;
border-radius: 12px;
background: #fff;
}
.staff-swipe-wrap + .staff-swipe-wrap {
border-top: 1px solid var(--c-surface-cool);
}
.staff-main {
position: relative;
z-index: 2;
transform: translateX(0);
transition: transform 0.2s ease;
background: #fff;
width: 100%;
min-width: 100%;
box-sizing: border-box;
}
.staff-swipe-wrap.is-open .staff-main {
transform: translateX(-76px);
}
.swipe-delete-btn {
position: absolute;
right: 0;
top: 0;
bottom: 0;
width: 76px;
background: #ef4444;
color: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
flex-shrink: 0;
}
.staff-nav {
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.nav-back { font-size: 20px; color: #fff; }
.nav-title { font-size: 18px; font-weight: 700; color: #fff; }
.avatar {
width: 42px; height: 42px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 18px; color: #fff;
overflow: hidden;
flex-shrink: 0;
border: 1px solid var(--c-border-cool);
}
.avatar-img { width: 100%; height: 100%; object-fit: cover; }
.avatar-initials { font-weight: 600; }
.popup-mask {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5); z-index: 100;
display: flex; align-items: flex-end; justify-content: center;
}
.popup-content {
background: #fff; border-radius: 16px 16px 0 0;
width: 430px; max-width: 100%;
display: flex; flex-direction: column;
}
.popup-header {
display: flex; justify-content: space-between; align-items: center;
padding: 16px 20px; border-bottom: 1px solid #f0f0f0;
}
.popup-title { font-size: 16px; font-weight: 600; }
.popup-close { font-size: 18px; color: #999; }
.popup-body { padding: 16px 20px; }
.popup-footer { padding: 12px 20px 20px; }
.popup-desc { font-size: 12px; color: var(--c-gray-500); line-height: 1.45; margin-bottom: 10px; }
.popup-actions { display: flex; align-items: center; gap: 10px; }
.btn-ghost {
width: 96px;
border: 1px solid #dbe3ee !important;
background: #fff !important;
color: var(--c-slate-500) !important;
}
.btn-confirm { flex: 1; } .btn-confirm { flex: 1; }
.security-alert { padding: 11px; color: #805f1d; font-size: 12px; line-height: 1.5; background: #fff6db; border-radius: 10px; }
.share-store { display: block; margin-top: 18px; color: #1e3628; font-size: 20px; font-weight: 800; }
.share-name,.share-expiry { display: block; margin-top: 6px; color: #738078; font-size: 13px; }
</style> </style>

View File

@ -13,7 +13,7 @@
</view> </view>
<view class="store-hero-text"> <view class="store-hero-text">
<text class="store-hero-title">门店资料</text> <text class="store-hero-title">门店资料</text>
<text class="store-hero-desc">在此维护门店名称电话地址与简介并设置营业时间营业时间决定客户线上可预约的时段范围每半小时为一个时段同一时段仅一单</text> <text class="store-hero-desc">在此维护门店资料营业容量时段与并发接待数系统会按服务时长检查每个半小时容量桶</text>
</view> </view>
</view> </view>
@ -68,7 +68,7 @@
<view class="store-card store-card--hours store-card--after-basic"> <view class="store-card store-card--hours store-card--after-basic">
<view class="store-card-head"> <view class="store-card-head">
<text class="store-card-title">营业时间</text> <text class="store-card-title">营业时间</text>
<text class="store-card-sub">客户可预约时段为开始结束之间的半点档每半小时为一档每档最多一单已满或已过不可选</text> <text class="store-card-sub">服务必须在开始至结束之间完成线上预约和到店占用共同消耗并发名额</text>
</view> </view>
<view class="store-field"> <view class="store-field">
<view class="store-field-label"> <view class="store-field-label">
@ -83,12 +83,12 @@
</view> </view>
</picker> </picker>
</view> </view>
<view class="store-field store-field-last"> <view class="store-field">
<view class="store-field-label"> <view class="store-field-label">
<span class="store-ico"><AppIcon name="service" :size="15" :color="SLATE_600" /></span> <span class="store-ico"><AppIcon name="service" :size="15" :color="SLATE_600" /></span>
<text>结束时间</text> <text>结束时间</text>
</view> </view>
<text class="store-field-tip">当日最后一个可预约的半点起始时刻例如 21:30 表示最晚可约 21:0021:30 这一档</text> <text class="store-field-tip">最后一个容量桶的开始时刻例如 21:30 表示营业容量至 22:00</text>
<picker mode="selector" :range="HALF_HOUR_TIME_SLOTS" :value="bookingLastIdx" @change="onBookingLastChange"> <picker mode="selector" :range="HALF_HOUR_TIME_SLOTS" :value="bookingLastIdx" @change="onBookingLastChange">
<view class="app-form-input app-form-picker store-picker-like"> <view class="app-form-input app-form-picker store-picker-like">
<text>{{ form.bookingLastSlotStart || '请选择' }}</text> <text>{{ form.bookingLastSlotStart || '请选择' }}</text>
@ -97,6 +97,19 @@
</picker> </picker>
<view class="booking-hint">结束须不早于开始保存后对新建预约生效</view> <view class="booking-hint">结束须不早于开始保存后对新建预约生效</view>
</view> </view>
<view class="store-field store-field-last">
<view class="store-field-label">
<span class="store-ico"><AppIcon name="profile" :size="15" :color="SLATE_600" /></span>
<text>并发接待数</text>
</view>
<text class="store-field-tip">同一半小时最多可同时服务的顾客数</text>
<picker mode="selector" :range="CAPACITY_OPTIONS" :value="bookingCapacityIdx" @change="onBookingCapacityChange">
<view class="app-form-input app-form-picker store-picker-like">
<text>{{ form.bookingCapacity }} </text>
<text class="app-form-arrow"></text>
</view>
</picker>
</view>
</view> </view>
<view class="store-actions"> <view class="store-actions">
@ -147,6 +160,7 @@ const navSafeStyle = (() => {
})() })()
const saving = ref(false) const saving = ref(false)
const pickingLocation = ref(false) const pickingLocation = ref(false)
const CAPACITY_OPTIONS = Array.from({ length: 10 }, (_, i) => i + 1)
const form = ref({ const form = ref({
name: '', name: '',
phone: '', phone: '',
@ -155,7 +169,8 @@ const form = ref({
latitude: null, latitude: null,
longitude: null, longitude: null,
bookingDayStart: '09:00', bookingDayStart: '09:00',
bookingLastSlotStart: '21:30' bookingLastSlotStart: '21:30',
bookingCapacity: 1
}) })
const toHHmm = (v) => { const toHHmm = (v) => {
@ -169,6 +184,7 @@ const toHHmm = (v) => {
const bookingStartIdx = computed(() => halfHourPickerIndex(form.value.bookingDayStart)) const bookingStartIdx = computed(() => halfHourPickerIndex(form.value.bookingDayStart))
const bookingLastIdx = computed(() => halfHourPickerIndex(form.value.bookingLastSlotStart)) const bookingLastIdx = computed(() => halfHourPickerIndex(form.value.bookingLastSlotStart))
const bookingCapacityIdx = computed(() => Math.max(0, CAPACITY_OPTIONS.indexOf(Number(form.value.bookingCapacity || 1))))
const onBookingStartChange = (e) => { const onBookingStartChange = (e) => {
const i = Number(e.detail.value) const i = Number(e.detail.value)
@ -180,6 +196,10 @@ const onBookingLastChange = (e) => {
form.value.bookingLastSlotStart = HALF_HOUR_TIME_SLOTS[i] || '21:30' form.value.bookingLastSlotStart = HALF_HOUR_TIME_SLOTS[i] || '21:30'
} }
const onBookingCapacityChange = (e) => {
form.value.bookingCapacity = CAPACITY_OPTIONS[Number(e.detail.value)] || 1
}
const hasMapPin = computed(() => form.value.latitude != null && form.value.longitude != null) const hasMapPin = computed(() => form.value.latitude != null && form.value.longitude != null)
const toCoord = (v) => { const toCoord = (v) => {
@ -237,7 +257,8 @@ onMounted(async () => {
latitude: toCoord(storeInfo.latitude), latitude: toCoord(storeInfo.latitude),
longitude: toCoord(storeInfo.longitude), longitude: toCoord(storeInfo.longitude),
bookingDayStart: toHHmm(storeInfo.bookingDayStart) || '09:00', bookingDayStart: toHHmm(storeInfo.bookingDayStart) || '09:00',
bookingLastSlotStart: toHHmm(storeInfo.bookingLastSlotStart) || '21:30' bookingLastSlotStart: toHHmm(storeInfo.bookingLastSlotStart) || '21:30',
bookingCapacity: Number(storeInfo.bookingCapacity || 1)
} }
if (storeInfo.id) { if (storeInfo.id) {
const res = await getStoreDetail(storeInfo.id) const res = await getStoreDetail(storeInfo.id)
@ -245,6 +266,7 @@ onMounted(async () => {
const d = res.data const d = res.data
base.bookingDayStart = toHHmm(d.bookingDayStart) || base.bookingDayStart base.bookingDayStart = toHHmm(d.bookingDayStart) || base.bookingDayStart
base.bookingLastSlotStart = toHHmm(d.bookingLastSlotStart) || base.bookingLastSlotStart base.bookingLastSlotStart = toHHmm(d.bookingLastSlotStart) || base.bookingLastSlotStart
base.bookingCapacity = Number(d.bookingCapacity || base.bookingCapacity)
if (d.name != null) base.name = d.name if (d.name != null) base.name = d.name
if (d.phone != null) base.phone = d.phone if (d.phone != null) base.phone = d.phone
if (d.address != null) base.address = d.address if (d.address != null) base.address = d.address

View File

@ -184,12 +184,16 @@
<ReportShareModal <ReportShareModal
:show="shareModalOpen" :show="shareModalOpen"
:report-id="shareReportId"
:report-token="shareToken" :report-token="shareToken"
:sent="shareSent"
:confirming="confirmingSend"
:pet-name="sharePetName" :pet-name="sharePetName"
:store-name="shareStoreName" :store-name="shareStoreName"
:service-type="shareServiceType" :service-type="shareServiceType"
@close="onShareModalClose" @close="onShareModalClose"
@preview="onSharePreview" @preview="onSharePreview"
@confirm-sent="onConfirmSent"
/> />
</view> </view>
</template> </template>
@ -197,7 +201,7 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue' 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, confirmReportSent, 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 ReportShareModal from '../../components/report/ReportShareModal.vue'
import { getUserSession, getStoreSession } from '../../utils/session.js' import { getUserSession, getStoreSession } from '../../utils/session.js'
@ -239,7 +243,10 @@ const serviceTypes = ref([])
const currentAppointmentId = ref(null) const currentAppointmentId = ref(null)
const submitting = ref(false) const submitting = ref(false)
const shareModalOpen = ref(false) const shareModalOpen = ref(false)
const shareReportId = ref(null)
const shareToken = ref('') const shareToken = ref('')
const shareSent = ref(false)
const confirmingSend = ref(false)
const sharePetName = ref('') const sharePetName = ref('')
const shareStoreName = ref('') const shareStoreName = ref('')
const shareServiceType = ref('') const shareServiceType = ref('')
@ -700,7 +707,6 @@ const submitReport = async () => {
const payload = { const payload = {
appointmentId: currentAppointmentId.value || null, appointmentId: currentAppointmentId.value || null,
userId: userInfo.id,
petName: report.value.petName, petName: report.value.petName,
serviceType: report.value.serviceType, serviceType: report.value.serviceType,
appointmentTime: report.value.appointmentTime || null, appointmentTime: report.value.appointmentTime || null,
@ -712,7 +718,9 @@ 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
shareReportId.value = res.data.reportId
shareToken.value = token shareToken.value = token
shareSent.value = false
sharePetName.value = report.value.petName || '' sharePetName.value = report.value.petName || ''
shareStoreName.value = storeInfo?.name || '' shareStoreName.value = storeInfo?.name || ''
shareServiceType.value = report.value.serviceType || '' shareServiceType.value = report.value.serviceType || ''
@ -728,7 +736,10 @@ const submitReport = async () => {
const onShareModalClose = () => { const onShareModalClose = () => {
shareModalOpen.value = false shareModalOpen.value = false
shareReportId.value = null
shareToken.value = '' shareToken.value = ''
shareSent.value = false
confirmingSend.value = false
sharePetName.value = '' sharePetName.value = ''
shareStoreName.value = '' shareStoreName.value = ''
shareServiceType.value = '' shareServiceType.value = ''
@ -737,7 +748,10 @@ const onShareModalClose = () => {
const onSharePreview = () => { const onSharePreview = () => {
const t = shareToken.value const t = shareToken.value
shareModalOpen.value = false shareModalOpen.value = false
shareReportId.value = null
shareToken.value = '' shareToken.value = ''
shareSent.value = false
confirmingSend.value = false
sharePetName.value = '' sharePetName.value = ''
shareStoreName.value = '' shareStoreName.value = ''
shareServiceType.value = '' shareServiceType.value = ''
@ -746,6 +760,24 @@ const onSharePreview = () => {
} }
} }
const onConfirmSent = async (channel) => {
if (!shareReportId.value || confirmingSend.value || shareSent.value) return
confirmingSend.value = true
try {
const res = await confirmReportSent(shareReportId.value, channel)
if (res.code !== 200) {
uni.showToast({ title: res.message || '确认失败,请重试', icon: 'none' })
return
}
shareSent.value = true
uni.showToast({ title: res.data?.alreadyConfirmed ? '此前已确认发送' : '已记录发送', icon: 'success' })
} catch (e) {
uni.showToast({ title: '确认失败,请检查网络', icon: 'none' })
} finally {
confirmingSend.value = false
}
}
const goBack = () => { const goBack = () => {
const pages = getCurrentPages() const pages = getCurrentPages()
if (pages.length > 1) { if (pages.length > 1) {

View File

@ -8,6 +8,7 @@ const ROOT_ROUTE_MAP = {
const SUB_ROUTE_MAP = { const SUB_ROUTE_MAP = {
staff: '/pages/mine/Staff', staff: '/pages/mine/Staff',
onboarding: '/pages/mine/Onboarding',
serviceType: '/pages/mine/ServiceType', serviceType: '/pages/mine/ServiceType',
store: '/pages/mine/Store', store: '/pages/mine/Store',
profile: '/pages/mine/Profile', profile: '/pages/mine/Profile',