Compare commits
No commits in common. "main" and "phase-a-rc1" have entirely different histories.
main
...
phase-a-rc
@ -1,3 +1,3 @@
|
|||||||
# 安全占位符:提审/体验版构建前必须用受控环境文件覆盖,并运行发布门禁。
|
# 小程序构建时的 API 域名(api.s-good.com 已挪作他用,勿再指向)
|
||||||
VITE_API_ORIGIN=https://api.petstore.invalid
|
# 提审/体验版前请改为宠小它专用域名,并在微信后台配置合法域名
|
||||||
VITE_REPORT_PUBLIC_ORIGIN=https://report.petstore.invalid
|
VITE_API_ORIGIN=http://localhost:8080
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
# 安全占位符:正式构建必须通过 PETSTORE_FRONTEND_ENV_FILE 指向服务器/CI 的受控环境文件,
|
# 宠小它生产 API 域名(api.s-good.com 已挪作他用,勿再指向)
|
||||||
# 并先执行 npm run preflight:release;.invalid 会被门禁拒绝。
|
# 部署前请改为宠小它专用域名,例如 https://api.chongxiaota.example
|
||||||
VITE_API_ORIGIN=https://api.petstore.invalid
|
VITE_API_ORIGIN=http://localhost:8080
|
||||||
VITE_REPORT_PUBLIC_ORIGIN=https://report.petstore.invalid
|
|
||||||
|
|||||||
20
README.md
20
README.md
@ -76,7 +76,9 @@ npm run dev:h5
|
|||||||
|
|
||||||
开发阶段:在微信开发者工具中勾选「不校验合法域名」(设置 → 项目设置)
|
开发阶段:在微信开发者工具中勾选「不校验合法域名」(设置 → 项目设置)
|
||||||
|
|
||||||
生产阶段:仅在微信公众平台后台添加本项目实际 HTTPS 后端域名;localhost 只用于开发,不能进入体验版/正式版。
|
生产阶段:在微信公众平台后台添加以下合法域名:
|
||||||
|
- `http://localhost:8080`(开发用)
|
||||||
|
- 你的实际后端域名
|
||||||
|
|
||||||
## API 配置
|
## API 配置
|
||||||
|
|
||||||
@ -133,10 +135,6 @@ 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 发布门禁
|
||||||
|
|
||||||
发布前必须依次执行并全部通过:
|
发布前必须依次执行并全部通过:
|
||||||
@ -145,23 +143,15 @@ npm run dev:h5
|
|||||||
# 1. 安装依赖
|
# 1. 安装依赖
|
||||||
npm --prefix frontend install
|
npm --prefix frontend install
|
||||||
|
|
||||||
# 2. 用权限受控的真实生产环境文件做门禁(不会打印变量值)
|
# 2. H5 构建
|
||||||
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.
|
||||||
|
|
||||||
# 4. 小程序配置单独门禁并构建
|
# 3. 微信小程序构建
|
||||||
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`)
|
||||||
|
|||||||
@ -4,10 +4,9 @@
|
|||||||
"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 --mode mp-weixin && node scripts/verify-mp-app-json.cjs",
|
"build:mp-weixin": "uni build -p 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",
|
||||||
|
|||||||
@ -1,59 +0,0 @@
|
|||||||
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')
|
|
||||||
@ -59,21 +59,11 @@ 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('/onboarding/register-boss', data)
|
export const registerBoss = (data) => post('/user/register-boss', data)
|
||||||
|
|
||||||
// 门店开通清单
|
// 注册员工
|
||||||
export const getOnboardingStatus = () => get('/admin/onboarding', {})
|
export const registerStaff = (data) => post('/user/register-staff', data)
|
||||||
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', {})
|
||||||
@ -93,12 +83,12 @@ export const getAppointmentList = (/* userId, storeId, */ options = {}) =>
|
|||||||
// 预约详情
|
// 预约详情
|
||||||
export const getAppointmentDetail = (id) => get('/appointment/detail', { id })
|
export const getAppointmentDetail = (id) => get('/appointment/detail', { id })
|
||||||
|
|
||||||
// 创建预约:登录人身份始终由后端派生;customer 传选中门店,门店代客预约传 customerUserId 或 customerPhone
|
// 创建预约(userId/storeId 由后端从上下文派生;前端仍传 storeId 供 customer 选店场景,后端会按 role 决定是否采纳)
|
||||||
export const createAppointment = (data) => post('/appointment/create', data)
|
export const createAppointment = (data) => post('/appointment/create', data)
|
||||||
|
|
||||||
/** 门店某日可预约开始时段;按服务时长与门店容量计算,公开接口。 */
|
/** 门店某日可预约半小时档(可约 / 已满 / 已过);公开接口 */
|
||||||
export const getAppointmentAvailableSlots = (storeId, date, serviceType) =>
|
export const getAppointmentAvailableSlots = (storeId, date) =>
|
||||||
get('/appointment/available-slots', { storeId, date, serviceType })
|
get('/appointment/available-slots', { storeId, date })
|
||||||
|
|
||||||
// 开始服务(staffUserId 由后端从上下文派生;保留旧签名兼容,不传 staffUserId)
|
// 开始服务(staffUserId 由后端从上下文派生;保留旧签名兼容,不传 staffUserId)
|
||||||
export const startAppointment = (appointmentId/*, staffUserId */) =>
|
export const startAppointment = (appointmentId/*, staffUserId */) =>
|
||||||
@ -110,10 +100,6 @@ 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)
|
||||||
|
|
||||||
@ -123,7 +109,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 })
|
||||||
|
|
||||||
/** 报告打开埋点(服务端落低敏 BusinessEvent;失败不影响公开报告阅读) */
|
/** P0:报告打开埋点(服务端记日志,可对接日志平台) */
|
||||||
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' })
|
||||||
|
|
||||||
@ -161,9 +147,8 @@ export const getServiceTypeList = (storeId) => {
|
|||||||
return get('/service-type/list', params)
|
return get('/service-type/list', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建服务类型(storeId 仅保留旧签名兼容,后端从登录态派生)
|
// 创建服务类型
|
||||||
export const createServiceType = (storeId, name, durationMinutes) =>
|
export const createServiceType = (storeId, name) => post('/service-type/create', { storeId, name })
|
||||||
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}`)
|
||||||
@ -171,6 +156,9 @@ 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}`)
|
||||||
|
|
||||||
|
|||||||
@ -6,10 +6,6 @@
|
|||||||
<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>
|
||||||
@ -54,14 +50,6 @@
|
|||||||
<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>
|
||||||
@ -74,9 +62,6 @@ 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: '' },
|
||||||
@ -84,7 +69,7 @@ const props = defineProps({
|
|||||||
serviceType: { type: String, default: '' }
|
serviceType: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['close', 'preview', 'confirm-sent'])
|
const emit = defineEmits(['close', 'preview'])
|
||||||
|
|
||||||
const copiedUrl = ref(false)
|
const copiedUrl = ref(false)
|
||||||
const copiedTplIdx = ref(-1)
|
const copiedTplIdx = ref(-1)
|
||||||
@ -200,19 +185,6 @@ 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>
|
||||||
@ -262,21 +234,6 @@ function confirmAlreadySent() {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
@ -381,16 +338,6 @@ function confirmAlreadySent() {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
{"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"},
|
||||||
|
|||||||
@ -38,17 +38,13 @@
|
|||||||
<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.assignedStaffId || detail.assignedUserId" class="detail-row">
|
<view v-if="detail.assignedUserId" class="detail-row">
|
||||||
<text class="label">服务技师</text>
|
<text class="label">服务技师</text>
|
||||||
<text class="value">#{{ detail.assignedStaffId || detail.assignedUserId }}</text>
|
<text class="value">#{{ 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>
|
||||||
@ -88,7 +84,6 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@ -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="onServiceTypeChange">
|
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
|
||||||
<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,9 +330,8 @@ 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()
|
||||||
|
|
||||||
@ -398,13 +397,11 @@ const slotPickerIndex = computed(() => {
|
|||||||
|
|
||||||
/** 滚轮选项展示为「09:00–09:30」 */
|
/** 滚轮选项展示为「09:00–09:30」 */
|
||||||
const availableSlotLabels = computed(() =>
|
const availableSlotLabels = computed(() =>
|
||||||
availableTimes.value.map((t) => `${t}–${availableSlotEnds.value[t] || formatHalfHourSlotRange(t).slice(6)}`)
|
availableTimes.value.map((t) => formatHalfHourSlotRange(t))
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedSlotRangeLabel = computed(() =>
|
const selectedSlotRangeLabel = computed(() =>
|
||||||
appointmentTime.value
|
appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : ''
|
||||||
? `${appointmentTime.value}–${availableSlotEnds.value[appointmentTime.value] || formatHalfHourSlotRange(appointmentTime.value).slice(6)}`
|
|
||||||
: ''
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const showTryTomorrow = computed(
|
const showTryTomorrow = computed(
|
||||||
@ -436,24 +433,20 @@ const loadAvailableSlots = async () => {
|
|||||||
}
|
}
|
||||||
loadingSlots.value = true
|
loadingSlots.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getAppointmentAvailableSlots(storeId, date, newAppt.value.serviceType || undefined)
|
const res = await getAppointmentAvailableSlots(storeId, date)
|
||||||
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 closing = res.data.closingTime
|
const le = res.data.lastSlotStart
|
||||||
if (ds != null && closing != null) {
|
if (ds != null && le != null) {
|
||||||
const a = String(ds).slice(0, 5)
|
const a = String(ds).slice(0, 5)
|
||||||
const b = String(closing).slice(0, 5)
|
const b = String(le).slice(0, 5)
|
||||||
const mins = Number(res.data.durationMinutes || 60)
|
bookingWindowHint.value = `本店可约 ${a}~${b};以下为半小时时段,同一时段仅一单,已满或已过不可选`
|
||||||
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 = ''
|
||||||
@ -473,7 +466,6 @@ 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' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -502,25 +494,12 @@ 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) => ({
|
serviceTypes.value = res.data.map((s) => ({ label: s.name, value: s.name }))
|
||||||
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))
|
||||||
@ -614,7 +593,7 @@ const confirmNewAppt = async () => {
|
|||||||
uni.showToast({ title: '宠物档案保存失败,请重试', icon: 'none' })
|
uni.showToast({ title: '宠物档案保存失败,请重试', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const payload = { ...a, storeId }
|
const payload = { ...a, storeId, userId: userInfo.id }
|
||||||
payload.petId = ensuredPet.id
|
payload.petId = ensuredPet.id
|
||||||
const res = await createAppointment(payload)
|
const res = await createAppointment(payload)
|
||||||
creatingAppt.value = false
|
creatingAppt.value = false
|
||||||
|
|||||||
@ -199,20 +199,6 @@
|
|||||||
<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="请输入" />
|
||||||
@ -228,7 +214,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="onStaffServiceTypeChange">
|
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
|
||||||
<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>
|
||||||
@ -256,7 +242,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>
|
||||||
@ -401,16 +387,7 @@ const petTypes = [
|
|||||||
{ label: '其他', value: '其他' }
|
{ label: '其他', value: '其他' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const emptyStaffAppointment = () => ({
|
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
|
||||||
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 ''
|
||||||
@ -421,7 +398,6 @@ 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(() => {
|
||||||
@ -431,13 +407,11 @@ const staffSlotPickerIndex = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const staffAvailableSlotLabels = computed(() =>
|
const staffAvailableSlotLabels = computed(() =>
|
||||||
staffAvailableTimes.value.map((t) => `${t}–${staffAvailableSlotEnds.value[t] || formatHalfHourSlotRange(t).slice(6)}`)
|
staffAvailableTimes.value.map((t) => formatHalfHourSlotRange(t))
|
||||||
)
|
)
|
||||||
|
|
||||||
const staffSelectedSlotRangeLabel = computed(() =>
|
const staffSelectedSlotRangeLabel = computed(() =>
|
||||||
appointmentTime.value
|
appointmentTime.value ? formatHalfHourSlotRange(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 => {
|
||||||
@ -528,14 +502,12 @@ const loadStaffAvailableSlots = async () => {
|
|||||||
staffApptDateStr.value = date
|
staffApptDateStr.value = date
|
||||||
staffLoadingSlots.value = true
|
staffLoadingSlots.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getAppointmentAvailableSlots(storeInfo.id, date, newAppt.value.serviceType || undefined)
|
const res = await getAppointmentAvailableSlots(storeInfo.id, date)
|
||||||
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 = ''
|
||||||
@ -555,7 +527,6 @@ const loadStaffAvailableSlots = async () => {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
staffLoadingSlots.value = false
|
staffLoadingSlots.value = false
|
||||||
staffAvailableTimes.value = []
|
staffAvailableTimes.value = []
|
||||||
staffAvailableSlotEnds.value = {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -565,12 +536,6 @@ 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) {
|
||||||
@ -666,11 +631,7 @@ 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 => ({
|
serviceTypes.value = res.data.map(s => ({ label: s.name, value: s.name }))
|
||||||
label: `${s.name} · 约${Number(s.durationMinutes || 60)}分钟`,
|
|
||||||
value: s.name,
|
|
||||||
durationMinutes: Number(s.durationMinutes || 60)
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -710,10 +671,6 @@ 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 }
|
||||||
@ -724,11 +681,11 @@ const confirmNewAppt = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
creatingAppt.value = true
|
creatingAppt.value = true
|
||||||
const res = await createAppointment({ ...a, storeId: storeInfo.id })
|
const res = await createAppointment({ ...a, storeId: storeInfo.id, userId: userInfo.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 = emptyStaffAppointment()
|
newAppt.value = { petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' }
|
||||||
showNewAppt.value = false
|
showNewAppt.value = false
|
||||||
fetchAppointments()
|
fetchAppointments()
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -5,359 +5,610 @@
|
|||||||
<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">
|
<button class="app-btn-code app-btn-code--neutral sms-code-btn" :disabled="smsCountdown > 0" @click="handleSendSms">{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}</button>
|
||||||
{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}
|
|
||||||
</button>
|
|
||||||
</view>
|
</view>
|
||||||
<button class="app-btn-main app-btn-primary login-main-btn" :disabled="loading" @click="handleLogin">
|
<button class="app-btn-main app-btn-primary login-main-btn" :disabled="loginLoading" @click="handleLogin">{{ loginLoading ? '登录中…' : '登录' }}</button>
|
||||||
{{ loading ? '登录中…' : '验证码登录' }}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<view class="divider"><view class="divider-line"/><text class="divider-text">或</text><view class="divider-line"/></view>
|
<view class="divider">
|
||||||
|
<view class="divider-line"></view>
|
||||||
|
<text class="divider-text">或</text>
|
||||||
|
<view class="divider-line"></view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<button class="app-btn-main app-btn-soft" open-type="getPhoneNumber" :disabled="loading" @getphonenumber="onWxLogin">
|
<button
|
||||||
{{ loading ? '登录中…' : '微信手机号登录' }}
|
class="app-btn-main app-btn-soft"
|
||||||
</button>
|
open-type="getPhoneNumber"
|
||||||
|
:disabled="loginLoading"
|
||||||
|
@getphonenumber="onWxGetPhoneNumber"
|
||||||
|
>{{ loginLoading ? '登录中…' : '微信授权登录' }}</button>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<!-- #ifndef MP-WEIXIN -->
|
<!-- #ifndef MP-WEIXIN -->
|
||||||
<button class="app-btn-main app-btn-soft" @click="wechatOnly">微信手机号登录</button>
|
<button class="app-btn-main app-btn-soft" @click="onWechatLoginNotInMp">微信授权登录</button>
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
|
|
||||||
<view class="footer-links">
|
<view class="footer-links">
|
||||||
<text class="link-btn" @click="openStaffInvite">员工受邀加入</text>
|
<text class="link-btn" @click="view = 'staff-reg'">员工注册</text>
|
||||||
<view class="link-dot"/>
|
<view class="link-dot"></view>
|
||||||
<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>
|
||||||
<view class="hint-box">手机号必须由微信核验;创建后继续补全门店资料、预约容量和服务项目。</view>
|
<input v-model="bossForm.storeName" class="field" placeholder="店铺名称" />
|
||||||
<input v-model="bossForm.storeName" class="field" placeholder="门店名称" maxlength="64" />
|
<input v-model="bossForm.bossName" class="field" placeholder="您的姓名" />
|
||||||
<input v-model="bossForm.bossName" class="field" placeholder="老板姓名" maxlength="64" />
|
|
||||||
|
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<button
|
<view class="phone-row">
|
||||||
class="app-btn-main app-btn-primary login-main-btn"
|
<input v-model="bossForm.phone" type="tel" class="field phone-input" placeholder="手机号" maxlength="11" />
|
||||||
open-type="getPhoneNumber"
|
<button class="app-btn-code app-btn-code--brand phone-auth-btn" open-type="getPhoneNumber" @getphonenumber="onWxGetPhoneNumberBoss">授权手机号</button>
|
||||||
:disabled="loading"
|
</view>
|
||||||
@getphonenumber="onRegisterBoss"
|
|
||||||
>{{ loading ? '创建中…' : '微信核验手机号并创建' }}</button>
|
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<!-- #ifndef MP-WEIXIN -->
|
<!-- #ifndef MP-WEIXIN -->
|
||||||
<button class="app-btn-main app-btn-primary login-main-btn" @click="wechatOnly">请在微信小程序完成入驻</button>
|
<input v-model="bossForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" />
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<text class="back-link" @click="backToLogin">← 返回登录</text>
|
<!-- 手机号手动填写时需要验证码 -->
|
||||||
</view>
|
<view v-if="!bossForm.wxPhoneAuto" class="sms-row">
|
||||||
|
<input v-model="bossForm.smsCode" type="digit" class="field sms-input" placeholder="验证码" maxlength="6" />
|
||||||
<view v-else class="card invitation-card">
|
<button class="app-btn-code app-btn-code--neutral sms-code-btn" :disabled="bossSmsCountdown > 0" @click="handleSendSmsBoss">{{ bossSmsCountdown > 0 ? bossSmsCountdown + 's' : '获取验证码' }}</button>
|
||||||
<text class="card-title">接受员工邀请</text>
|
</view>
|
||||||
<view v-if="!invitation" class="hint-box">粘贴老板发来的邀请口令,或直接从邀请卡片进入。</view>
|
<view v-else class="wx-verified-hint">✓ 微信已认证手机号</view>
|
||||||
<view v-if="!invitation" class="token-row">
|
<input v-model="bossForm.password" type="password" class="field" placeholder="登录密码(至少6位)" />
|
||||||
<input v-model="inviteToken" class="field token-input" placeholder="邀请口令" maxlength="512" />
|
<button class="app-btn-main app-btn-primary login-main-btn" :disabled="regLoading" @click="handleRegisterBoss">{{ regLoading ? '提交中…' : '提交申请' }}</button>
|
||||||
<button class="app-btn-code app-btn-code--brand" :disabled="previewLoading" @click="loadInvitation">
|
<text class="back-link" @click="backToLoginForm">← 返回登录</text>
|
||||||
{{ previewLoading ? '查询中' : '查询' }}
|
|
||||||
</button>
|
|
||||||
</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>
|
</view>
|
||||||
|
|
||||||
|
<!-- 员工注册 -->
|
||||||
|
<view v-else-if="view === 'staff-reg'" class="card">
|
||||||
|
<text class="card-title">员工注册</text>
|
||||||
|
<view class="hint-box">请输入店长提供的邀请码加入团队</view>
|
||||||
|
<input v-model="staffForm.inviteCode" class="field" placeholder="8位邀请码" maxlength="8" />
|
||||||
|
<input v-model="staffForm.name" class="field" placeholder="您的姓名" />
|
||||||
<!-- #ifdef MP-WEIXIN -->
|
<!-- #ifdef MP-WEIXIN -->
|
||||||
<button
|
<view class="phone-row">
|
||||||
v-if="invitation?.canAccept"
|
<input v-model="staffForm.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="onWxGetPhoneNumberStaff">授权手机号</button>
|
||||||
open-type="getPhoneNumber"
|
</view>
|
||||||
:disabled="loading"
|
|
||||||
@getphonenumber="onAcceptInvitation"
|
|
||||||
>{{ loading ? '核验中…' : invitation.status === 'accepted' ? '微信核验手机号并登录' : '微信核验手机号并加入' }}</button>
|
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<!-- #ifndef MP-WEIXIN -->
|
<!-- #ifndef MP-WEIXIN -->
|
||||||
<view v-if="invitation?.canAccept" class="non-mp-notice">请在微信小程序内完成手机号核验与加入。</view>
|
<input v-model="staffForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" />
|
||||||
<!-- #endif -->
|
<!-- #endif -->
|
||||||
<button v-if="invitation" class="app-btn-main app-btn-soft secondary-btn" @click="resetInvitation">更换邀请口令</button>
|
<!-- 手机号手动填写时需要验证码 -->
|
||||||
<text class="back-link" @click="backToLogin">← 返回登录</text>
|
<view v-if="!staffForm.wxPhoneAuto" class="sms-row">
|
||||||
|
<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 { computed, onUnmounted, reactive, ref } from 'vue'
|
import { ref, reactive, onUnmounted } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import {
|
import { sendSms, login, wxPhoneLogin, registerBoss, registerStaff } from '../../api/index.js'
|
||||||
acceptStaffInvitation,
|
import { setStoreSession, setUserSession, setSessionToken } from '../../utils/session.js'
|
||||||
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: '', code: '' })
|
const loginForm = reactive({ phone: '13800138001', code: '123456' })
|
||||||
const bossForm = reactive({ storeName: '', bossName: '' })
|
const bossForm = reactive({ storeName: '', bossName: '', phone: '', password: '', smsCode: '', wxPhoneAuto: false })
|
||||||
const loading = ref(false)
|
const staffForm = reactive({ inviteCode: '', name: '', phone: '', password: '', smsCode: '', wxPhoneAuto: false })
|
||||||
const previewLoading = ref(false)
|
const loginLoading = ref(false)
|
||||||
|
const regLoading = ref(false)
|
||||||
const smsCountdown = ref(0)
|
const smsCountdown = ref(0)
|
||||||
const inviteToken = ref('')
|
const bossSmsCountdown = ref(0)
|
||||||
const invitation = ref(null)
|
const staffSmsCountdown = ref(0)
|
||||||
const redirectAfterLogin = ref('')
|
let bossSmsTimer = null
|
||||||
|
let staffSmsTimer = null
|
||||||
|
|
||||||
let smsTimer = null
|
let smsTimer = null
|
||||||
|
|
||||||
const showToast = (title) => uni.showToast({ title, icon: 'none' })
|
const parseBossRegData = (data) => {
|
||||||
const isOk = (response) => response && Number(response.code) === 200
|
if (!data || typeof data !== 'object') return null
|
||||||
|
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 invitationStatus = computed(() => ({
|
const parseStaffRegData = (data) => {
|
||||||
pending: '邀请待接受',
|
if (!data || typeof data !== 'object') return null
|
||||||
accepted: '已加入,可核验本人手机号登录',
|
const st = data.store
|
||||||
revoked: '邀请已撤销',
|
const u = data.user
|
||||||
expired: '邀请已过期'
|
if (!st || typeof st !== 'object' || Array.isArray(st)) return null
|
||||||
}[invitation.value?.status] || '状态未知'))
|
if (!u || typeof u !== 'object' || Array.isArray(u)) return null
|
||||||
|
return {
|
||||||
|
storeName: uString(st.name),
|
||||||
|
phone: uString(u.phone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseOption(value) {
|
const uString = (v) => {
|
||||||
if (value == null) return ''
|
if (v == 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 {
|
||||||
return decodeURIComponent(String(value)).trim()
|
s = decodeURIComponent(s)
|
||||||
|
if (/%[0-9A-Fa-f]{2}/.test(s)) s = decodeURIComponent(s)
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeRedirect(path) {
|
function isSafeLoginRedirect(path) {
|
||||||
return typeof path === 'string' && path.startsWith('/pages/') && !path.includes('..') && !path.includes('//')
|
return (
|
||||||
|
typeof path === 'string' &&
|
||||||
|
path.startsWith('/pages/') &&
|
||||||
|
!path.includes('..') &&
|
||||||
|
!path.includes('//')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad((options) => {
|
const redirectAfterLogin = ref('')
|
||||||
redirectAfterLogin.value = parseOption(options?.redirect)
|
|
||||||
const token = parseOption(options?.staffInvite || options?.token)
|
|
||||||
if (token) {
|
|
||||||
inviteToken.value = token
|
|
||||||
view.value = 'staff-invite'
|
|
||||||
loadInvitation()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchWxLoginCode = () => new Promise((resolve) => {
|
function navigateAfterLogin() {
|
||||||
// #ifdef MP-WEIXIN
|
const r = redirectAfterLogin.value
|
||||||
uni.login({ success: (result) => resolve(result?.code || ''), fail: () => resolve('') })
|
if (isSafeLoginRedirect(r)) {
|
||||||
// #endif
|
uni.reLaunch({ url: r })
|
||||||
// #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')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSendSms() {
|
onLoad((options) => {
|
||||||
if (!/^1[3-9]\d{9}$/.test(loginForm.phone)) return showToast('请输入正确手机号')
|
view.value = 'login'
|
||||||
const response = await sendSms(loginForm.phone)
|
const raw = options && (options.phone ?? options.prefillPhone)
|
||||||
if (!isOk(response)) return showToast(response.message || '验证码发送失败')
|
const p = raw == null ? '' : String(raw).trim()
|
||||||
|
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 -= 1
|
smsCountdown.value--
|
||||||
if (smsCountdown.value <= 0 && smsTimer) clearInterval(smsTimer)
|
if (smsCountdown.value <= 0) clearInterval(smsTimer)
|
||||||
}, 1000)
|
}, 1000)
|
||||||
|
} else {
|
||||||
|
showToast(res.message || '发送失败')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
const handleSendSmsBoss = async () => {
|
||||||
if (!/^1[3-9]\d{9}$/.test(loginForm.phone) || loginForm.code.length !== 6) {
|
if (!bossForm.phone || bossForm.phone.length !== 11) return showToast('请输入正确的手机号')
|
||||||
return showToast('请填写手机号和 6 位验证码')
|
const res = await sendSms(bossForm.phone)
|
||||||
|
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 response = await login(loginForm.phone, loginForm.code)
|
const res = await login(loginForm.phone, loginForm.code)
|
||||||
if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '登录失败')
|
if (res.code === 200) {
|
||||||
goAfterLogin()
|
setUserSession(res.data.user)
|
||||||
} catch (_) {
|
setStoreSession(res.data.store)
|
||||||
showToast('网络异常,请稍后重试')
|
if (res.data.sessionToken) setSessionToken(res.data.sessionToken)
|
||||||
|
navigateAfterLogin()
|
||||||
|
} else {
|
||||||
|
showToast(res.message || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('网络异常,请检查服务是否启动')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loginLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function validPhoneEvent(event) {
|
/** 非微信小程序:无法调起手机号授权 */
|
||||||
const detail = event?.detail || {}
|
const onWechatLoginNotInMp = () => {
|
||||||
const error = String(detail.errMsg || '')
|
showToast('微信授权登录仅支持在微信小程序内使用,请使用验证码登录')
|
||||||
if (error.includes('deny') || error.includes('cancel')) {
|
|
||||||
showToast('需要授权本人手机号才能继续')
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
if (!detail.code) showToast('未获取到手机号授权码,请重试')
|
|
||||||
return detail.code || ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onWxLogin(event) {
|
const getWxLoginPhone = (res) => {
|
||||||
const phoneCode = validPhoneEvent(event)
|
if (!isHttpOk(res)) return ''
|
||||||
if (!phoneCode) return
|
return uString(res?.data?.user?.phone || res?.data?.phone)
|
||||||
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 response = await wxPhoneLogin(phoneCode, loginCode)
|
const res = await wxPhoneLogin(code, loginCode)
|
||||||
if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '登录失败')
|
if (res.code === 200) {
|
||||||
goAfterLogin()
|
setUserSession(res.data.user)
|
||||||
} catch (_) {
|
setStoreSession(res.data.store)
|
||||||
showToast('网络异常,请稍后重试')
|
if (res.data.sessionToken) setSessionToken(res.data.sessionToken)
|
||||||
|
navigateAfterLogin()
|
||||||
|
} else {
|
||||||
|
showToast(res.message || '登录失败')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast('网络异常,请检查服务是否启动')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loginLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onRegisterBoss(event) {
|
/** 商家入驻:微信获取手机号并填入 */
|
||||||
if (!bossForm.storeName.trim()) return showToast('请填写门店名称')
|
const onWxGetPhoneNumberBoss = async (e) => {
|
||||||
if (!bossForm.bossName.trim()) return showToast('请填写老板姓名')
|
const d = e.detail || {}
|
||||||
const phoneCode = validPhoneEvent(event)
|
const errMsg = d.errMsg || ''
|
||||||
if (!phoneCode) return
|
if (errMsg.includes('deny') || errMsg.includes('cancel')) return
|
||||||
loading.value = true
|
const code = d.code
|
||||||
|
if (!code) { showToast('未获取到授权,请手动输入'); return }
|
||||||
|
loginLoading.value = true
|
||||||
try {
|
try {
|
||||||
const loginCode = await fetchWxLoginCode()
|
const loginCode = await fetchWxLoginCode()
|
||||||
const response = await registerBoss({
|
const res = await wxPhoneLogin(code, loginCode)
|
||||||
storeName: bossForm.storeName.trim(),
|
const phone = getWxLoginPhone(res)
|
||||||
bossName: bossForm.bossName.trim(),
|
if (phone) {
|
||||||
phoneCode,
|
bossForm.phone = phone
|
||||||
loginCode
|
bossForm.wxPhoneAuto = true
|
||||||
})
|
showToast('手机号已获取')
|
||||||
if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '门店创建失败')
|
} 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({
|
uni.showModal({
|
||||||
title: '门店已创建',
|
title: '入驻成功',
|
||||||
content: '接下来补全门店资料、确认预约容量和服务项目,即可完成开通。',
|
content,
|
||||||
showCancel: false,
|
cancelText: '前往登录',
|
||||||
confirmText: '继续开通',
|
confirmText: '复制邀请码',
|
||||||
success: () => uni.reLaunch({ url: '/pages/mine/Onboarding' })
|
success: (r) => {
|
||||||
|
if (r.confirm && code && code !== '—') {
|
||||||
|
uni.setClipboardData({ data: code, success: () => uni.showToast({ title: '已复制', icon: 'none' }) })
|
||||||
|
}
|
||||||
|
backToLoginForm()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} catch (_) {
|
} else {
|
||||||
showToast('网络异常,请稍后重试')
|
showToast('注册成功,请用手机号登录')
|
||||||
} finally {
|
}
|
||||||
loading.value = false
|
} else {
|
||||||
|
showToast(res.message || '注册失败')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openStaffInvite() {
|
const handleRegisterStaff = async () => {
|
||||||
view.value = 'staff-invite'
|
const f = staffForm
|
||||||
}
|
if (!f.inviteCode || f.inviteCode.length !== 8) return showToast('请输入8位邀请码')
|
||||||
|
if (!f.name) return showToast('请输入您的姓名')
|
||||||
async function loadInvitation() {
|
if (!f.phone || f.phone.length !== 11) return showToast('请输入正确的手机号')
|
||||||
const token = inviteToken.value.trim()
|
if (!f.wxPhoneAuto && (!f.smsCode || f.smsCode.length !== 6)) return showToast('请输入6位验证码')
|
||||||
if (!token) return showToast('请粘贴邀请口令')
|
if (!f.password || f.password.length < 6) return showToast('密码至少6位')
|
||||||
previewLoading.value = true
|
const payload = { ...f }
|
||||||
try {
|
if (f.wxPhoneAuto) delete payload.smsCode
|
||||||
const response = await previewStaffInvitation(token)
|
regLoading.value = true
|
||||||
if (!isOk(response)) {
|
const res = await registerStaff(payload)
|
||||||
invitation.value = null
|
regLoading.value = false
|
||||||
return showToast(response.message || '邀请不存在或已失效')
|
if (isHttpOk(res)) {
|
||||||
}
|
const parsed = parseStaffRegData(res.data)
|
||||||
invitation.value = response.data
|
if (parsed) {
|
||||||
} catch (_) {
|
const shop = safeLine(parsed.storeName, '未知店铺')
|
||||||
showToast('邀请查询失败,请检查网络')
|
const phone = safeLine(parsed.phone, '—')
|
||||||
} 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({
|
uni.showModal({
|
||||||
title: '已加入门店',
|
title: '注册成功',
|
||||||
content: `你已加入「${invitation.value.storeName}」,现在可以开始处理门店工作。`,
|
content: `已加入「${shop}」\n账号:${phone}`,
|
||||||
showCancel: false,
|
showCancel: false,
|
||||||
confirmText: '进入工作台',
|
confirmText: '前往登录',
|
||||||
success: () => openAppPage('home')
|
complete: () => backToLoginForm()
|
||||||
})
|
})
|
||||||
} catch (_) {
|
} else {
|
||||||
showToast('网络异常,请稍后重试')
|
showToast('注册成功,请用手机号登录')
|
||||||
} finally {
|
}
|
||||||
loading.value = false
|
} else {
|
||||||
|
showToast(res.message || '注册失败')
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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 { min-height: 100vh; padding: 0 24px 40px; background: #fafaf8; box-sizing: border-box; }
|
.login-page {
|
||||||
.login-brand { padding: 76px 0 36px; text-align: center; }
|
min-height: 100vh;
|
||||||
.brand-name { display: block; color: #1a1a1a; font-size: 28px; font-weight: 800; letter-spacing: 1px; }
|
background: #fafaf8;
|
||||||
.brand-sub { display: block; margin-top: 6px; color: var(--c-text-3); font-size: 13px; }
|
display: flex;
|
||||||
.card { padding: 28px 24px; background: #fff; border-radius: 20px; box-shadow: 0 2px 12px rgba(0,0,0,.04); }
|
flex-direction: column;
|
||||||
.card-title { display: block; margin-bottom: 20px; color: #1a1a1a; font-size: 20px; font-weight: 700; }
|
padding: 0 24px;
|
||||||
.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; }
|
/* 品牌 */
|
||||||
.sms-code-btn { flex-shrink: 0; align-self: center; }
|
.login-brand {
|
||||||
|
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 { color: #bbbbb5; font-size: 12px; }
|
.divider-text { font-size: 12px; color: #bbbbb5; }
|
||||||
.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; }
|
/* 底部链接 */
|
||||||
.link-dot { width: 3px; height: 3px; background: #ddddd8; border-radius: 50%; }
|
.footer-links {
|
||||||
.back-link { display: block; padding: 8px; margin-top: 20px; color: var(--c-text-3); font-size: 14px; text-align: center; }
|
display: flex;
|
||||||
.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; }
|
justify-content: 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; }
|
align-items: center;
|
||||||
.preview-kicker { display: block; color: var(--c-text-3); font-size: 11px; font-weight: 700; letter-spacing: .12em; }
|
gap: 16px;
|
||||||
.preview-store { display: block; margin: 6px 0 16px; color: #193326; font-size: 21px; font-weight: 800; }
|
margin-top: 28px;
|
||||||
.preview-row { display: flex; justify-content: space-between; padding: 8px 0; color: #6b756f; font-size: 13px; border-top: 1px solid #edf3ef; }
|
padding-top: 20px;
|
||||||
.preview-row text:last-child { color: #25342c; font-weight: 600; }
|
border-top: 1px solid #f0f0ee;
|
||||||
.status-pill { display: inline-flex; padding: 5px 9px; margin-top: 12px; font-size: 12px; font-weight: 700; border-radius: 999px; }
|
}
|
||||||
.status-pending { color: #9a6500; background: #fff1c7; }
|
.link-btn {
|
||||||
.status-accepted { color: #147340; background: #dff6e9; }
|
font-size: 14px;
|
||||||
.status-revoked,.status-expired { color: #7c8189; background: #eef0f2; }
|
color: var(--c-brand);
|
||||||
.non-mp-notice { padding: 12px; color: #7b6a3d; font-size: 13px; text-align: center; background: #fff8e6; border-radius: 10px; }
|
font-weight: 600;
|
||||||
|
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>
|
||||||
|
|||||||
@ -19,11 +19,6 @@
|
|||||||
<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>
|
||||||
@ -120,7 +115,6 @@ 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' })
|
||||||
|
|||||||
@ -1,175 +0,0 @@
|
|||||||
<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>
|
|
||||||
@ -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) }} · 容量 {{ row.usedCapacity || 0 }}/{{ row.totalCapacity || 1 }}</text>
|
<text class="sched-line-sub">{{ apptStatusText(row.appointment.status) }}</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,7 +212,6 @@ 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
|
||||||
|
|||||||
@ -37,7 +37,6 @@
|
|||||||
<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>
|
||||||
@ -59,13 +58,6 @@
|
|||||||
<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">
|
||||||
@ -79,7 +71,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, 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'
|
||||||
@ -110,10 +102,6 @@ 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)
|
||||||
@ -123,17 +111,12 @@ 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, newDuration.value)
|
const res = await createServiceType(storeInfo.id, newName.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 {
|
||||||
@ -210,8 +193,6 @@ 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 {
|
||||||
|
|||||||
@ -1,290 +1,392 @@
|
|||||||
<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="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
|
<view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
|
||||||
<view class="nav-title">员工与邀请</view>
|
<view class="nav-title">员工管理</view>
|
||||||
<view class="nav-placeholder" />
|
<view class="nav-placeholder"></view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="page-body">
|
<!-- 邀请码:标题与码+按钮分行,同一行内码与复制垂直居中 -->
|
||||||
<view class="invite-hero">
|
<view class="page-section invite-wrap">
|
||||||
<text class="invite-kicker">ONE-TIME INVITE</text>
|
<text class="invite-kicker">员工邀请码</text>
|
||||||
<text class="invite-title">邀请员工本人加入</text>
|
<view class="invite-code-row">
|
||||||
<text class="invite-desc">邀请绑定微信手机号、限时且只能使用一次。原始口令只在创建后显示,不再使用永久门店邀请码。</text>
|
<text class="invite-code-text" selectable>{{ inviteCodeText }}</text>
|
||||||
<button class="van-button van-button--primary van-button--block hero-btn" @click="showCreate = true">创建员工邀请</button>
|
<button class="invite-copy-btn" hover-class="invite-copy-btn--hover" @click="copyCode">复制</button>
|
||||||
|
</view>
|
||||||
|
<text class="invite-tip">新员工注册时填写此码即可加入本店</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="section-head"><text>门店成员</text><text>{{ staffList.length }} 人</text></view>
|
<view class="page-section add-btn-wrap">
|
||||||
<view class="list-card">
|
<button class="van-button van-button--primary van-button--block" @click="showAddStaff = true">新增员工</button>
|
||||||
<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="list-card">
|
<view class="page-section section-gap">
|
||||||
<view v-for="item in invitations" :key="item.invitationId" class="invitation-row">
|
<view class="van-cell-group card-section">
|
||||||
<view class="invitation-main">
|
<view
|
||||||
<view class="invitation-title-row">
|
v-for="(s, idx) in staffList"
|
||||||
<text class="staff-name">{{ item.invitedName }}</text>
|
:key="s.id != null ? s.id : 'row-' + idx"
|
||||||
<text class="status-pill" :class="`status-${item.status}`">{{ statusLabel(item.status) }}</text>
|
class="staff-swipe-wrap"
|
||||||
|
: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="showCreate" class="popup-mask" @click="showCreate = false">
|
<view v-if="staffList.length === 0" class="empty"><text>暂无员工</text></view>
|
||||||
|
|
||||||
|
<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"><text class="popup-title">创建员工邀请</text><text class="popup-close" @click="showCreate = false">✕</text></view>
|
<view class="popup-header">
|
||||||
|
<view class="popup-title">新增员工</view>
|
||||||
|
<view class="popup-close" @click="showAddStaff = false">✕</view>
|
||||||
|
</view>
|
||||||
<view class="popup-body">
|
<view class="popup-body">
|
||||||
<text class="popup-desc">请填写员工本人用于微信授权的手机号。手机号不一致时系统会拒绝加入。</text>
|
<view class="popup-desc">创建后会自动生成初始密码,员工可使用手机号登录。</view>
|
||||||
<text class="field-label">员工姓名</text>
|
<view class="field-label">员工姓名</view>
|
||||||
<input v-model="form.name" class="van-field" placeholder="请输入" maxlength="64" />
|
<input v-model="newStaff.name" class="van-field" placeholder="请输入" />
|
||||||
<text class="field-label">本人微信手机号</text>
|
<view class="field-label">手机号</view>
|
||||||
<input v-model="form.phone" type="tel" class="van-field" placeholder="请输入" maxlength="11" />
|
<input v-model="newStaff.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">
|
||||||
<button class="van-button btn-ghost" @click="showCreate = false">取消</button>
|
<view class="popup-actions">
|
||||||
<button class="van-button van-button--primary btn-confirm" :disabled="creating" @click="createInvite">
|
<button class="van-button btn-ghost" @click="showAddStaff = false">取消</button>
|
||||||
{{ creating ? '创建中…' : '创建邀请' }}
|
<button class="van-button van-button--primary btn-confirm" @click="confirmAddStaff">确认创建</button>
|
||||||
</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 { computed, reactive, ref } from 'vue'
|
import { ref, computed, onMounted } 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 staffList = ref([])
|
const emit = defineEmits(['change-page'])
|
||||||
const invitations = ref([])
|
const storeInfo = getStoreSession()
|
||||||
const showCreate = ref(false)
|
const inviteCodeText = computed(() => (storeInfo && storeInfo.inviteCode) ? String(storeInfo.inviteCode) : '—')
|
||||||
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 = (() => {
|
||||||
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
let 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?.top && menuRect?.height) {
|
if (menuRect && menuRect.top && menuRect.height) {
|
||||||
const gap = Math.max(menuRect.top - statusBarHeight, 4)
|
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4)
|
||||||
navHeight = statusBarHeight + gap * 2 + menuRect.height
|
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height
|
||||||
}
|
}
|
||||||
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
|
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
|
||||||
})()
|
})()
|
||||||
|
|
||||||
async function load() {
|
const goBackToMine = () => {
|
||||||
const [staffResponse, invitationResponse] = await Promise.all([getStaffList(), getStaffInvitations()])
|
const pages = getCurrentPages()
|
||||||
if (staffResponse.code === 200) staffList.value = Array.isArray(staffResponse.data) ? staffResponse.data : []
|
if (pages.length > 1) {
|
||||||
if (invitationResponse.code === 200) invitations.value = Array.isArray(invitationResponse.data) ? invitationResponse.data : []
|
uni.navigateBack()
|
||||||
}
|
|
||||||
|
|
||||||
function onValidDayChange(event) {
|
|
||||||
form.validDays = validDays[Number(event.detail.value)] || 7
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createInvite() {
|
|
||||||
if (!form.name.trim()) return uni.showToast({ title: '请输入员工姓名', icon: 'none' })
|
|
||||||
if (!/^1[3-9]\d{9}$/.test(form.phone.trim())) return uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
const response = await createStaffInvitation({
|
|
||||||
name: form.name.trim(),
|
|
||||||
phone: form.phone.trim(),
|
|
||||||
validDays: form.validDays
|
|
||||||
})
|
|
||||||
if (response.code !== 200 || !response.data?.inviteToken) {
|
|
||||||
uni.showToast({ title: response.message || '邀请创建失败', icon: 'none' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
createdInvite.value = response.data
|
openAppPage('mine')
|
||||||
showCreate.value = false
|
|
||||||
form.name = ''
|
|
||||||
form.phone = ''
|
|
||||||
await load()
|
|
||||||
} finally {
|
|
||||||
creating.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCreated() {
|
const staffInitial = (s) => {
|
||||||
createdInvite.value = null
|
const n = s && s.name
|
||||||
|
return (typeof n === 'string' && n.length > 0) ? n[0] : '?'
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyInvite() {
|
const staffList = ref([])
|
||||||
if (!createdInvite.value?.inviteToken) return
|
const showAddStaff = ref(false)
|
||||||
const message = [
|
const newStaff = ref({ name: '', phone: '' })
|
||||||
`【宠小它员工邀请】${createdInvite.value.storeName} 邀请 ${createdInvite.value.invitedName} 加入门店。`,
|
const swipedId = ref(null)
|
||||||
`邀请口令:${createdInvite.value.inviteToken}`,
|
const touchStartX = ref(0)
|
||||||
`小程序路径:${createdInvite.value.invitePath}`,
|
const touchCurrentX = ref(0)
|
||||||
`有效期至:${formatDate(createdInvite.value.expiresAt)}`,
|
|
||||||
'请使用受邀手机号在宠小它小程序完成微信核验。'
|
const COLORS = ['#f5913e', 'var(--c-brand)', '#8b6914', '#e06050', '#5090d0', '#9b59b6']
|
||||||
].join('\n')
|
const avatarStyle = (s) => {
|
||||||
uni.setClipboardData({ data: message, success: () => uni.showToast({ title: '邀请已复制', icon: 'none' }) })
|
if (s.avatar) return { background: 'transparent' }
|
||||||
|
const idx = (s.name?.charCodeAt(0) || 0) % COLORS.length
|
||||||
|
return { background: COLORS[idx] }
|
||||||
}
|
}
|
||||||
|
|
||||||
function revoke(item) {
|
const loadStaff = async () => {
|
||||||
uni.showModal({
|
const res = await getStaffList(storeInfo.id)
|
||||||
title: '撤销邀请',
|
if (res.code === 200) staffList.value = res.data
|
||||||
content: `确认撤销发给「${item.invitedName}」的邀请?`,
|
}
|
||||||
success: async (result) => {
|
|
||||||
if (!result.confirm) return
|
const copyCode = () => {
|
||||||
const response = await revokeStaffInvitation(item.invitationId)
|
const code = inviteCodeText.value === '—' ? '' : inviteCodeText.value
|
||||||
if (response.code === 200) {
|
if (!code) return
|
||||||
uni.showToast({ title: '邀请已撤销', icon: 'success' })
|
uni.setClipboardData({
|
||||||
load()
|
data: code,
|
||||||
} else {
|
success: () => uni.showToast({ title: '邀请码已复制', icon: 'none' })
|
||||||
uni.showToast({ title: response.message || '撤销失败', icon: 'none' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeStaff(staff) {
|
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
|
||||||
|
}
|
||||||
|
if (deltaX > 24 && swipedId.value === staff.id) {
|
||||||
|
swipedId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onRowTap = (staff) => {
|
||||||
|
if (swipedId.value && swipedId.value !== staff.id) {
|
||||||
|
swipedId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteStaff = async (staffId) => {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '删除员工',
|
title: '提示',
|
||||||
content: `确认删除「${staff.name}」?删除后其门店访问权限将失效。`,
|
content: '确定删除该员工?',
|
||||||
success: async (result) => {
|
success: async (res) => {
|
||||||
if (!result.confirm) return
|
if (!res.confirm) return
|
||||||
const response = await deleteStaff(staff.id)
|
const r = await delStaff(staffId)
|
||||||
if (response.code === 200) {
|
if (r.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' })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status) {
|
onMounted(() => loadStaff())
|
||||||
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 { min-height: 100vh; padding-bottom: 100rpx; background: #f6f8f7; }
|
.staff-page { padding-bottom: 120rpx; }
|
||||||
.staff-nav { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; }
|
.nav-placeholder { width: 32px; }
|
||||||
.nav-back,.nav-placeholder { width: 32px; }
|
.add-btn-wrap { padding: 0; }
|
||||||
.nav-title { color: #fff; font-size: 18px; font-weight: 700; }
|
.card-section { margin: 0; }
|
||||||
.page-body { padding: 14px; }
|
.staff-cell { min-height: 58px; }
|
||||||
.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-title { display: block; margin: 7px 0 5px; color: #1d3327; font-size: 21px; font-weight: 800; }
|
.invite-wrap {
|
||||||
.invite-desc { display: block; color: #738078; font-size: 13px; line-height: 1.55; }
|
background: #fff;
|
||||||
.hero-btn { margin: 16px 0 0 !important; }
|
border-radius: 14px;
|
||||||
.section-head { display: flex; justify-content: space-between; padding: 20px 4px 8px; color: #657269; font-size: 13px; font-weight: 700; }
|
padding: 16px 16px 14px;
|
||||||
.section-head text:last-child { color: #9aa39d; font-weight: 500; }
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||||
.list-card { overflow: hidden; background: #fff; border: 1px solid #e7ebe8; border-radius: 14px; }
|
border: 1px solid #f0f0ee;
|
||||||
.staff-row,.invitation-row { display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 12px 14px; }
|
}
|
||||||
.staff-row + .staff-row,.invitation-row + .invitation-row { border-top: 1px solid #eff2f0; }
|
.invite-kicker {
|
||||||
.avatar { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 40px; height: 40px; color: #fff; font-weight: 700; border-radius: 12px; }
|
display: block;
|
||||||
.staff-copy,.invitation-main { display: grid; flex: 1; gap: 3px; min-width: 0; }
|
font-size: 12px;
|
||||||
.staff-name { color: #28342d; font-size: 15px; font-weight: 700; }
|
font-weight: 700;
|
||||||
.staff-meta { color: #8a948e; font-size: 12px; }
|
color: var(--c-text-3);
|
||||||
.delete-link { flex-shrink: 0; padding: 8px 0 8px 10px; color: #dc5b5b; font-size: 12px; }
|
letter-spacing: 0.5px;
|
||||||
.invitation-head { margin-top: 4px; }
|
margin-bottom: 10px;
|
||||||
.invitation-title-row { display: flex; align-items: center; gap: 8px; }
|
}
|
||||||
.status-pill { padding: 3px 7px; color: #946700; font-size: 10px; font-weight: 700; background: #fff1c5; border-radius: 999px; }
|
.invite-code-row {
|
||||||
.status-accepted { color: #177440; background: #dcf5e7; }
|
display: flex;
|
||||||
.status-revoked { color: #a14b4b; background: #fde7e7; }
|
flex-direction: row;
|
||||||
.status-expired { color: #737d77; background: #edf0ee; }
|
align-items: center;
|
||||||
.empty-row { padding: 28px; color: #a1aaa4; font-size: 13px; text-align: center; }
|
justify-content: space-between;
|
||||||
.popup-mask { position: fixed; inset: 0; z-index: 100; display: flex; align-items: flex-end; justify-content: center; background: rgba(0,0,0,.48); }
|
gap: 12px;
|
||||||
.popup-content { width: 100%; max-width: 430px; background: #fff; border-radius: 18px 18px 0 0; }
|
min-height: 44px;
|
||||||
.popup-header { display: flex; align-items: center; justify-content: space-between; padding: 17px 20px; border-bottom: 1px solid #f0f2f0; }
|
}
|
||||||
.popup-title { font-size: 17px; font-weight: 700; }
|
.invite-code-text {
|
||||||
.popup-close { color: #9ca39f; }
|
flex: 1;
|
||||||
.popup-body { padding: 18px 20px; }
|
min-width: 0;
|
||||||
.popup-desc { display: block; margin-bottom: 14px; color: #77817b; font-size: 12px; line-height: 1.5; }
|
font-size: 20px;
|
||||||
.field-label { display: block; margin: 12px 0 6px; color: #626d66; font-size: 12px; font-weight: 700; }
|
font-weight: 700;
|
||||||
.picker-field { display: flex; align-items: center; justify-content: space-between; }
|
font-family: ui-monospace, Menlo, Monaco, Consolas, monospace;
|
||||||
.popup-footer,.share-actions { display: flex; gap: 10px; padding: 12px 20px 22px; }
|
color: #1a1a1a;
|
||||||
.btn-ghost { width: 104px; color: #647168 !important; background: #fff !important; border: 1px solid #dfe5e1 !important; }
|
letter-spacing: 0.12em;
|
||||||
|
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>
|
||||||
|
|||||||
@ -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">
|
<view class="store-field store-field-last">
|
||||||
<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 表示营业容量至 22:00)</text>
|
<text class="store-field-tip">当日最后一个可预约的半点起始时刻(例如 21:30 表示最晚可约 21:00–21:30 这一档)</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,19 +97,6 @@
|
|||||||
</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">
|
||||||
@ -160,7 +147,6 @@ 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: '',
|
||||||
@ -169,8 +155,7 @@ 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) => {
|
||||||
@ -184,7 +169,6 @@ 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)
|
||||||
@ -196,10 +180,6 @@ 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) => {
|
||||||
@ -257,8 +237,7 @@ 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)
|
||||||
@ -266,7 +245,6 @@ 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
|
||||||
|
|||||||
@ -184,16 +184,12 @@
|
|||||||
|
|
||||||
<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>
|
||||||
@ -201,7 +197,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, confirmReportSent, getServiceTypeList, imgUrl, BASE_URL } from '../../api/index.js'
|
import { createReport, getServiceTypeList, imgUrl, BASE_URL } from '../../api/index.js'
|
||||||
import AppIcon from '../../components/AppIcon.vue'
|
import AppIcon from '../../components/AppIcon.vue'
|
||||||
import ReportShareModal from '../../components/report/ReportShareModal.vue'
|
import ReportShareModal from '../../components/report/ReportShareModal.vue'
|
||||||
import { getUserSession, getStoreSession } from '../../utils/session.js'
|
import { getUserSession, getStoreSession } from '../../utils/session.js'
|
||||||
@ -243,10 +239,7 @@ 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('')
|
||||||
@ -707,6 +700,7 @@ 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,
|
||||||
@ -718,9 +712,7 @@ 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 || ''
|
||||||
@ -736,10 +728,7 @@ 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 = ''
|
||||||
@ -748,10 +737,7 @@ 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 = ''
|
||||||
@ -760,24 +746,6 @@ 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) {
|
||||||
|
|||||||
@ -8,7 +8,6 @@ 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',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user