Compare commits
6 Commits
phase-a-rc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a595ea8fa1 | ||
|
|
d9dfb8b7f1 | ||
|
|
e11ce1aaaa | ||
|
|
d29ece3a85 | ||
|
|
9a12ee07b0 | ||
|
|
ec4015aaba |
@ -1,3 +1,3 @@
|
||||
# 小程序构建时的 API 域名(api.s-good.com 已挪作他用,勿再指向)
|
||||
# 提审/体验版前请改为宠小它专用域名,并在微信后台配置合法域名
|
||||
VITE_API_ORIGIN=http://localhost:8080
|
||||
# 安全占位符:提审/体验版构建前必须用受控环境文件覆盖,并运行发布门禁。
|
||||
VITE_API_ORIGIN=https://api.petstore.invalid
|
||||
VITE_REPORT_PUBLIC_ORIGIN=https://report.petstore.invalid
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
# 宠小它生产 API 域名(api.s-good.com 已挪作他用,勿再指向)
|
||||
# 部署前请改为宠小它专用域名,例如 https://api.chongxiaota.example
|
||||
VITE_API_ORIGIN=http://localhost:8080
|
||||
# 安全占位符:正式构建必须通过 PETSTORE_FRONTEND_ENV_FILE 指向服务器/CI 的受控环境文件,
|
||||
# 并先执行 npm run preflight:release;.invalid 会被门禁拒绝。
|
||||
VITE_API_ORIGIN=https://api.petstore.invalid
|
||||
VITE_REPORT_PUBLIC_ORIGIN=https://report.petstore.invalid
|
||||
|
||||
20
README.md
20
README.md
@ -76,9 +76,7 @@ npm run dev:h5
|
||||
|
||||
开发阶段:在微信开发者工具中勾选「不校验合法域名」(设置 → 项目设置)
|
||||
|
||||
生产阶段:在微信公众平台后台添加以下合法域名:
|
||||
- `http://localhost:8080`(开发用)
|
||||
- 你的实际后端域名
|
||||
生产阶段:仅在微信公众平台后台添加本项目实际 HTTPS 后端域名;localhost 只用于开发,不能进入体验版/正式版。
|
||||
|
||||
## API 配置
|
||||
|
||||
@ -135,6 +133,10 @@ npm run dev:h5
|
||||
|
||||
当前实现:**提交时登录 + guest 草稿恢复**。未登录用户可填写预约表单,提交时跳转登录页(带 `redirect` 回预约页并保留 `storeId`),登录成功后回到预约页并恢复 guest 草稿(草稿 key 不依赖 `userInfo.id`,避免 `userId: undefined`)。登录态用户继续按 `userId` 维度保存草稿。草稿有效期 7 天。
|
||||
|
||||
### 门店代客预约身份
|
||||
|
||||
老板/员工在首页新建预约时必须填写宠主手机号,可选填宠主称呼。后端按手机号关联或创建 customer,并分别记录 `customerUserId` 与 `createdByUserId`;前端不得再把当前员工 `userId` 当作宠主提交。预约详情优先读取 `assignedStaffId`,兼容旧字段 `assignedUserId`。
|
||||
|
||||
## 前端 RC 发布门禁
|
||||
|
||||
发布前必须依次执行并全部通过:
|
||||
@ -143,15 +145,23 @@ npm run dev:h5
|
||||
# 1. 安装依赖
|
||||
npm --prefix frontend install
|
||||
|
||||
# 2. H5 构建
|
||||
# 2. 用权限受控的真实生产环境文件做门禁(不会打印变量值)
|
||||
PETSTORE_FRONTEND_ENV_FILE=/secure/path/petstore-h5.env npm --prefix frontend run preflight:release
|
||||
|
||||
# 3. 让 Vite 使用同一份已通过门禁的配置并构建 H5
|
||||
cp /secure/path/petstore-h5.env frontend/.env.production.local
|
||||
npm --prefix frontend run build:h5
|
||||
# 期望:DONE Build complete.
|
||||
|
||||
# 3. 微信小程序构建
|
||||
# 4. 小程序配置单独门禁并构建
|
||||
PETSTORE_FRONTEND_ENV_FILE=/secure/path/petstore-mp.env npm --prefix frontend run preflight:release
|
||||
cp /secure/path/petstore-mp.env frontend/.env.mp-weixin.local
|
||||
npm --prefix frontend run build:mp-weixin
|
||||
# 期望:DONE Build complete. + [verify-mp-app-json] OK
|
||||
```
|
||||
|
||||
仓库内 `.env.production` / `.env.mp-weixin` 只含 `.invalid` 安全占位符,会被门禁主动拒绝。真实域名文件不得提交;发布结束后删除临时 `.env.*.local`。
|
||||
|
||||
### 前端 RC 检查项
|
||||
|
||||
- [ ] `VITE_API_ORIGIN` 已配置(宠小它专用后端 API 域名;**勿再用** `api.s-good.com`)
|
||||
|
||||
@ -4,9 +4,10 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev:mp-weixin": "uni -p mp-weixin",
|
||||
"build:mp-weixin": "uni build -p mp-weixin && node scripts/verify-mp-app-json.cjs",
|
||||
"build:mp-weixin": "uni build -p mp-weixin --mode mp-weixin && node scripts/verify-mp-app-json.cjs",
|
||||
"dev:h5": "uni",
|
||||
"build:h5": "uni build"
|
||||
"build:h5": "uni build",
|
||||
"preflight:release": "node scripts/release-preflight.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dcloudio/uni-app": "3.0.0-4060620250520001",
|
||||
|
||||
59
scripts/release-preflight.cjs
Normal file
59
scripts/release-preflight.cjs
Normal file
@ -0,0 +1,59 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const envFile = process.env.PETSTORE_FRONTEND_ENV_FILE || '.env.production'
|
||||
const absoluteEnvFile = path.resolve(process.cwd(), envFile)
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[release-preflight] FAIL: ${message}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absoluteEnvFile)) {
|
||||
fail('environment file does not exist')
|
||||
}
|
||||
|
||||
const values = {}
|
||||
for (const rawLine of fs.readFileSync(absoluteEnvFile, 'utf8').split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line || line.startsWith('#')) continue
|
||||
const separator = line.indexOf('=')
|
||||
if (separator < 1) continue
|
||||
const key = line.slice(0, separator).trim().replace(/^export\s+/, '')
|
||||
const value = line.slice(separator + 1).trim().replace(/^(['"])(.*)\1$/, '$2')
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
function requireProductionOrigin(name, forbiddenHosts) {
|
||||
const value = values[name]
|
||||
if (!value) fail(`${name} is required`)
|
||||
|
||||
let url
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
fail(`${name} must be a valid URL`)
|
||||
}
|
||||
|
||||
const hostname = url.hostname.toLowerCase()
|
||||
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
|
||||
const isPlaceholder = hostname.endsWith('.invalid')
|
||||
|| hostname.endsWith('.test')
|
||||
|| hostname.endsWith('.example')
|
||||
|| ['example.com', 'example.net', 'example.org'].some(
|
||||
(reserved) => hostname === reserved || hostname.endsWith(`.${reserved}`),
|
||||
)
|
||||
const hasExtraParts = Boolean(url.username || url.password || url.search || url.hash)
|
||||
|| (url.pathname !== '' && url.pathname !== '/')
|
||||
if (url.protocol !== 'https:' || isLocal || isPlaceholder || hasExtraParts) {
|
||||
fail(`${name} must be an explicit production HTTPS origin`)
|
||||
}
|
||||
if (forbiddenHosts.includes(hostname)) {
|
||||
fail(`${name} uses a domain reserved by another product`)
|
||||
}
|
||||
console.log(`[release-preflight] ${name}: PASS`)
|
||||
}
|
||||
|
||||
requireProductionOrigin('VITE_API_ORIGIN', ['api.s-good.com'])
|
||||
requireProductionOrigin('VITE_REPORT_PUBLIC_ORIGIN', ['www.s-good.com'])
|
||||
console.log('[release-preflight] frontend release configuration PASS')
|
||||
@ -59,11 +59,21 @@ export const login = (phone, code) => post('/user/login', { phone, code })
|
||||
export const wxPhoneLogin = (phoneCode, loginCode) =>
|
||||
post('/user/wx-phone-login', { phoneCode, loginCode: loginCode || '' })
|
||||
|
||||
// 注册老板
|
||||
export const registerBoss = (data) => post('/user/register-boss', data)
|
||||
// 微信核验手机号后创建门店与老板账号(不接收明文密码或手填手机号)
|
||||
export const registerBoss = (data) => post('/onboarding/register-boss', data)
|
||||
|
||||
// 注册员工
|
||||
export const registerStaff = (data) => post('/user/register-staff', data)
|
||||
// 门店开通清单
|
||||
export const getOnboardingStatus = () => get('/admin/onboarding', {})
|
||||
export const completeOnboarding = () => post('/admin/onboarding/complete', {})
|
||||
|
||||
// 员工邀请:预览/接受为公开接口;创建/列表/撤销仅老板
|
||||
export const previewStaffInvitation = (token) => get('/staff-invitations/preview', { token })
|
||||
export const acceptStaffInvitation = (token, phoneCode, loginCode) =>
|
||||
post('/staff-invitations/accept', { token, phoneCode, loginCode: loginCode || '' })
|
||||
export const getStaffInvitations = () => get('/admin/staff-invitations', {})
|
||||
export const createStaffInvitation = (data) => post('/admin/staff-invitations', data)
|
||||
export const revokeStaffInvitation = (invitationId) =>
|
||||
del(`/admin/staff-invitations?invitationId=${encodeURIComponent(invitationId)}`)
|
||||
|
||||
// 宠物档案(身份由后端从 session token 派生;保留旧签名兼容,不再传 ownerUserId/storeId 给后端)
|
||||
export const getPetList = (/* params */) => get('/pet/list', {})
|
||||
@ -83,12 +93,12 @@ export const getAppointmentList = (/* userId, storeId, */ options = {}) =>
|
||||
// 预约详情
|
||||
export const getAppointmentDetail = (id) => get('/appointment/detail', { id })
|
||||
|
||||
// 创建预约(userId/storeId 由后端从上下文派生;前端仍传 storeId 供 customer 选店场景,后端会按 role 决定是否采纳)
|
||||
// 创建预约:登录人身份始终由后端派生;customer 传选中门店,门店代客预约传 customerUserId 或 customerPhone
|
||||
export const createAppointment = (data) => post('/appointment/create', data)
|
||||
|
||||
/** 门店某日可预约半小时档(可约 / 已满 / 已过);公开接口 */
|
||||
export const getAppointmentAvailableSlots = (storeId, date) =>
|
||||
get('/appointment/available-slots', { storeId, date })
|
||||
/** 门店某日可预约开始时段;按服务时长与门店容量计算,公开接口。 */
|
||||
export const getAppointmentAvailableSlots = (storeId, date, serviceType) =>
|
||||
get('/appointment/available-slots', { storeId, date, serviceType })
|
||||
|
||||
// 开始服务(staffUserId 由后端从上下文派生;保留旧签名兼容,不传 staffUserId)
|
||||
export const startAppointment = (appointmentId/*, staffUserId */) =>
|
||||
@ -100,6 +110,10 @@ export const cancelAppointment = (id) => put(`/appointment/status?id=${id}&statu
|
||||
// 提交报告
|
||||
export const createReport = (data) => post('/report/create', data)
|
||||
|
||||
/** 员工在真实发送完成后显式确认;复制链接、二维码和预览均不得自动调用。 */
|
||||
export const confirmReportSent = (reportId, channel) =>
|
||||
post('/report/confirm-sent', { reportId, channel })
|
||||
|
||||
/** 触发服务回顾短片生成(异步)。composeMode: preset | interleave;身份由后端从上下文派生 */
|
||||
export const postReportHighlightStart = (data) => post('/report/highlight/start', data)
|
||||
|
||||
@ -109,7 +123,7 @@ export const postReportHighlightStart = (data) => post('/report/highlight/start'
|
||||
*/
|
||||
export const getReportByToken = (token) => get('/report/get', { token })
|
||||
|
||||
/** P0:报告打开埋点(服务端记日志,可对接日志平台) */
|
||||
/** 报告打开埋点(服务端落低敏 BusinessEvent;失败不影响公开报告阅读) */
|
||||
export const postReportOpenTrack = (token, visitType) =>
|
||||
post('/report/open-track', { token, visitType: visitType || 'unknown' })
|
||||
|
||||
@ -147,8 +161,9 @@ export const getServiceTypeList = (storeId) => {
|
||||
return get('/service-type/list', params)
|
||||
}
|
||||
|
||||
// 创建服务类型
|
||||
export const createServiceType = (storeId, name) => post('/service-type/create', { storeId, name })
|
||||
// 创建服务类型(storeId 仅保留旧签名兼容,后端从登录态派生)
|
||||
export const createServiceType = (storeId, name, durationMinutes) =>
|
||||
post('/service-type/create', { storeId, name, durationMinutes })
|
||||
|
||||
// 删除服务类型
|
||||
export const deleteServiceType = (id) => del(`/service-type/delete?id=${id}`)
|
||||
@ -156,9 +171,6 @@ export const deleteServiceType = (id) => del(`/service-type/delete?id=${id}`)
|
||||
// 员工列表(storeId 由后端从上下文派生;保留旧签名兼容)
|
||||
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}`)
|
||||
|
||||
|
||||
@ -6,6 +6,10 @@
|
||||
<text class="rsm-close" @click="emitClose">✕</text>
|
||||
</view>
|
||||
<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">
|
||||
<text class="rsm-label">报告链接</text>
|
||||
@ -50,6 +54,14 @@
|
||||
<button class="rsm-btn rsm-btn--ghost" @click="emitClose">稍后</button>
|
||||
<button class="rsm-btn rsm-btn--primary" @click="previewReport">预览报告</button>
|
||||
</view>
|
||||
<button
|
||||
class="rsm-btn rsm-confirm"
|
||||
:class="{ 'rsm-confirm--done': sent }"
|
||||
:disabled="sent || confirming"
|
||||
@click="confirmAlreadySent"
|
||||
>
|
||||
{{ sent ? '已确认发送' : (confirming ? '确认中…' : '确认已发送给宠主') }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@ -62,6 +74,9 @@ import { buildReportH5FullUrl } from '../../utils/reportPublicUrl.js'
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
reportToken: { type: String, default: '' },
|
||||
reportId: { type: [Number, String], default: '' },
|
||||
sent: { type: Boolean, default: false },
|
||||
confirming: { type: Boolean, default: false },
|
||||
petName: { type: String, default: '' },
|
||||
/** 门店名(话术变量) */
|
||||
storeName: { type: String, default: '' },
|
||||
@ -69,7 +84,7 @@ const props = defineProps({
|
||||
serviceType: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'preview'])
|
||||
const emit = defineEmits(['close', 'preview', 'confirm-sent'])
|
||||
|
||||
const copiedUrl = ref(false)
|
||||
const copiedTplIdx = ref(-1)
|
||||
@ -185,6 +200,19 @@ function onMask() {
|
||||
function previewReport() {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@ -234,6 +262,21 @@ function previewReport() {
|
||||
line-height: 1.5;
|
||||
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 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@ -338,6 +381,16 @@ function previewReport() {
|
||||
background: var(--c-slate-100);
|
||||
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] {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
{"path": "pages/appointment/CustAppointmentCreate"},
|
||||
{"path": "pages/report/Report"},
|
||||
{"path": "pages/mine/Mine"},
|
||||
{"path": "pages/mine/Onboarding"},
|
||||
{"path": "pages/mine/Staff"},
|
||||
{"path": "pages/mine/ServiceType"},
|
||||
{"path": "pages/mine/Store"},
|
||||
|
||||
@ -38,13 +38,17 @@
|
||||
<text class="label">预约时间</text>
|
||||
<text class="value time">{{ timeDisplay }}</text>
|
||||
</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">
|
||||
<text class="label">备注</text>
|
||||
<text class="value remark">{{ detail.remark }}</text>
|
||||
</view>
|
||||
<view v-if="detail.assignedUserId" class="detail-row">
|
||||
<view v-if="detail.assignedStaffId || detail.assignedUserId" class="detail-row">
|
||||
<text class="label">服务技师</text>
|
||||
<text class="value">#{{ detail.assignedUserId }}</text>
|
||||
<text class="value">#{{ detail.assignedStaffId || detail.assignedUserId }}</text>
|
||||
</view>
|
||||
<view class="detail-meta">
|
||||
<text v-if="detail.id">预约编号 {{ detail.id }}</text>
|
||||
@ -84,6 +88,7 @@ const navSafeStyle = (() => {
|
||||
const statusText = computed(() => (detail.value ? getAppointmentStatusText(detail.value.status) : ''))
|
||||
|
||||
const timeDisplay = computed(() => formatDateTimeCN(detail.value?.appointmentTime))
|
||||
const endTimeDisplay = computed(() => formatDateTimeCN(detail.value?.endTime))
|
||||
|
||||
const tagClass = (status) => getAppointmentTagClass(status)
|
||||
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
</view>
|
||||
<view class="c-field">
|
||||
<text class="app-form-label">服务类型</text>
|
||||
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
|
||||
<picker mode="selector" :range="serviceTypes" range-key="label" @change="onServiceTypeChange">
|
||||
<view class="app-form-input app-form-picker">
|
||||
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
|
||||
<text class="app-form-arrow">›</text>
|
||||
@ -330,8 +330,9 @@ const creatingAppt = ref(false)
|
||||
/** 与日期选择器同步,避免无号源时丢失已选日期 */
|
||||
const selectedDateStr = ref(ymdLocal())
|
||||
const availableTimes = ref([])
|
||||
const availableSlotEnds = ref({})
|
||||
const loadingSlots = ref(false)
|
||||
const bookingWindowHint = ref('请选择半小时时间段;同一时段仅接待一单,已满或已过期不可选')
|
||||
const bookingWindowHint = ref('请选择服务后查看可预约开始时间;系统会按服务时长校验完整容量')
|
||||
|
||||
const normalizeText = (s) => (s || '').toString().trim().toLowerCase()
|
||||
|
||||
@ -397,11 +398,13 @@ const slotPickerIndex = computed(() => {
|
||||
|
||||
/** 滚轮选项展示为「09:00–09:30」 */
|
||||
const availableSlotLabels = computed(() =>
|
||||
availableTimes.value.map((t) => formatHalfHourSlotRange(t))
|
||||
availableTimes.value.map((t) => `${t}–${availableSlotEnds.value[t] || formatHalfHourSlotRange(t).slice(6)}`)
|
||||
)
|
||||
|
||||
const selectedSlotRangeLabel = computed(() =>
|
||||
appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : ''
|
||||
appointmentTime.value
|
||||
? `${appointmentTime.value}–${availableSlotEnds.value[appointmentTime.value] || formatHalfHourSlotRange(appointmentTime.value).slice(6)}`
|
||||
: ''
|
||||
)
|
||||
|
||||
const showTryTomorrow = computed(
|
||||
@ -433,20 +436,24 @@ const loadAvailableSlots = async () => {
|
||||
}
|
||||
loadingSlots.value = true
|
||||
try {
|
||||
const res = await getAppointmentAvailableSlots(storeId, date)
|
||||
const res = await getAppointmentAvailableSlots(storeId, date, newAppt.value.serviceType || undefined)
|
||||
loadingSlots.value = false
|
||||
if (res.code !== 200 || !res.data?.slots) {
|
||||
availableTimes.value = []
|
||||
availableSlotEnds.value = {}
|
||||
uni.showToast({ title: res.message || '号源加载失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const ds = res.data.dayStart
|
||||
const le = res.data.lastSlotStart
|
||||
if (ds != null && le != null) {
|
||||
const closing = res.data.closingTime
|
||||
if (ds != null && closing != null) {
|
||||
const a = String(ds).slice(0, 5)
|
||||
const b = String(le).slice(0, 5)
|
||||
bookingWindowHint.value = `本店可约 ${a}~${b};以下为半小时时段,同一时段仅一单,已满或已过不可选`
|
||||
const b = String(closing).slice(0, 5)
|
||||
const mins = Number(res.data.durationMinutes || 60)
|
||||
const capacity = Number(res.data.bookingCapacity || 1)
|
||||
bookingWindowHint.value = `营业容量 ${a}~${b};本服务约 ${mins} 分钟,门店并发接待 ${capacity} 位`
|
||||
}
|
||||
availableSlotEnds.value = Object.fromEntries(res.data.slots.map((s) => [s.time, s.endTime]))
|
||||
availableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time)
|
||||
if (!availableTimes.value.length) {
|
||||
newAppt.value.appointmentTime = ''
|
||||
@ -466,6 +473,7 @@ const loadAvailableSlots = async () => {
|
||||
} catch (_) {
|
||||
loadingSlots.value = false
|
||||
availableTimes.value = []
|
||||
availableSlotEnds.value = {}
|
||||
uni.showToast({ title: '号源加载失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
@ -494,12 +502,25 @@ const onAppointmentTimeOnlyChange = (e) => {
|
||||
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 row = orderedStores.value[selectedStoreIndex.value]
|
||||
const sid = row?.id
|
||||
const res = await getServiceTypeList(sid ?? undefined)
|
||||
if (res.code === 200 && Array.isArray(res.data)) {
|
||||
serviceTypes.value = res.data.map((s) => ({ label: s.name, value: s.name }))
|
||||
serviceTypes.value = res.data.map((s) => ({
|
||||
label: `${s.name} · 约${Number(s.durationMinutes || 60)}分钟`,
|
||||
value: s.name,
|
||||
durationMinutes: Number(s.durationMinutes || 60)
|
||||
}))
|
||||
if (newAppt.value.serviceType && !serviceTypes.value.some((s) => s.value === newAppt.value.serviceType)) {
|
||||
newAppt.value.serviceType = ''
|
||||
}
|
||||
if (sid) {
|
||||
try {
|
||||
const last = uni.getStorageSync(lastServiceStorageKey(sid))
|
||||
@ -593,7 +614,7 @@ const confirmNewAppt = async () => {
|
||||
uni.showToast({ title: '宠物档案保存失败,请重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const payload = { ...a, storeId, userId: userInfo.id }
|
||||
const payload = { ...a, storeId }
|
||||
payload.petId = ensuredPet.id
|
||||
const res = await createAppointment(payload)
|
||||
creatingAppt.value = false
|
||||
|
||||
@ -199,6 +199,20 @@
|
||||
<text class="popup-close" @click="showNewAppt = false">✕</text>
|
||||
</view>
|
||||
<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">
|
||||
<text class="app-form-label">宠物名字</text>
|
||||
<input v-model="newAppt.petName" class="app-form-input" placeholder="请输入" />
|
||||
@ -214,7 +228,7 @@
|
||||
</view>
|
||||
<view class="c-field">
|
||||
<text class="app-form-label">服务类型</text>
|
||||
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
|
||||
<picker mode="selector" :range="serviceTypes" range-key="label" @change="onStaffServiceTypeChange">
|
||||
<view class="app-form-input app-form-picker">
|
||||
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
|
||||
<text class="app-form-arrow">›</text>
|
||||
@ -242,7 +256,7 @@
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<text class="c-store-hint home-slot-hint">每行半小时时段,约满不可选</text>
|
||||
<text class="c-store-hint home-slot-hint">按服务时长与门店并发容量计算,约满不可选</text>
|
||||
</view>
|
||||
<view class="c-field">
|
||||
<text class="app-form-label">备注(可选)</text>
|
||||
@ -387,7 +401,16 @@ const petTypes = [
|
||||
{ label: '其他', value: '其他' }
|
||||
]
|
||||
|
||||
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
|
||||
const emptyStaffAppointment = () => ({
|
||||
customerPhone: '',
|
||||
customerName: '',
|
||||
petName: '',
|
||||
petType: '',
|
||||
serviceType: '',
|
||||
appointmentTime: '',
|
||||
remark: ''
|
||||
})
|
||||
const newAppt = ref(emptyStaffAppointment())
|
||||
const appointmentTime = computed(() => {
|
||||
const raw = newAppt.value.appointmentTime || ''
|
||||
if (!raw.includes('T')) return ''
|
||||
@ -398,6 +421,7 @@ const appointmentTime = computed(() => {
|
||||
/** B 端弹窗:按半小时档选可约时段 */
|
||||
const staffApptDateStr = ref('')
|
||||
const staffAvailableTimes = ref([])
|
||||
const staffAvailableSlotEnds = ref({})
|
||||
const staffLoadingSlots = ref(false)
|
||||
|
||||
const staffSlotPickerIndex = computed(() => {
|
||||
@ -407,11 +431,13 @@ const staffSlotPickerIndex = computed(() => {
|
||||
})
|
||||
|
||||
const staffAvailableSlotLabels = computed(() =>
|
||||
staffAvailableTimes.value.map((t) => formatHalfHourSlotRange(t))
|
||||
staffAvailableTimes.value.map((t) => `${t}–${staffAvailableSlotEnds.value[t] || formatHalfHourSlotRange(t).slice(6)}`)
|
||||
)
|
||||
|
||||
const staffSelectedSlotRangeLabel = computed(() =>
|
||||
appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : ''
|
||||
appointmentTime.value
|
||||
? `${appointmentTime.value}–${staffAvailableSlotEnds.value[appointmentTime.value] || formatHalfHourSlotRange(appointmentTime.value).slice(6)}`
|
||||
: ''
|
||||
)
|
||||
|
||||
const filteredOrders = computed(() => orders.value.filter(o => {
|
||||
@ -502,12 +528,14 @@ const loadStaffAvailableSlots = async () => {
|
||||
staffApptDateStr.value = date
|
||||
staffLoadingSlots.value = true
|
||||
try {
|
||||
const res = await getAppointmentAvailableSlots(storeInfo.id, date)
|
||||
const res = await getAppointmentAvailableSlots(storeInfo.id, date, newAppt.value.serviceType || undefined)
|
||||
staffLoadingSlots.value = false
|
||||
if (res.code !== 200 || !res.data?.slots) {
|
||||
staffAvailableTimes.value = []
|
||||
staffAvailableSlotEnds.value = {}
|
||||
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)
|
||||
if (!staffAvailableTimes.value.length) {
|
||||
newAppt.value.appointmentTime = ''
|
||||
@ -527,6 +555,7 @@ const loadStaffAvailableSlots = async () => {
|
||||
} catch (_) {
|
||||
staffLoadingSlots.value = false
|
||||
staffAvailableTimes.value = []
|
||||
staffAvailableSlotEnds.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -536,6 +565,12 @@ const openStaffNewApptDialog = async () => {
|
||||
await loadStaffAvailableSlots()
|
||||
}
|
||||
|
||||
const onStaffServiceTypeChange = (e) => {
|
||||
const selected = serviceTypes.value[Number(e.detail.value)]
|
||||
newAppt.value.serviceType = selected?.value || ''
|
||||
loadStaffAvailableSlots()
|
||||
}
|
||||
|
||||
const onStaffApptDateChange = (e) => {
|
||||
const date = e?.detail?.value || ''
|
||||
if (!date) {
|
||||
@ -631,7 +666,11 @@ const loadServiceTypes = async () => {
|
||||
if (!storeInfo.id) return
|
||||
const res = await getServiceTypeList(storeInfo.id)
|
||||
if (res.code === 200) {
|
||||
serviceTypes.value = res.data.map(s => ({ label: s.name, value: s.name }))
|
||||
serviceTypes.value = res.data.map(s => ({
|
||||
label: `${s.name} · 约${Number(s.durationMinutes || 60)}分钟`,
|
||||
value: s.name,
|
||||
durationMinutes: Number(s.durationMinutes || 60)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -671,6 +710,10 @@ const goReport = (item) => {
|
||||
|
||||
const confirmNewAppt = async () => {
|
||||
const 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.petType) { uni.showToast({ title: '请选择宠物类型', icon: 'none' }); return }
|
||||
if (!a.serviceType) { uni.showToast({ title: '请选择服务类型', icon: 'none' }); return }
|
||||
@ -681,11 +724,11 @@ const confirmNewAppt = async () => {
|
||||
return
|
||||
}
|
||||
creatingAppt.value = true
|
||||
const res = await createAppointment({ ...a, storeId: storeInfo.id, userId: userInfo.id })
|
||||
const res = await createAppointment({ ...a, storeId: storeInfo.id })
|
||||
creatingAppt.value = false
|
||||
if (res.code === 200) {
|
||||
uni.showToast({ title: '预约成功', icon: 'success' })
|
||||
newAppt.value = { petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' }
|
||||
newAppt.value = emptyStaffAppointment()
|
||||
showNewAppt.value = false
|
||||
fetchAppointments()
|
||||
} else {
|
||||
|
||||
@ -5,610 +5,359 @@
|
||||
<text class="brand-sub">用心宠小它,温暖伴一生</text>
|
||||
</view>
|
||||
|
||||
<!-- 登录 -->
|
||||
<view v-if="view === 'login'" class="card">
|
||||
<text class="card-title">登录</text>
|
||||
<input v-model="loginForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" />
|
||||
<view class="sms-row">
|
||||
<input v-model="loginForm.code" type="digit" class="field sms-input" placeholder="验证码" maxlength="6" />
|
||||
<button class="app-btn-code app-btn-code--neutral sms-code-btn" :disabled="smsCountdown > 0" @click="handleSendSms">{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}</button>
|
||||
<button class="app-btn-code app-btn-code--neutral sms-code-btn" :disabled="smsCountdown > 0" @click="handleSendSms">
|
||||
{{ smsCountdown > 0 ? smsCountdown + 's' : '获取验证码' }}
|
||||
</button>
|
||||
</view>
|
||||
<button class="app-btn-main app-btn-primary login-main-btn" :disabled="loginLoading" @click="handleLogin">{{ loginLoading ? '登录中…' : '登录' }}</button>
|
||||
<button class="app-btn-main app-btn-primary login-main-btn" :disabled="loading" @click="handleLogin">
|
||||
{{ loading ? '登录中…' : '验证码登录' }}
|
||||
</button>
|
||||
|
||||
<view class="divider">
|
||||
<view class="divider-line"></view>
|
||||
<text class="divider-text">或</text>
|
||||
<view class="divider-line"></view>
|
||||
</view>
|
||||
<view class="divider"><view class="divider-line"/><text class="divider-text">或</text><view class="divider-line"/></view>
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button
|
||||
class="app-btn-main app-btn-soft"
|
||||
open-type="getPhoneNumber"
|
||||
:disabled="loginLoading"
|
||||
@getphonenumber="onWxGetPhoneNumber"
|
||||
>{{ loginLoading ? '登录中…' : '微信授权登录' }}</button>
|
||||
<button class="app-btn-main app-btn-soft" open-type="getPhoneNumber" :disabled="loading" @getphonenumber="onWxLogin">
|
||||
{{ loading ? '登录中…' : '微信手机号登录' }}
|
||||
</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<button class="app-btn-main app-btn-soft" @click="onWechatLoginNotInMp">微信授权登录</button>
|
||||
<button class="app-btn-main app-btn-soft" @click="wechatOnly">微信手机号登录</button>
|
||||
<!-- #endif -->
|
||||
|
||||
<view class="footer-links">
|
||||
<text class="link-btn" @click="view = 'staff-reg'">员工注册</text>
|
||||
<view class="link-dot"></view>
|
||||
<text class="link-btn" @click="openStaffInvite">员工受邀加入</text>
|
||||
<view class="link-dot"/>
|
||||
<text class="link-btn" @click="view = 'boss-reg'">商家入驻</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 商家入驻 -->
|
||||
<view v-else-if="view === 'boss-reg'" class="card">
|
||||
<text class="card-title">商家入驻</text>
|
||||
<input v-model="bossForm.storeName" class="field" placeholder="店铺名称" />
|
||||
<input v-model="bossForm.bossName" class="field" placeholder="您的姓名" />
|
||||
<text class="card-title">创建门店</text>
|
||||
<view class="hint-box">手机号必须由微信核验;创建后继续补全门店资料、预约容量和服务项目。</view>
|
||||
<input v-model="bossForm.storeName" class="field" placeholder="门店名称" maxlength="64" />
|
||||
<input v-model="bossForm.bossName" class="field" placeholder="老板姓名" maxlength="64" />
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view class="phone-row">
|
||||
<input v-model="bossForm.phone" type="tel" class="field phone-input" placeholder="手机号" maxlength="11" />
|
||||
<button class="app-btn-code app-btn-code--brand phone-auth-btn" open-type="getPhoneNumber" @getphonenumber="onWxGetPhoneNumberBoss">授权手机号</button>
|
||||
</view>
|
||||
<button
|
||||
class="app-btn-main app-btn-primary login-main-btn"
|
||||
open-type="getPhoneNumber"
|
||||
:disabled="loading"
|
||||
@getphonenumber="onRegisterBoss"
|
||||
>{{ loading ? '创建中…' : '微信核验手机号并创建' }}</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<input v-model="bossForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" />
|
||||
<button class="app-btn-main app-btn-primary login-main-btn" @click="wechatOnly">请在微信小程序完成入驻</button>
|
||||
<!-- #endif -->
|
||||
<!-- 手机号手动填写时需要验证码 -->
|
||||
<view v-if="!bossForm.wxPhoneAuto" class="sms-row">
|
||||
<input v-model="bossForm.smsCode" type="digit" class="field sms-input" placeholder="验证码" maxlength="6" />
|
||||
<button class="app-btn-code app-btn-code--neutral sms-code-btn" :disabled="bossSmsCountdown > 0" @click="handleSendSmsBoss">{{ bossSmsCountdown > 0 ? bossSmsCountdown + 's' : '获取验证码' }}</button>
|
||||
</view>
|
||||
<view v-else class="wx-verified-hint">✓ 微信已认证手机号</view>
|
||||
<input v-model="bossForm.password" type="password" class="field" placeholder="登录密码(至少6位)" />
|
||||
<button class="app-btn-main app-btn-primary login-main-btn" :disabled="regLoading" @click="handleRegisterBoss">{{ regLoading ? '提交中…' : '提交申请' }}</button>
|
||||
<text class="back-link" @click="backToLoginForm">← 返回登录</text>
|
||||
<text class="back-link" @click="backToLogin">← 返回登录</text>
|
||||
</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 -->
|
||||
<view class="phone-row">
|
||||
<input v-model="staffForm.phone" type="tel" class="field phone-input" placeholder="手机号" maxlength="11" />
|
||||
<button class="app-btn-code app-btn-code--brand phone-auth-btn" open-type="getPhoneNumber" @getphonenumber="onWxGetPhoneNumberStaff">授权手机号</button>
|
||||
<view v-else class="card invitation-card">
|
||||
<text class="card-title">接受员工邀请</text>
|
||||
<view v-if="!invitation" class="hint-box">粘贴老板发来的邀请口令,或直接从邀请卡片进入。</view>
|
||||
<view v-if="!invitation" class="token-row">
|
||||
<input v-model="inviteToken" class="field token-input" placeholder="邀请口令" maxlength="512" />
|
||||
<button class="app-btn-code app-btn-code--brand" :disabled="previewLoading" @click="loadInvitation">
|
||||
{{ 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>
|
||||
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<button
|
||||
v-if="invitation?.canAccept"
|
||||
class="app-btn-main app-btn-primary login-main-btn"
|
||||
open-type="getPhoneNumber"
|
||||
:disabled="loading"
|
||||
@getphonenumber="onAcceptInvitation"
|
||||
>{{ loading ? '核验中…' : invitation.status === 'accepted' ? '微信核验手机号并登录' : '微信核验手机号并加入' }}</button>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<input v-model="staffForm.phone" type="tel" class="field" placeholder="手机号" maxlength="11" />
|
||||
<view v-if="invitation?.canAccept" class="non-mp-notice">请在微信小程序内完成手机号核验与加入。</view>
|
||||
<!-- #endif -->
|
||||
<!-- 手机号手动填写时需要验证码 -->
|
||||
<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>
|
||||
<button v-if="invitation" class="app-btn-main app-btn-soft secondary-btn" @click="resetInvitation">更换邀请口令</button>
|
||||
<text class="back-link" @click="backToLogin">← 返回登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onUnmounted } from 'vue'
|
||||
import { computed, onUnmounted, reactive, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { sendSms, login, wxPhoneLogin, registerBoss, registerStaff } from '../../api/index.js'
|
||||
import { setStoreSession, setUserSession, setSessionToken } from '../../utils/session.js'
|
||||
import {
|
||||
acceptStaffInvitation,
|
||||
login,
|
||||
previewStaffInvitation,
|
||||
registerBoss,
|
||||
sendSms,
|
||||
wxPhoneLogin
|
||||
} from '../../api/index.js'
|
||||
import { setSessionToken, setStoreSession, setUserSession } from '../../utils/session.js'
|
||||
import { openAppPage } from '../../utils/globalState.js'
|
||||
|
||||
const view = ref('login')
|
||||
const loginForm = reactive({ phone: '13800138001', code: '123456' })
|
||||
const bossForm = reactive({ storeName: '', bossName: '', phone: '', password: '', smsCode: '', wxPhoneAuto: false })
|
||||
const staffForm = reactive({ inviteCode: '', name: '', phone: '', password: '', smsCode: '', wxPhoneAuto: false })
|
||||
const loginLoading = ref(false)
|
||||
const regLoading = ref(false)
|
||||
const loginForm = reactive({ phone: '', code: '' })
|
||||
const bossForm = reactive({ storeName: '', bossName: '' })
|
||||
const loading = ref(false)
|
||||
const previewLoading = ref(false)
|
||||
const smsCountdown = ref(0)
|
||||
const bossSmsCountdown = ref(0)
|
||||
const staffSmsCountdown = ref(0)
|
||||
let bossSmsTimer = null
|
||||
let staffSmsTimer = null
|
||||
|
||||
const inviteToken = ref('')
|
||||
const invitation = ref(null)
|
||||
const redirectAfterLogin = ref('')
|
||||
let smsTimer = null
|
||||
|
||||
const parseBossRegData = (data) => {
|
||||
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 showToast = (title) => uni.showToast({ title, icon: 'none' })
|
||||
const isOk = (response) => response && Number(response.code) === 200
|
||||
|
||||
const parseStaffRegData = (data) => {
|
||||
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),
|
||||
phone: uString(u.phone)
|
||||
}
|
||||
}
|
||||
const invitationStatus = computed(() => ({
|
||||
pending: '邀请待接受',
|
||||
accepted: '已加入,可核验本人手机号登录',
|
||||
revoked: '邀请已撤销',
|
||||
expired: '邀请已过期'
|
||||
}[invitation.value?.status] || '状态未知'))
|
||||
|
||||
const uString = (v) => {
|
||||
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)
|
||||
function parseOption(value) {
|
||||
if (value == null) return ''
|
||||
try {
|
||||
s = decodeURIComponent(s)
|
||||
if (/%[0-9A-Fa-f]{2}/.test(s)) s = decodeURIComponent(s)
|
||||
return decodeURIComponent(String(value)).trim()
|
||||
} catch (_) {
|
||||
return ''
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
function isSafeLoginRedirect(path) {
|
||||
return (
|
||||
typeof path === 'string' &&
|
||||
path.startsWith('/pages/') &&
|
||||
!path.includes('..') &&
|
||||
!path.includes('//')
|
||||
)
|
||||
}
|
||||
|
||||
const redirectAfterLogin = ref('')
|
||||
|
||||
function navigateAfterLogin() {
|
||||
const r = redirectAfterLogin.value
|
||||
if (isSafeLoginRedirect(r)) {
|
||||
uni.reLaunch({ url: r })
|
||||
} else {
|
||||
openAppPage('home')
|
||||
}
|
||||
function safeRedirect(path) {
|
||||
return typeof path === 'string' && path.startsWith('/pages/') && !path.includes('..') && !path.includes('//')
|
||||
}
|
||||
|
||||
onLoad((options) => {
|
||||
view.value = 'login'
|
||||
const raw = options && (options.phone ?? options.prefillPhone)
|
||||
const p = raw == null ? '' : String(raw).trim()
|
||||
if (/^1[3-9]\d{9}$/.test(p)) {
|
||||
loginForm.phone = p
|
||||
redirectAfterLogin.value = parseOption(options?.redirect)
|
||||
const token = parseOption(options?.staffInvite || options?.token)
|
||||
if (token) {
|
||||
inviteToken.value = token
|
||||
view.value = 'staff-invite'
|
||||
loadInvitation()
|
||||
}
|
||||
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) => {
|
||||
const fetchWxLoginCode = () => new Promise((resolve) => {
|
||||
// #ifdef MP-WEIXIN
|
||||
uni.login({
|
||||
success: (res) => resolve((res && res.code) || ''),
|
||||
fail: () => resolve('')
|
||||
})
|
||||
uni.login({ success: (result) => resolve(result?.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) {
|
||||
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 {
|
||||
openAppPage('home')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendSms() {
|
||||
if (!/^1[3-9]\d{9}$/.test(loginForm.phone)) return showToast('请输入正确手机号')
|
||||
const response = await sendSms(loginForm.phone)
|
||||
if (!isOk(response)) return showToast(response.message || '验证码发送失败')
|
||||
showToast('验证码已发送')
|
||||
smsCountdown.value = 60
|
||||
smsTimer = setInterval(() => {
|
||||
smsCountdown.value--
|
||||
if (smsCountdown.value <= 0) clearInterval(smsTimer)
|
||||
smsCountdown.value -= 1
|
||||
if (smsCountdown.value <= 0 && smsTimer) clearInterval(smsTimer)
|
||||
}, 1000)
|
||||
} else {
|
||||
showToast(res.message || '发送失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendSmsBoss = async () => {
|
||||
if (!bossForm.phone || bossForm.phone.length !== 11) return showToast('请输入正确的手机号')
|
||||
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 || '发送失败')
|
||||
async function handleLogin() {
|
||||
if (!/^1[3-9]\d{9}$/.test(loginForm.phone) || loginForm.code.length !== 6) {
|
||||
return showToast('请填写手机号和 6 位验证码')
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await login(loginForm.phone, loginForm.code)
|
||||
if (res.code === 200) {
|
||||
setUserSession(res.data.user)
|
||||
setStoreSession(res.data.store)
|
||||
if (res.data.sessionToken) setSessionToken(res.data.sessionToken)
|
||||
navigateAfterLogin()
|
||||
} else {
|
||||
showToast(res.message || '登录失败')
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('网络异常,请检查服务是否启动')
|
||||
const response = await login(loginForm.phone, loginForm.code)
|
||||
if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '登录失败')
|
||||
goAfterLogin()
|
||||
} catch (_) {
|
||||
showToast('网络异常,请稍后重试')
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 非微信小程序:无法调起手机号授权 */
|
||||
const onWechatLoginNotInMp = () => {
|
||||
showToast('微信授权登录仅支持在微信小程序内使用,请使用验证码登录')
|
||||
function validPhoneEvent(event) {
|
||||
const detail = event?.detail || {}
|
||||
const error = String(detail.errMsg || '')
|
||||
if (error.includes('deny') || error.includes('cancel')) {
|
||||
showToast('需要授权本人手机号才能继续')
|
||||
return ''
|
||||
}
|
||||
if (!detail.code) showToast('未获取到手机号授权码,请重试')
|
||||
return detail.code || ''
|
||||
}
|
||||
|
||||
const getWxLoginPhone = (res) => {
|
||||
if (!isHttpOk(res)) return ''
|
||||
return uString(res?.data?.user?.phone || res?.data?.phone)
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信小程序:用户同意授权后携带 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
|
||||
async function onWxLogin(event) {
|
||||
const phoneCode = validPhoneEvent(event)
|
||||
if (!phoneCode) return
|
||||
loading.value = true
|
||||
try {
|
||||
const loginCode = await fetchWxLoginCode()
|
||||
const res = await wxPhoneLogin(code, loginCode)
|
||||
if (res.code === 200) {
|
||||
setUserSession(res.data.user)
|
||||
setStoreSession(res.data.store)
|
||||
if (res.data.sessionToken) setSessionToken(res.data.sessionToken)
|
||||
navigateAfterLogin()
|
||||
} else {
|
||||
showToast(res.message || '登录失败')
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('网络异常,请检查服务是否启动')
|
||||
const response = await wxPhoneLogin(phoneCode, loginCode)
|
||||
if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '登录失败')
|
||||
goAfterLogin()
|
||||
} catch (_) {
|
||||
showToast('网络异常,请稍后重试')
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 商家入驻:微信获取手机号并填入 */
|
||||
const onWxGetPhoneNumberBoss = 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
|
||||
async function onRegisterBoss(event) {
|
||||
if (!bossForm.storeName.trim()) return showToast('请填写门店名称')
|
||||
if (!bossForm.bossName.trim()) return showToast('请填写老板姓名')
|
||||
const phoneCode = validPhoneEvent(event)
|
||||
if (!phoneCode) return
|
||||
loading.value = true
|
||||
try {
|
||||
const loginCode = await fetchWxLoginCode()
|
||||
const res = await wxPhoneLogin(code, loginCode)
|
||||
const phone = getWxLoginPhone(res)
|
||||
if (phone) {
|
||||
bossForm.phone = phone
|
||||
bossForm.wxPhoneAuto = true
|
||||
showToast('手机号已获取')
|
||||
} else {
|
||||
showToast('未获取到手机号,请手动输入')
|
||||
}
|
||||
} catch {
|
||||
showToast('网络异常')
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 员工注册:微信获取手机号并填入 */
|
||||
const onWxGetPhoneNumberStaff = async (e) => {
|
||||
const d = e.detail || {}
|
||||
const errMsg = d.errMsg || ''
|
||||
if (errMsg.includes('deny') || errMsg.includes('cancel')) return
|
||||
const code = d.code
|
||||
if (!code) { showToast('未获取到授权,请手动输入'); return }
|
||||
loginLoading.value = true
|
||||
try {
|
||||
const loginCode = await fetchWxLoginCode()
|
||||
const res = await wxPhoneLogin(code, loginCode)
|
||||
const phone = getWxLoginPhone(res)
|
||||
if (phone) {
|
||||
staffForm.phone = phone
|
||||
staffForm.wxPhoneAuto = true
|
||||
showToast('手机号已获取')
|
||||
} else {
|
||||
showToast('未获取到手机号,请手动输入')
|
||||
}
|
||||
} catch {
|
||||
showToast('网络异常')
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegisterBoss = async () => {
|
||||
const f = bossForm
|
||||
if (!f.storeName) return showToast('请输入店铺名称')
|
||||
if (!f.bossName) return showToast('请输入您的姓名')
|
||||
if (!f.phone || f.phone.length !== 11) return showToast('请输入正确的手机号')
|
||||
if (!f.wxPhoneAuto && (!f.smsCode || f.smsCode.length !== 6)) return showToast('请输入6位验证码')
|
||||
if (!f.password || f.password.length < 6) return showToast('密码至少6位')
|
||||
const payload = { ...f }
|
||||
if (f.wxPhoneAuto) delete payload.smsCode
|
||||
regLoading.value = true
|
||||
const res = await registerBoss(payload)
|
||||
regLoading.value = false
|
||||
if (isHttpOk(res)) {
|
||||
const parsed = parseBossRegData(res.data)
|
||||
if (parsed) {
|
||||
const n = safeLine(parsed.storeName, '—')
|
||||
const p = safeLine(parsed.phone, '—')
|
||||
const pw = safeLine(parsed.password, '—')
|
||||
const code = safeLine(parsed.inviteCode, '—')
|
||||
const content = `店铺:${n}\n账号:${p}\n密码:${pw}\n邀请码:${code}\n\n请妥善保存邀请码。`
|
||||
uni.showModal({
|
||||
title: '入驻成功',
|
||||
content,
|
||||
cancelText: '前往登录',
|
||||
confirmText: '复制邀请码',
|
||||
success: (r) => {
|
||||
if (r.confirm && code && code !== '—') {
|
||||
uni.setClipboardData({ data: code, success: () => uni.showToast({ title: '已复制', icon: 'none' }) })
|
||||
}
|
||||
backToLoginForm()
|
||||
}
|
||||
const response = await registerBoss({
|
||||
storeName: bossForm.storeName.trim(),
|
||||
bossName: bossForm.bossName.trim(),
|
||||
phoneCode,
|
||||
loginCode
|
||||
})
|
||||
} else {
|
||||
showToast('注册成功,请用手机号登录')
|
||||
}
|
||||
} else {
|
||||
showToast(res.message || '注册失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegisterStaff = async () => {
|
||||
const f = staffForm
|
||||
if (!f.inviteCode || f.inviteCode.length !== 8) return showToast('请输入8位邀请码')
|
||||
if (!f.name) return showToast('请输入您的姓名')
|
||||
if (!f.phone || f.phone.length !== 11) return showToast('请输入正确的手机号')
|
||||
if (!f.wxPhoneAuto && (!f.smsCode || f.smsCode.length !== 6)) return showToast('请输入6位验证码')
|
||||
if (!f.password || f.password.length < 6) return showToast('密码至少6位')
|
||||
const payload = { ...f }
|
||||
if (f.wxPhoneAuto) delete payload.smsCode
|
||||
regLoading.value = true
|
||||
const res = await registerStaff(payload)
|
||||
regLoading.value = false
|
||||
if (isHttpOk(res)) {
|
||||
const parsed = parseStaffRegData(res.data)
|
||||
if (parsed) {
|
||||
const shop = safeLine(parsed.storeName, '未知店铺')
|
||||
const phone = safeLine(parsed.phone, '—')
|
||||
if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '门店创建失败')
|
||||
uni.showModal({
|
||||
title: '注册成功',
|
||||
content: `已加入「${shop}」\n账号:${phone}`,
|
||||
title: '门店已创建',
|
||||
content: '接下来补全门店资料、确认预约容量和服务项目,即可完成开通。',
|
||||
showCancel: false,
|
||||
confirmText: '前往登录',
|
||||
complete: () => backToLoginForm()
|
||||
confirmText: '继续开通',
|
||||
success: () => uni.reLaunch({ url: '/pages/mine/Onboarding' })
|
||||
})
|
||||
} else {
|
||||
showToast('注册成功,请用手机号登录')
|
||||
} catch (_) {
|
||||
showToast('网络异常,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
} else {
|
||||
showToast(res.message || '注册失败')
|
||||
}
|
||||
|
||||
function openStaffInvite() {
|
||||
view.value = 'staff-invite'
|
||||
}
|
||||
|
||||
async function loadInvitation() {
|
||||
const token = inviteToken.value.trim()
|
||||
if (!token) return showToast('请粘贴邀请口令')
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const response = await previewStaffInvitation(token)
|
||||
if (!isOk(response)) {
|
||||
invitation.value = null
|
||||
return showToast(response.message || '邀请不存在或已失效')
|
||||
}
|
||||
invitation.value = response.data
|
||||
} catch (_) {
|
||||
showToast('邀请查询失败,请检查网络')
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onAcceptInvitation(event) {
|
||||
const phoneCode = validPhoneEvent(event)
|
||||
if (!phoneCode || !invitation.value?.canAccept) return
|
||||
loading.value = true
|
||||
try {
|
||||
const loginCode = await fetchWxLoginCode()
|
||||
const response = await acceptStaffInvitation(inviteToken.value.trim(), phoneCode, loginCode)
|
||||
if (!isOk(response) || !saveSession(response.data)) return showToast(response.message || '加入门店失败')
|
||||
uni.showModal({
|
||||
title: '已加入门店',
|
||||
content: `你已加入「${invitation.value.storeName}」,现在可以开始处理门店工作。`,
|
||||
showCancel: false,
|
||||
confirmText: '进入工作台',
|
||||
success: () => openAppPage('home')
|
||||
})
|
||||
} catch (_) {
|
||||
showToast('网络异常,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetInvitation() {
|
||||
invitation.value = null
|
||||
inviteToken.value = ''
|
||||
}
|
||||
|
||||
function backToLogin() {
|
||||
view.value = 'login'
|
||||
}
|
||||
|
||||
function wechatOnly() {
|
||||
showToast('请在微信小程序内完成此操作')
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value).replace('T', ' ')
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (smsTimer) clearInterval(smsTimer)
|
||||
if (bossSmsTimer) clearInterval(bossSmsTimer)
|
||||
if (staffSmsTimer) clearInterval(staffSmsTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: #fafaf8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
/* 品牌 */
|
||||
.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-page { min-height: 100vh; padding: 0 24px 40px; background: #fafaf8; box-sizing: border-box; }
|
||||
.login-brand { padding: 76px 0 36px; text-align: center; }
|
||||
.brand-name { display: block; color: #1a1a1a; font-size: 28px; font-weight: 800; letter-spacing: 1px; }
|
||||
.brand-sub { display: block; margin-top: 6px; color: var(--c-text-3); font-size: 13px; }
|
||||
.card { padding: 28px 24px; background: #fff; border-radius: 20px; box-shadow: 0 2px 12px rgba(0,0,0,.04); }
|
||||
.card-title { display: block; margin-bottom: 20px; color: #1a1a1a; font-size: 20px; font-weight: 700; }
|
||||
.field { display: block; width: 100%; height: 48px; min-height: 48px; margin-bottom: 12px; padding: 0 14px; color: #1a1a1a; font-size: 16px; line-height: 48px; background: #f5f5f2; border: 1.5px solid transparent; border-radius: 12px; box-sizing: border-box; }
|
||||
.sms-row,.token-row { display: flex; align-items: stretch; gap: 10px; margin-bottom: 12px; }
|
||||
.sms-input,.token-input { flex: 1; min-width: 0; margin-bottom: 0; }
|
||||
.sms-code-btn { flex-shrink: 0; align-self: center; }
|
||||
.login-main-btn { margin-top: 4px !important; }
|
||||
|
||||
/* 分割线 */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 20px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
.secondary-btn { margin-top: 10px !important; }
|
||||
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; }
|
||||
.divider-line { flex: 1; height: 1px; background: #ebebea; }
|
||||
.divider-text { font-size: 12px; color: #bbbbb5; }
|
||||
|
||||
/* 底部链接 */
|
||||
.footer-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 28px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #f0f0ee;
|
||||
}
|
||||
.link-btn {
|
||||
font-size: 14px;
|
||||
color: var(--c-brand);
|
||||
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;
|
||||
}
|
||||
.divider-text { color: #bbbbb5; font-size: 12px; }
|
||||
.footer-links { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 28px; padding-top: 20px; border-top: 1px solid #f0f0ee; }
|
||||
.link-btn { padding: 8px 4px; color: var(--c-brand); font-size: 14px; font-weight: 600; }
|
||||
.link-dot { width: 3px; height: 3px; background: #ddddd8; border-radius: 50%; }
|
||||
.back-link { display: block; padding: 8px; margin-top: 20px; color: var(--c-text-3); font-size: 14px; text-align: center; }
|
||||
.hint-box { padding: 11px 14px; margin-bottom: 14px; color: var(--c-brand); font-size: 13px; line-height: 1.5; background: var(--c-brand-light); border-radius: 10px; }
|
||||
.invitation-preview { position: relative; padding: 18px; margin-bottom: 16px; overflow: hidden; background: linear-gradient(145deg,#f5fff8,#fff); border: 1px solid #d7f0e0; border-radius: 14px; }
|
||||
.preview-kicker { display: block; color: var(--c-text-3); font-size: 11px; font-weight: 700; letter-spacing: .12em; }
|
||||
.preview-store { display: block; margin: 6px 0 16px; color: #193326; font-size: 21px; font-weight: 800; }
|
||||
.preview-row { display: flex; justify-content: space-between; padding: 8px 0; color: #6b756f; font-size: 13px; border-top: 1px solid #edf3ef; }
|
||||
.preview-row text:last-child { color: #25342c; font-weight: 600; }
|
||||
.status-pill { display: inline-flex; padding: 5px 9px; margin-top: 12px; font-size: 12px; font-weight: 700; border-radius: 999px; }
|
||||
.status-pending { color: #9a6500; background: #fff1c7; }
|
||||
.status-accepted { color: #147340; background: #dff6e9; }
|
||||
.status-revoked,.status-expired { color: #7c8189; background: #eef0f2; }
|
||||
.non-mp-notice { padding: 12px; color: #7b6a3d; font-size: 13px; text-align: center; background: #fff8e6; border-radius: 10px; }
|
||||
</style>
|
||||
|
||||
@ -19,6 +19,11 @@
|
||||
<view v-if="userInfo.role === 'boss'" class="menu-group">
|
||||
<text class="menu-label module-title">店铺与经营</text>
|
||||
<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-icon-wrap"><AppIcon name="service" :size="16" color="#666660" /></view>
|
||||
<text class="menu-text">服务类型</text>
|
||||
@ -115,6 +120,7 @@ const userInfo = getUserSession()
|
||||
const { goPage, openAppPage } = useNavigator()
|
||||
|
||||
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 goStorePage = () => uni.navigateTo({ url: '/pages/mine/Store' })
|
||||
const goSchedulePage = () => uni.navigateTo({ url: '/pages/mine/Schedule' })
|
||||
|
||||
175
src/pages/mine/Onboarding.vue
Normal file
175
src/pages/mine/Onboarding.vue
Normal file
@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<view class="page-shell onboarding-page">
|
||||
<view class="top-nav nav-gradient" :style="navSafeStyle">
|
||||
<view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
|
||||
<text class="nav-title">门店开通</text>
|
||||
<view class="nav-placeholder" />
|
||||
</view>
|
||||
|
||||
<view class="page-body">
|
||||
<view class="hero-card">
|
||||
<text class="hero-kicker">PILOT READINESS</text>
|
||||
<view class="hero-title-row">
|
||||
<text class="hero-title">准备好真实接待</text>
|
||||
<text class="status-tag" :class="`status-${status?.status || 'loading'}`">{{ statusLabel }}</text>
|
||||
</view>
|
||||
<text class="hero-desc">资料、容量和服务项目是必填项;单人门店可以稍后邀请员工。</text>
|
||||
<view class="progress-track"><view class="progress-fill" :style="`width:${progress}%`" /></view>
|
||||
<text class="progress-text">{{ status?.completedStepCount || 0 }}/{{ status?.totalStepCount || 4 }} 步完成</text>
|
||||
</view>
|
||||
|
||||
<view class="step-list">
|
||||
<view
|
||||
v-for="step in status?.steps || []"
|
||||
:key="step.id"
|
||||
class="step-card"
|
||||
:class="{ done: step.completed }"
|
||||
@click="openStep(step.id)"
|
||||
>
|
||||
<view class="step-icon">{{ step.completed ? '✓' : step.required ? '!' : '+' }}</view>
|
||||
<view class="step-copy">
|
||||
<view class="step-title-row">
|
||||
<text class="step-title">{{ step.title }}</text>
|
||||
<text class="step-kind">{{ step.required ? '必填' : '选填' }}</text>
|
||||
</view>
|
||||
<text class="step-hint">{{ step.hint }}</text>
|
||||
</view>
|
||||
<text class="step-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="status" class="finish-card">
|
||||
<text v-if="status.status === 'completed'" class="finish-copy success-copy">门店已完成开通,可以进入真实试点。</text>
|
||||
<text v-else-if="status.readyToComplete" class="finish-copy">必填项已齐,请确认后完成开通。</text>
|
||||
<text v-else class="finish-copy">尚缺:{{ status.missingRequiredSteps.join('、') }}</text>
|
||||
<button
|
||||
v-if="status.status !== 'completed'"
|
||||
class="van-button van-button--primary van-button--block finish-btn"
|
||||
:disabled="!status.readyToComplete || completing"
|
||||
@click="finish"
|
||||
>{{ completing ? '确认中…' : '完成门店开通' }}</button>
|
||||
<button v-else class="van-button van-button--primary van-button--block finish-btn" @click="goHome">进入工作台</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import AppIcon from '../../components/AppIcon.vue'
|
||||
import { completeOnboarding, getOnboardingStatus } from '../../api/index.js'
|
||||
import { openAppPage } from '../../utils/globalState.js'
|
||||
|
||||
const status = ref(null)
|
||||
const completing = ref(false)
|
||||
|
||||
const navSafeStyle = (() => {
|
||||
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
||||
let navHeight = statusBarHeight + 44
|
||||
const menuRect = uni.getMenuButtonBoundingClientRect?.()
|
||||
if (menuRect?.top && menuRect?.height) {
|
||||
const gap = Math.max(menuRect.top - statusBarHeight, 4)
|
||||
navHeight = statusBarHeight + gap * 2 + menuRect.height
|
||||
}
|
||||
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
|
||||
})()
|
||||
|
||||
const progress = computed(() => {
|
||||
if (!status.value?.totalStepCount) return 0
|
||||
return Math.round(status.value.completedStepCount / status.value.totalStepCount * 100)
|
||||
})
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
if (status.value?.status === 'completed') return '已开通'
|
||||
if (status.value?.status === 'unknown') return '待确认'
|
||||
if (!status.value) return '加载中'
|
||||
return '开通中'
|
||||
})
|
||||
|
||||
async function load() {
|
||||
const response = await getOnboardingStatus()
|
||||
if (response.code === 200) {
|
||||
status.value = response.data
|
||||
} else {
|
||||
uni.showToast({ title: response.message || '开通清单加载失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function openStep(stepId) {
|
||||
const routes = {
|
||||
profile: '/pages/mine/Store',
|
||||
booking: '/pages/mine/Store',
|
||||
services: '/pages/mine/ServiceType',
|
||||
team: '/pages/mine/Staff'
|
||||
}
|
||||
uni.navigateTo({ url: routes[stepId] || '/pages/mine/Mine' })
|
||||
}
|
||||
|
||||
function finish() {
|
||||
uni.showModal({
|
||||
title: '完成门店开通',
|
||||
content: '确认门店资料、号源容量与服务项目已可用于真实接待?',
|
||||
confirmText: '确认完成',
|
||||
success: async (result) => {
|
||||
if (!result.confirm) return
|
||||
completing.value = true
|
||||
try {
|
||||
const response = await completeOnboarding()
|
||||
if (response.code !== 200) {
|
||||
uni.showToast({ title: response.message || '暂不能完成开通', icon: 'none' })
|
||||
return
|
||||
}
|
||||
status.value = response.data
|
||||
uni.showToast({ title: '门店已开通', icon: 'success' })
|
||||
} finally {
|
||||
completing.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) uni.navigateBack()
|
||||
else openAppPage('mine')
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
openAppPage('home')
|
||||
}
|
||||
|
||||
onShow(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.onboarding-page { min-height: 100vh; padding-bottom: 80rpx; background: #f6f8f7; }
|
||||
.top-nav { position: sticky; top: 0; z-index: 10; display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; }
|
||||
.nav-back,.nav-placeholder { width: 32px; }
|
||||
.nav-title { color: #fff; font-size: 18px; font-weight: 700; }
|
||||
.page-body { padding: 14px; }
|
||||
.hero-card { padding: 20px; color: #173b29; background: radial-gradient(circle at 88% 4%,rgba(46,185,109,.18),transparent 35%),#fff; border: 1px solid #d8eee1; border-radius: 18px; box-shadow: 0 8px 24px rgba(20,70,43,.06); }
|
||||
.hero-kicker { display: block; color: var(--c-brand); font-size: 11px; font-weight: 800; letter-spacing: .14em; }
|
||||
.hero-title-row,.step-title-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.hero-title { margin: 8px 0 5px; font-size: 23px; font-weight: 800; }
|
||||
.hero-desc { display: block; color: #6d7e75; font-size: 13px; line-height: 1.55; }
|
||||
.status-tag { flex-shrink: 0; padding: 5px 9px; color: #8a6518; font-size: 11px; font-weight: 700; background: #fff3cd; border-radius: 999px; }
|
||||
.status-completed { color: #16733f; background: #dff6e9; }
|
||||
.progress-track { height: 8px; margin-top: 18px; overflow: hidden; background: #edf1ee; border-radius: 99px; }
|
||||
.progress-fill { height: 100%; background: linear-gradient(90deg,#28a961,var(--c-brand)); border-radius: 99px; transition: width .25s ease; }
|
||||
.progress-text { display: block; margin-top: 7px; color: #7b887f; font-size: 11px; text-align: right; }
|
||||
.step-list { display: grid; gap: 10px; margin-top: 14px; }
|
||||
.step-card { display: flex; align-items: center; gap: 12px; padding: 15px; background: #fff; border: 1px solid #e8ece9; border-radius: 14px; }
|
||||
.step-card.done { background: #f7fdf9; border-color: #d6eddf; }
|
||||
.step-icon { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 34px; height: 34px; color: #a17016; font-size: 14px; font-weight: 800; background: #fff2cd; border-radius: 10px; }
|
||||
.step-card.done .step-icon { color: #fff; background: var(--c-brand); }
|
||||
.step-copy { flex: 1; min-width: 0; }
|
||||
.step-title { color: #27342d; font-size: 15px; font-weight: 700; }
|
||||
.step-kind { color: #9aa49e; font-size: 10px; }
|
||||
.step-hint { display: block; margin-top: 4px; color: #879189; font-size: 12px; line-height: 1.4; }
|
||||
.step-arrow { color: #a9b1ac; font-size: 24px; }
|
||||
.finish-card { padding: 16px; margin-top: 14px; background: #fff; border: 1px solid #e8ece9; border-radius: 14px; }
|
||||
.finish-copy { display: block; margin-bottom: 12px; color: #7c6a3d; font-size: 13px; line-height: 1.5; }
|
||||
.success-copy { color: #16733f; }
|
||||
.finish-btn { margin: 0 !important; }
|
||||
</style>
|
||||
@ -17,7 +17,7 @@
|
||||
</view>
|
||||
<view class="sched-hero-text">
|
||||
<text class="sched-hero-title">时段一览</text>
|
||||
<text class="sched-hero-desc">绿色为客户预约;橙色为到店占用;灰色为暂停线上预约。点空白格可手动占用,点占用可取消。</text>
|
||||
<text class="sched-hero-desc">绿色为客户预约;橙色为到店占用;灰色为暂停线上预约。每行显示已用/总容量。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
<view class="sched-main">
|
||||
<template v-if="row.kind === 'appointment' && row.appointment">
|
||||
<text class="sched-line-title">{{ row.appointment.petName || '宠物' }} · {{ row.appointment.serviceType || '服务' }}</text>
|
||||
<text class="sched-line-sub">{{ apptStatusText(row.appointment.status) }}</text>
|
||||
<text class="sched-line-sub">{{ apptStatusText(row.appointment.status) }} · 容量 {{ row.usedCapacity || 0 }}/{{ row.totalCapacity || 1 }}</text>
|
||||
</template>
|
||||
<template v-else-if="row.kind === 'block' && row.block">
|
||||
<text class="sched-line-title">{{ blockTitle(row.block) }}</text>
|
||||
@ -61,7 +61,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="sched-foot-hint">与「营业时间」一致,每半小时一档;占用后客户端该档不可再约。</view>
|
||||
<view class="sched-foot-hint">与营业容量时段一致;服务和到店占用会跨半小时桶消耗名额,暂停预约会关闭全部名额。</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@ -212,6 +212,7 @@ function onRowTap(row) {
|
||||
const res = await createScheduleBlock({
|
||||
storeId: storeInfo.id,
|
||||
slotStart,
|
||||
durationMinutes: 30,
|
||||
blockType,
|
||||
note: '',
|
||||
createdByUserId: userInfo.id
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
<view class="st-name-row">
|
||||
<span class="st-dot"><AppIcon name="service" :size="14" /></span>
|
||||
<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>
|
||||
</view>
|
||||
@ -58,6 +59,13 @@
|
||||
<view class="popup-desc">建议使用简洁明确的命名,方便前台下单与报告展示。</view>
|
||||
<view class="field-label">服务类型名称</view>
|
||||
<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 class="popup-footer">
|
||||
<view class="popup-actions">
|
||||
@ -71,7 +79,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import AppIcon from '../../components/AppIcon.vue'
|
||||
import { navigateTo } from '../../utils/globalState.js'
|
||||
import { getServiceTypeList, createServiceType, deleteServiceType } from '../../api/index.js'
|
||||
@ -102,6 +110,10 @@ const navSafeStyle = (() => {
|
||||
const serviceTypes = ref([])
|
||||
const showNew = ref(false)
|
||||
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 touchStartX = ref(0)
|
||||
const touchCurrentX = ref(0)
|
||||
@ -111,12 +123,17 @@ const load = async () => {
|
||||
if (res.code === 200) serviceTypes.value = res.data
|
||||
}
|
||||
|
||||
const onDurationChange = (e) => {
|
||||
newDuration.value = DURATION_OPTIONS[Number(e.detail.value)] || 60
|
||||
}
|
||||
|
||||
const confirmAdd = async () => {
|
||||
if (!newName.value) { uni.showToast({ title: '请输入服务类型名称', icon: 'none' }); return }
|
||||
const res = await createServiceType(storeInfo.id, newName.value)
|
||||
const res = await createServiceType(storeInfo.id, newName.value, newDuration.value)
|
||||
if (res.code === 200) {
|
||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||
newName.value = ''
|
||||
newDuration.value = 60
|
||||
showNew.value = false
|
||||
load()
|
||||
} else {
|
||||
@ -193,6 +210,8 @@ onMounted(() => load())
|
||||
background: var(--c-slate-100);
|
||||
}
|
||||
.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; }
|
||||
/* 与员工管理页一致:删除区绝对贴底,主内容盖住;左滑才露出 */
|
||||
.st-swipe-wrap {
|
||||
|
||||
@ -1,392 +1,290 @@
|
||||
<template>
|
||||
<view class="page-shell staff-page">
|
||||
<view class="staff-nav nav-gradient" :style="navSafeStyle">
|
||||
<view class="nav-back" @click="goBackToMine"><AppIcon name="back" :size="18" color="#ffffff" /></view>
|
||||
<view class="nav-title">员工管理</view>
|
||||
<view class="nav-placeholder"></view>
|
||||
<view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
|
||||
<view class="nav-title">员工与邀请</view>
|
||||
<view class="nav-placeholder" />
|
||||
</view>
|
||||
|
||||
<!-- 邀请码:标题与码+按钮分行,同一行内码与复制垂直居中 -->
|
||||
<view class="page-section invite-wrap">
|
||||
<text class="invite-kicker">员工邀请码</text>
|
||||
<view class="invite-code-row">
|
||||
<text class="invite-code-text" selectable>{{ inviteCodeText }}</text>
|
||||
<button class="invite-copy-btn" hover-class="invite-copy-btn--hover" @click="copyCode">复制</button>
|
||||
</view>
|
||||
<text class="invite-tip">新员工注册时填写此码即可加入本店</text>
|
||||
<view class="page-body">
|
||||
<view class="invite-hero">
|
||||
<text class="invite-kicker">ONE-TIME INVITE</text>
|
||||
<text class="invite-title">邀请员工本人加入</text>
|
||||
<text class="invite-desc">邀请绑定微信手机号、限时且只能使用一次。原始口令只在创建后显示,不再使用永久门店邀请码。</text>
|
||||
<button class="van-button van-button--primary van-button--block hero-btn" @click="showCreate = true">创建员工邀请</button>
|
||||
</view>
|
||||
|
||||
<view class="page-section add-btn-wrap">
|
||||
<button class="van-button van-button--primary van-button--block" @click="showAddStaff = true">新增员工</button>
|
||||
<view class="section-head"><text>门店成员</text><text>{{ staffList.length }} 人</text></view>
|
||||
<view class="list-card">
|
||||
<view v-for="staff in staffList" :key="staff.id" class="staff-row">
|
||||
<view class="avatar" :style="avatarStyle(staff)">{{ staffInitial(staff) }}</view>
|
||||
<view class="staff-copy">
|
||||
<text class="staff-name">{{ staff.name }}</text>
|
||||
<text class="staff-meta">{{ staff.phone }} · {{ staff.role === 'boss' ? '老板' : '员工' }}</text>
|
||||
</view>
|
||||
<text v-if="staff.role === 'staff'" class="delete-link" @click="removeStaff(staff)">删除</text>
|
||||
</view>
|
||||
<view v-if="staffList.length === 0" class="empty-row">暂无门店成员</view>
|
||||
</view>
|
||||
|
||||
<!-- 员工列表 -->
|
||||
<view class="page-section section-gap">
|
||||
<view class="van-cell-group card-section">
|
||||
<view
|
||||
v-for="(s, idx) in staffList"
|
||||
:key="s.id != null ? s.id : 'row-' + idx"
|
||||
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 class="section-head invitation-head"><text>邀请记录</text><text>{{ invitations.length }} 条</text></view>
|
||||
<view class="list-card">
|
||||
<view v-for="item in invitations" :key="item.invitationId" class="invitation-row">
|
||||
<view class="invitation-main">
|
||||
<view class="invitation-title-row">
|
||||
<text class="staff-name">{{ item.invitedName }}</text>
|
||||
<text class="status-pill" :class="`status-${item.status}`">{{ statusLabel(item.status) }}</text>
|
||||
</view>
|
||||
<text class="staff-meta">{{ item.maskedPhone }}</text>
|
||||
<text class="staff-meta">有效期至 {{ formatDate(item.expiresAt) }}</text>
|
||||
</view>
|
||||
<text v-if="item.status === 'pending'" class="delete-link" @click="revoke(item)">撤销</text>
|
||||
</view>
|
||||
<view v-if="invitations.length === 0" class="empty-row">尚未创建员工邀请</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="staffList.length === 0" class="empty"><text>暂无员工</text></view>
|
||||
|
||||
<view v-if="showAddStaff" class="popup-mask" @click="showAddStaff = false">
|
||||
<view v-if="showCreate" class="popup-mask" @click="showCreate = false">
|
||||
<view class="popup-content" @click.stop>
|
||||
<view class="popup-header">
|
||||
<view class="popup-title">新增员工</view>
|
||||
<view class="popup-close" @click="showAddStaff = false">✕</view>
|
||||
</view>
|
||||
<view class="popup-header"><text class="popup-title">创建员工邀请</text><text class="popup-close" @click="showCreate = false">✕</text></view>
|
||||
<view class="popup-body">
|
||||
<view class="popup-desc">创建后会自动生成初始密码,员工可使用手机号登录。</view>
|
||||
<view class="field-label">员工姓名</view>
|
||||
<input v-model="newStaff.name" class="van-field" placeholder="请输入" />
|
||||
<view class="field-label">手机号</view>
|
||||
<input v-model="newStaff.phone" type="tel" class="van-field" placeholder="请输入" maxlength="11" />
|
||||
<text class="popup-desc">请填写员工本人用于微信授权的手机号。手机号不一致时系统会拒绝加入。</text>
|
||||
<text class="field-label">员工姓名</text>
|
||||
<input v-model="form.name" class="van-field" placeholder="请输入" maxlength="64" />
|
||||
<text class="field-label">本人微信手机号</text>
|
||||
<input v-model="form.phone" type="tel" class="van-field" placeholder="请输入" maxlength="11" />
|
||||
<text class="field-label">有效期</text>
|
||||
<picker mode="selector" :range="validDayLabels" :value="validDayIndex" @change="onValidDayChange">
|
||||
<view class="van-field picker-field">{{ form.validDays }} 天 <text>›</text></view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="popup-footer">
|
||||
<view class="popup-actions">
|
||||
<button class="van-button btn-ghost" @click="showAddStaff = false">取消</button>
|
||||
<button class="van-button van-button--primary btn-confirm" @click="confirmAddStaff">确认创建</button>
|
||||
<button class="van-button btn-ghost" @click="showCreate = false">取消</button>
|
||||
<button class="van-button van-button--primary btn-confirm" :disabled="creating" @click="createInvite">
|
||||
{{ creating ? '创建中…' : '创建邀请' }}
|
||||
</button>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { onShareAppMessage, onShow } from '@dcloudio/uni-app'
|
||||
import AppIcon from '../../components/AppIcon.vue'
|
||||
import {
|
||||
createStaffInvitation,
|
||||
deleteStaff,
|
||||
getStaffInvitations,
|
||||
getStaffList,
|
||||
revokeStaffInvitation
|
||||
} from '../../api/index.js'
|
||||
import { openAppPage } from '../../utils/globalState.js'
|
||||
import { getStaffList, createStaff, deleteStaff as delStaff } from '../../api/index.js'
|
||||
import { getStoreSession } from '../../utils/session.js'
|
||||
|
||||
const emit = defineEmits(['change-page'])
|
||||
const storeInfo = getStoreSession()
|
||||
const inviteCodeText = computed(() => (storeInfo && storeInfo.inviteCode) ? String(storeInfo.inviteCode) : '—')
|
||||
const staffList = ref([])
|
||||
const invitations = ref([])
|
||||
const showCreate = ref(false)
|
||||
const creating = ref(false)
|
||||
const createdInvite = ref(null)
|
||||
const form = reactive({ name: '', phone: '', validDays: 7 })
|
||||
const validDays = [3, 7, 14, 30]
|
||||
const validDayLabels = validDays.map((day) => `${day} 天`)
|
||||
const validDayIndex = computed(() => Math.max(0, validDays.indexOf(form.validDays)))
|
||||
|
||||
const navSafeStyle = (() => {
|
||||
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
|
||||
}
|
||||
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
||||
let navHeight = statusBarHeight + 44
|
||||
const menuRect = uni.getMenuButtonBoundingClientRect?.()
|
||||
if (menuRect && menuRect.top && menuRect.height) {
|
||||
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4)
|
||||
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height
|
||||
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 goBackToMine = () => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack()
|
||||
return
|
||||
}
|
||||
openAppPage('mine')
|
||||
async function load() {
|
||||
const [staffResponse, invitationResponse] = await Promise.all([getStaffList(), getStaffInvitations()])
|
||||
if (staffResponse.code === 200) staffList.value = Array.isArray(staffResponse.data) ? staffResponse.data : []
|
||||
if (invitationResponse.code === 200) invitations.value = Array.isArray(invitationResponse.data) ? invitationResponse.data : []
|
||||
}
|
||||
|
||||
const staffInitial = (s) => {
|
||||
const n = s && s.name
|
||||
return (typeof n === 'string' && n.length > 0) ? n[0] : '?'
|
||||
function onValidDayChange(event) {
|
||||
form.validDays = validDays[Number(event.detail.value)] || 7
|
||||
}
|
||||
|
||||
const staffList = ref([])
|
||||
const showAddStaff = ref(false)
|
||||
const newStaff = ref({ name: '', phone: '' })
|
||||
const swipedId = ref(null)
|
||||
const touchStartX = ref(0)
|
||||
const touchCurrentX = ref(0)
|
||||
|
||||
const COLORS = ['#f5913e', 'var(--c-brand)', '#8b6914', '#e06050', '#5090d0', '#9b59b6']
|
||||
const avatarStyle = (s) => {
|
||||
if (s.avatar) return { background: 'transparent' }
|
||||
const idx = (s.name?.charCodeAt(0) || 0) % COLORS.length
|
||||
return { background: COLORS[idx] }
|
||||
}
|
||||
|
||||
const loadStaff = async () => {
|
||||
const res = await getStaffList(storeInfo.id)
|
||||
if (res.code === 200) staffList.value = res.data
|
||||
}
|
||||
|
||||
const copyCode = () => {
|
||||
const code = inviteCodeText.value === '—' ? '' : inviteCodeText.value
|
||||
if (!code) return
|
||||
uni.setClipboardData({
|
||||
data: code,
|
||||
success: () => uni.showToast({ title: '邀请码已复制', icon: 'none' })
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
if (response.code !== 200 || !response.data?.inviteToken) {
|
||||
uni.showToast({ title: response.message || '邀请创建失败', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (deltaX > 24 && swipedId.value === staff.id) {
|
||||
swipedId.value = null
|
||||
createdInvite.value = response.data
|
||||
showCreate.value = false
|
||||
form.name = ''
|
||||
form.phone = ''
|
||||
await load()
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onRowTap = (staff) => {
|
||||
if (swipedId.value && swipedId.value !== staff.id) {
|
||||
swipedId.value = null
|
||||
}
|
||||
function closeCreated() {
|
||||
createdInvite.value = null
|
||||
}
|
||||
|
||||
const deleteStaff = async (staffId) => {
|
||||
function copyInvite() {
|
||||
if (!createdInvite.value?.inviteToken) return
|
||||
const message = [
|
||||
`【宠小它员工邀请】${createdInvite.value.storeName} 邀请 ${createdInvite.value.invitedName} 加入门店。`,
|
||||
`邀请口令:${createdInvite.value.inviteToken}`,
|
||||
`小程序路径:${createdInvite.value.invitePath}`,
|
||||
`有效期至:${formatDate(createdInvite.value.expiresAt)}`,
|
||||
'请使用受邀手机号在宠小它小程序完成微信核验。'
|
||||
].join('\n')
|
||||
uni.setClipboardData({ data: message, success: () => uni.showToast({ title: '邀请已复制', icon: 'none' }) })
|
||||
}
|
||||
|
||||
function revoke(item) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '确定删除该员工?',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
const r = await delStaff(staffId)
|
||||
if (r.code === 200) {
|
||||
swipedId.value = null
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
loadStaff()
|
||||
title: '撤销邀请',
|
||||
content: `确认撤销发给「${item.invitedName}」的邀请?`,
|
||||
success: async (result) => {
|
||||
if (!result.confirm) return
|
||||
const response = await revokeStaffInvitation(item.invitationId)
|
||||
if (response.code === 200) {
|
||||
uni.showToast({ title: '邀请已撤销', icon: 'success' })
|
||||
load()
|
||||
} else {
|
||||
uni.showToast({ title: response.message || '撤销失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => loadStaff())
|
||||
function removeStaff(staff) {
|
||||
uni.showModal({
|
||||
title: '删除员工',
|
||||
content: `确认删除「${staff.name}」?删除后其门店访问权限将失效。`,
|
||||
success: async (result) => {
|
||||
if (!result.confirm) return
|
||||
const response = await deleteStaff(staff.id)
|
||||
if (response.code === 200) {
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
load()
|
||||
} else {
|
||||
uni.showToast({ title: response.message || '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
return { pending: '待接受', accepted: '已加入', revoked: '已撤销', expired: '已过期' }[status] || '未知'
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return String(value).replace('T', ' ')
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日 ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function staffInitial(staff) {
|
||||
return String(staff?.name || '?').slice(0, 1)
|
||||
}
|
||||
|
||||
function avatarStyle(staff) {
|
||||
const colors = ['#f5913e', 'var(--c-brand)', '#5090d0', '#e06050', '#8b6914']
|
||||
return { background: colors[(staff?.name?.charCodeAt(0) || 0) % colors.length] }
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) uni.navigateBack()
|
||||
else openAppPage('mine')
|
||||
}
|
||||
|
||||
onShareAppMessage(() => ({
|
||||
title: createdInvite.value
|
||||
? `${createdInvite.value.storeName} 邀请你加入门店`
|
||||
: '宠小它员工邀请',
|
||||
path: createdInvite.value?.invitePath || '/pages/login/Login'
|
||||
}))
|
||||
|
||||
onShow(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.staff-page { padding-bottom: 120rpx; }
|
||||
.nav-placeholder { width: 32px; }
|
||||
.add-btn-wrap { padding: 0; }
|
||||
.card-section { margin: 0; }
|
||||
.staff-cell { min-height: 58px; }
|
||||
|
||||
/* 邀请码卡片 */
|
||||
.invite-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 16px 16px 14px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
border: 1px solid #f0f0ee;
|
||||
}
|
||||
.invite-kicker {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--c-text-3);
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.invite-code-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 44px;
|
||||
}
|
||||
.invite-code-text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
font-family: ui-monospace, Menlo, Monaco, Consolas, monospace;
|
||||
color: #1a1a1a;
|
||||
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;
|
||||
}
|
||||
.staff-page { min-height: 100vh; padding-bottom: 100rpx; background: #f6f8f7; }
|
||||
.staff-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; }
|
||||
.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-desc { display: block; color: #738078; font-size: 13px; line-height: 1.55; }
|
||||
.hero-btn { margin: 16px 0 0 !important; }
|
||||
.section-head { display: flex; justify-content: space-between; padding: 20px 4px 8px; color: #657269; font-size: 13px; font-weight: 700; }
|
||||
.section-head text:last-child { color: #9aa39d; font-weight: 500; }
|
||||
.list-card { overflow: hidden; background: #fff; border: 1px solid #e7ebe8; border-radius: 14px; }
|
||||
.staff-row,.invitation-row { display: flex; align-items: center; gap: 12px; min-height: 64px; padding: 12px 14px; }
|
||||
.staff-row + .staff-row,.invitation-row + .invitation-row { border-top: 1px solid #eff2f0; }
|
||||
.avatar { display: flex; flex-shrink: 0; align-items: center; justify-content: center; width: 40px; height: 40px; color: #fff; font-weight: 700; border-radius: 12px; }
|
||||
.staff-copy,.invitation-main { display: grid; flex: 1; gap: 3px; min-width: 0; }
|
||||
.staff-name { color: #28342d; font-size: 15px; font-weight: 700; }
|
||||
.staff-meta { color: #8a948e; font-size: 12px; }
|
||||
.delete-link { flex-shrink: 0; padding: 8px 0 8px 10px; color: #dc5b5b; font-size: 12px; }
|
||||
.invitation-head { margin-top: 4px; }
|
||||
.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; }
|
||||
.status-accepted { color: #177440; background: #dcf5e7; }
|
||||
.status-revoked { color: #a14b4b; background: #fde7e7; }
|
||||
.status-expired { color: #737d77; background: #edf0ee; }
|
||||
.empty-row { padding: 28px; color: #a1aaa4; font-size: 13px; text-align: center; }
|
||||
.popup-mask { position: fixed; inset: 0; z-index: 100; display: flex; align-items: flex-end; justify-content: center; background: rgba(0,0,0,.48); }
|
||||
.popup-content { width: 100%; max-width: 430px; background: #fff; border-radius: 18px 18px 0 0; }
|
||||
.popup-header { display: flex; align-items: center; justify-content: space-between; padding: 17px 20px; border-bottom: 1px solid #f0f2f0; }
|
||||
.popup-title { font-size: 17px; font-weight: 700; }
|
||||
.popup-close { color: #9ca39f; }
|
||||
.popup-body { padding: 18px 20px; }
|
||||
.popup-desc { display: block; margin-bottom: 14px; color: #77817b; font-size: 12px; line-height: 1.5; }
|
||||
.field-label { display: block; margin: 12px 0 6px; color: #626d66; font-size: 12px; font-weight: 700; }
|
||||
.picker-field { display: flex; align-items: center; justify-content: space-between; }
|
||||
.popup-footer,.share-actions { display: flex; gap: 10px; padding: 12px 20px 22px; }
|
||||
.btn-ghost { width: 104px; color: #647168 !important; background: #fff !important; border: 1px solid #dfe5e1 !important; }
|
||||
.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>
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
</view>
|
||||
<view class="store-hero-text">
|
||||
<text class="store-hero-title">门店资料</text>
|
||||
<text class="store-hero-desc">在此维护门店名称、电话、地址与简介,并设置「营业时间」。营业时间决定客户线上可预约的时段范围(每半小时为一个时段,同一时段仅一单)。</text>
|
||||
<text class="store-hero-desc">在此维护门店资料、营业容量时段与并发接待数。系统会按服务时长检查每个半小时容量桶。</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -68,7 +68,7 @@
|
||||
<view class="store-card store-card--hours store-card--after-basic">
|
||||
<view class="store-card-head">
|
||||
<text class="store-card-title">营业时间</text>
|
||||
<text class="store-card-sub">客户可预约时段为「开始」至「结束」之间的半点档;每半小时为一档,每档最多一单,已满或已过不可选。</text>
|
||||
<text class="store-card-sub">服务必须在开始至结束之间完成;线上预约和到店占用共同消耗并发名额。</text>
|
||||
</view>
|
||||
<view class="store-field">
|
||||
<view class="store-field-label">
|
||||
@ -83,12 +83,12 @@
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="store-field store-field-last">
|
||||
<view class="store-field">
|
||||
<view class="store-field-label">
|
||||
<span class="store-ico"><AppIcon name="service" :size="15" :color="SLATE_600" /></span>
|
||||
<text>结束时间</text>
|
||||
</view>
|
||||
<text class="store-field-tip">当日最后一个可预约的半点起始时刻(例如 21:30 表示最晚可约 21:00–21:30 这一档)</text>
|
||||
<text class="store-field-tip">最后一个容量桶的开始时刻(例如 21:30 表示营业容量至 22:00)</text>
|
||||
<picker mode="selector" :range="HALF_HOUR_TIME_SLOTS" :value="bookingLastIdx" @change="onBookingLastChange">
|
||||
<view class="app-form-input app-form-picker store-picker-like">
|
||||
<text>{{ form.bookingLastSlotStart || '请选择' }}</text>
|
||||
@ -97,6 +97,19 @@
|
||||
</picker>
|
||||
<view class="booking-hint">结束须不早于开始。保存后对新建预约生效。</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 class="store-actions">
|
||||
@ -147,6 +160,7 @@ const navSafeStyle = (() => {
|
||||
})()
|
||||
const saving = ref(false)
|
||||
const pickingLocation = ref(false)
|
||||
const CAPACITY_OPTIONS = Array.from({ length: 10 }, (_, i) => i + 1)
|
||||
const form = ref({
|
||||
name: '',
|
||||
phone: '',
|
||||
@ -155,7 +169,8 @@ const form = ref({
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
bookingDayStart: '09:00',
|
||||
bookingLastSlotStart: '21:30'
|
||||
bookingLastSlotStart: '21:30',
|
||||
bookingCapacity: 1
|
||||
})
|
||||
|
||||
const toHHmm = (v) => {
|
||||
@ -169,6 +184,7 @@ const toHHmm = (v) => {
|
||||
|
||||
const bookingStartIdx = computed(() => halfHourPickerIndex(form.value.bookingDayStart))
|
||||
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 i = Number(e.detail.value)
|
||||
@ -180,6 +196,10 @@ const onBookingLastChange = (e) => {
|
||||
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 toCoord = (v) => {
|
||||
@ -237,7 +257,8 @@ onMounted(async () => {
|
||||
latitude: toCoord(storeInfo.latitude),
|
||||
longitude: toCoord(storeInfo.longitude),
|
||||
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) {
|
||||
const res = await getStoreDetail(storeInfo.id)
|
||||
@ -245,6 +266,7 @@ onMounted(async () => {
|
||||
const d = res.data
|
||||
base.bookingDayStart = toHHmm(d.bookingDayStart) || base.bookingDayStart
|
||||
base.bookingLastSlotStart = toHHmm(d.bookingLastSlotStart) || base.bookingLastSlotStart
|
||||
base.bookingCapacity = Number(d.bookingCapacity || base.bookingCapacity)
|
||||
if (d.name != null) base.name = d.name
|
||||
if (d.phone != null) base.phone = d.phone
|
||||
if (d.address != null) base.address = d.address
|
||||
|
||||
@ -184,12 +184,16 @@
|
||||
|
||||
<ReportShareModal
|
||||
:show="shareModalOpen"
|
||||
:report-id="shareReportId"
|
||||
:report-token="shareToken"
|
||||
:sent="shareSent"
|
||||
:confirming="confirmingSend"
|
||||
:pet-name="sharePetName"
|
||||
:store-name="shareStoreName"
|
||||
:service-type="shareServiceType"
|
||||
@close="onShareModalClose"
|
||||
@preview="onSharePreview"
|
||||
@confirm-sent="onConfirmSent"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
@ -197,7 +201,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { onPageScroll } from '@dcloudio/uni-app'
|
||||
import { createReport, getServiceTypeList, imgUrl, BASE_URL } from '../../api/index.js'
|
||||
import { createReport, confirmReportSent, getServiceTypeList, imgUrl, BASE_URL } from '../../api/index.js'
|
||||
import AppIcon from '../../components/AppIcon.vue'
|
||||
import ReportShareModal from '../../components/report/ReportShareModal.vue'
|
||||
import { getUserSession, getStoreSession } from '../../utils/session.js'
|
||||
@ -239,7 +243,10 @@ const serviceTypes = ref([])
|
||||
const currentAppointmentId = ref(null)
|
||||
const submitting = ref(false)
|
||||
const shareModalOpen = ref(false)
|
||||
const shareReportId = ref(null)
|
||||
const shareToken = ref('')
|
||||
const shareSent = ref(false)
|
||||
const confirmingSend = ref(false)
|
||||
const sharePetName = ref('')
|
||||
const shareStoreName = ref('')
|
||||
const shareServiceType = ref('')
|
||||
@ -700,7 +707,6 @@ const submitReport = async () => {
|
||||
|
||||
const payload = {
|
||||
appointmentId: currentAppointmentId.value || null,
|
||||
userId: userInfo.id,
|
||||
petName: report.value.petName,
|
||||
serviceType: report.value.serviceType,
|
||||
appointmentTime: report.value.appointmentTime || null,
|
||||
@ -712,7 +718,9 @@ const submitReport = async () => {
|
||||
submitting.value = false
|
||||
if (res.code === 200) {
|
||||
const token = res.data.reportToken
|
||||
shareReportId.value = res.data.reportId
|
||||
shareToken.value = token
|
||||
shareSent.value = false
|
||||
sharePetName.value = report.value.petName || ''
|
||||
shareStoreName.value = storeInfo?.name || ''
|
||||
shareServiceType.value = report.value.serviceType || ''
|
||||
@ -728,7 +736,10 @@ const submitReport = async () => {
|
||||
|
||||
const onShareModalClose = () => {
|
||||
shareModalOpen.value = false
|
||||
shareReportId.value = null
|
||||
shareToken.value = ''
|
||||
shareSent.value = false
|
||||
confirmingSend.value = false
|
||||
sharePetName.value = ''
|
||||
shareStoreName.value = ''
|
||||
shareServiceType.value = ''
|
||||
@ -737,7 +748,10 @@ const onShareModalClose = () => {
|
||||
const onSharePreview = () => {
|
||||
const t = shareToken.value
|
||||
shareModalOpen.value = false
|
||||
shareReportId.value = null
|
||||
shareToken.value = ''
|
||||
shareSent.value = false
|
||||
confirmingSend.value = false
|
||||
sharePetName.value = ''
|
||||
shareStoreName.value = ''
|
||||
shareServiceType.value = ''
|
||||
@ -746,6 +760,24 @@ const onSharePreview = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirmSent = async (channel) => {
|
||||
if (!shareReportId.value || confirmingSend.value || shareSent.value) return
|
||||
confirmingSend.value = true
|
||||
try {
|
||||
const res = await confirmReportSent(shareReportId.value, channel)
|
||||
if (res.code !== 200) {
|
||||
uni.showToast({ title: res.message || '确认失败,请重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
shareSent.value = true
|
||||
uni.showToast({ title: res.data?.alreadyConfirmed ? '此前已确认发送' : '已记录发送', icon: 'success' })
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '确认失败,请检查网络', icon: 'none' })
|
||||
} finally {
|
||||
confirmingSend.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
|
||||
@ -8,6 +8,7 @@ const ROOT_ROUTE_MAP = {
|
||||
|
||||
const SUB_ROUTE_MAP = {
|
||||
staff: '/pages/mine/Staff',
|
||||
onboarding: '/pages/mine/Onboarding',
|
||||
serviceType: '/pages/mine/ServiceType',
|
||||
store: '/pages/mine/Store',
|
||||
profile: '/pages/mine/Profile',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user