feat: add merchant onboarding and staff invite flow
This commit is contained in:
parent
d9dfb8b7f1
commit
a595ea8fa1
@ -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}`)
|
||||
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
|
||||
@ -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