feat: add merchant onboarding and staff invite flow

This commit is contained in:
malei 2026-08-02 01:02:10 +08:00
parent d9dfb8b7f1
commit a595ea8fa1
7 changed files with 692 additions and 855 deletions

View File

@ -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', {})
@ -161,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}`)

View File

@ -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"},

View File

@ -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('//')
)
function safeRedirect(path) {
return typeof path === 'string' && path.startsWith('/pages/') && !path.includes('..') && !path.includes('//')
}
const redirectAfterLogin = ref('')
onLoad((options) => {
redirectAfterLogin.value = parseOption(options?.redirect)
const token = parseOption(options?.staffInvite || options?.token)
if (token) {
inviteToken.value = token
view.value = 'staff-invite'
loadInvitation()
}
})
function navigateAfterLogin() {
const r = redirectAfterLogin.value
if (isSafeLoginRedirect(r)) {
uni.reLaunch({ url: r })
const fetchWxLoginCode = () => new Promise((resolve) => {
// #ifdef MP-WEIXIN
uni.login({ success: (result) => resolve(result?.code || ''), fail: () => resolve('') })
// #endif
// #ifndef MP-WEIXIN
resolve('')
// #endif
})
function saveSession(data) {
if (!data?.user || !data?.sessionToken) return false
setUserSession(data.user)
setStoreSession(data.store || {})
setSessionToken(data.sessionToken)
return true
}
function goAfterLogin() {
if (safeRedirect(redirectAfterLogin.value)) {
uni.reLaunch({ url: redirectAfterLogin.value })
} else {
openAppPage('home')
}
}
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
}
const red = options && options.redirect != null ? options.redirect : ''
redirectAfterLogin.value = parseRedirectParam(red)
})
const backToLoginForm = () => { view.value = 'login' }
const showToast = (msg) => uni.showToast({ title: msg, icon: 'none' })
/** 微信小程序 wx.login用于服务端 jscode2session 换 openid与手机号授权 code 无关) */
const fetchWxLoginCode = () =>
new Promise((resolve) => {
// #ifdef MP-WEIXIN
uni.login({
success: (res) => resolve((res && res.code) || ''),
fail: () => resolve('')
})
// #endif
// #ifndef MP-WEIXIN
resolve('')
// #endif
})
const handleSendSms = async () => {
if (!loginForm.phone || loginForm.phone.length !== 11) return showToast('请输入正确的手机号')
const res = await sendSms(loginForm.phone)
if (res.code === 200) {
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>

View File

@ -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' })

View File

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

View File

@ -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>

View File

@ -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',