feat: guide store onboarding and staff invites
This commit is contained in:
parent
21cb134a29
commit
c0475362cb
@ -60,8 +60,50 @@ export const getStore = (id: number) => apiGet('/store/get', { id })
|
||||
|
||||
export const getStaffList = () => apiGet('/user/staff-list')
|
||||
|
||||
export const createStaff = (data: { name: string; phone: string }) =>
|
||||
apiPost('/user/create-staff', data)
|
||||
export type StaffInvitation = {
|
||||
invitationId: string
|
||||
storeName: string
|
||||
invitedName: string
|
||||
maskedPhone: string
|
||||
status: 'pending' | 'accepted' | 'revoked' | 'expired'
|
||||
expiresAt: string
|
||||
acceptedAt?: string | null
|
||||
createdAt: string
|
||||
inviteToken?: string
|
||||
invitePath?: string
|
||||
}
|
||||
|
||||
export type OnboardingStep = {
|
||||
id: 'profile' | 'booking' | 'services' | 'team'
|
||||
title: string
|
||||
required: boolean
|
||||
completed: boolean
|
||||
hint: string
|
||||
}
|
||||
|
||||
export type OnboardingStatus = {
|
||||
status: 'unknown' | 'in_progress' | 'completed'
|
||||
completedAt?: string | null
|
||||
readyToComplete: boolean
|
||||
missingRequiredSteps: string[]
|
||||
completedStepCount: number
|
||||
totalStepCount: number
|
||||
staffCount: number
|
||||
steps: OnboardingStep[]
|
||||
alreadyCompleted?: boolean
|
||||
}
|
||||
|
||||
export const getOnboardingStatus = () => apiGet<OnboardingStatus>('/admin/onboarding')
|
||||
|
||||
export const completeOnboarding = () => apiPost<OnboardingStatus>('/admin/onboarding/complete')
|
||||
|
||||
export const getStaffInvitations = () => apiGet<StaffInvitation[]>('/admin/staff-invitations')
|
||||
|
||||
export const createStaffInvitation = (data: { name: string; phone: string; validDays: number }) =>
|
||||
apiPost<StaffInvitation>('/admin/staff-invitations', data)
|
||||
|
||||
export const revokeStaffInvitation = (invitationId: string) =>
|
||||
apiDelete<StaffInvitation>('/admin/staff-invitations', { invitationId })
|
||||
|
||||
export const deleteStaff = (staffId: number) =>
|
||||
apiDelete('/user/staff', { staffId })
|
||||
|
||||
@ -13,7 +13,6 @@ export type AdminUser = {
|
||||
export type AdminStore = {
|
||||
id: number
|
||||
name?: string
|
||||
inviteCode?: string
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
|
||||
@ -94,7 +94,7 @@ async function onLogin() {
|
||||
}
|
||||
const storeRaw = res.data.store
|
||||
const store: AdminStore | null = storeRaw
|
||||
? { id: Number(storeRaw.id), name: storeRaw.name as string | undefined, inviteCode: storeRaw.inviteCode as string | undefined }
|
||||
? { id: Number(storeRaw.id), name: storeRaw.name as string | undefined }
|
||||
: null
|
||||
setSession(res.data.sessionToken, user, store)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/workbench'
|
||||
|
||||
@ -1,9 +1,61 @@
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<section class="onboarding-card" v-loading="loadingOnboarding">
|
||||
<div class="onboarding-head">
|
||||
<div>
|
||||
<div class="eyebrow">PILOT READINESS</div>
|
||||
<div class="title-row">
|
||||
<h2>门店开通清单</h2>
|
||||
<el-tag :type="onboardingTagType" effect="light" round>{{ onboardingLabel }}</el-tag>
|
||||
</div>
|
||||
<p>完成 3 个必填项即可进入真实试点;单人门店不强制邀请员工。</p>
|
||||
</div>
|
||||
<div class="progress-copy">
|
||||
<strong>{{ onboarding?.completedStepCount || 0 }}/{{ onboarding?.totalStepCount || 4 }}</strong>
|
||||
<span>步骤完成</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="onboardingProgress"
|
||||
:stroke-width="8"
|
||||
:show-text="false"
|
||||
:status="onboarding?.status === 'completed' ? 'success' : undefined"
|
||||
/>
|
||||
<div class="step-grid">
|
||||
<button
|
||||
v-for="step in onboarding?.steps || []"
|
||||
:key="step.id"
|
||||
type="button"
|
||||
class="step-card"
|
||||
:class="{ done: step.completed }"
|
||||
@click="openStep(step.id)"
|
||||
>
|
||||
<span class="step-state">{{ step.completed ? '✓' : step.required ? '必填' : '选填' }}</span>
|
||||
<strong>{{ step.title }}</strong>
|
||||
<small>{{ step.hint }}</small>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isBoss" class="complete-row">
|
||||
<span v-if="!onboarding?.readyToComplete">
|
||||
尚缺:{{ onboarding?.missingRequiredSteps.join('、') || '加载中' }}
|
||||
</span>
|
||||
<span v-else-if="onboarding?.status === 'completed'">本店已完成开通,可开展真实试点。</span>
|
||||
<span v-else>必填项已齐,可以显式完成门店开通。</span>
|
||||
<el-button
|
||||
v-if="onboarding?.status !== 'completed'"
|
||||
type="primary"
|
||||
:disabled="!onboarding?.readyToComplete"
|
||||
:loading="completingOnboarding"
|
||||
@click="finishOnboarding"
|
||||
>完成开通</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="page-card">
|
||||
<h2 class="page-title">门店设置</h2>
|
||||
<el-tabs v-model="tab">
|
||||
<el-tab-pane label="资料" name="store">
|
||||
<el-form label-width="120px" style="max-width: 560px" v-loading="loadingStore">
|
||||
<el-form label-width="120px" style="max-width: 620px" v-loading="loadingStore">
|
||||
<el-form-item label="店名">
|
||||
<el-input v-model="form.name" :disabled="!isBoss" />
|
||||
</el-form-item>
|
||||
@ -36,16 +88,10 @@
|
||||
</el-form-item>
|
||||
<el-form-item label="并发接待数">
|
||||
<el-input-number v-model="form.bookingCapacity" :min="1" :max="10" :disabled="!isBoss" />
|
||||
<span style="margin-left: 10px; color: var(--el-text-color-secondary)">同一时段最多同时服务的顾客数</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="邀请码">
|
||||
<div class="invite-row">
|
||||
<el-input v-model="form.inviteCode" readonly />
|
||||
<el-button @click="copyInvite">复制</el-button>
|
||||
</div>
|
||||
<span class="field-hint">同一时段最多同时服务的顾客数</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isBoss">
|
||||
<el-button type="primary" :loading="savingStore" @click="saveStore">保存</el-button>
|
||||
<el-button type="primary" :loading="savingStore" @click="saveStore">保存并刷新清单</el-button>
|
||||
</el-form-item>
|
||||
<el-alert v-else type="info" :closable="false" title="员工可查看;改资料仅老板" />
|
||||
</el-form>
|
||||
@ -68,62 +114,108 @@
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isBoss" label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.storeId != null"
|
||||
link
|
||||
type="primary"
|
||||
@click="editService(row)"
|
||||
>编辑</el-button>
|
||||
<el-button
|
||||
v-if="row.storeId != null"
|
||||
link
|
||||
type="danger"
|
||||
@click="removeService(row)"
|
||||
>删除</el-button>
|
||||
<el-button v-if="row.storeId != null" link type="primary" @click="editService(row)">编辑</el-button>
|
||||
<el-button v-if="row.storeId != null" link type="danger" @click="removeService(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="员工" name="staff">
|
||||
<div class="filter-row" v-if="isBoss">
|
||||
<el-input v-model="newStaffName" placeholder="姓名" style="width: 140px" />
|
||||
<el-input v-model="newStaffPhone" placeholder="手机号" style="width: 160px" maxlength="11" />
|
||||
<el-button type="primary" @click="addStaff">创建员工</el-button>
|
||||
<el-tab-pane label="员工与邀请" name="staff">
|
||||
<section v-if="isBoss" class="invite-builder">
|
||||
<div>
|
||||
<h3>创建一次性员工邀请</h3>
|
||||
<p>邀请绑定手机号,默认 7 天有效,可撤销且只能使用一次。原始邀请口令仅本次显示。</p>
|
||||
</div>
|
||||
<div class="filter-row invite-form">
|
||||
<el-input v-model="newStaffName" placeholder="员工姓名" style="width: 150px" />
|
||||
<el-input v-model="newStaffPhone" placeholder="本人微信手机号" style="width: 180px" maxlength="11" />
|
||||
<el-select v-model="inviteValidDays" style="width: 120px">
|
||||
<el-option label="3 天有效" :value="3" />
|
||||
<el-option label="7 天有效" :value="7" />
|
||||
<el-option label="14 天有效" :value="14" />
|
||||
<el-option label="30 天有效" :value="30" />
|
||||
</el-select>
|
||||
<el-button type="primary" :loading="creatingInvite" @click="addInvitation">生成邀请</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<h3 class="table-title">门店成员</h3>
|
||||
<el-table :data="staff" v-loading="loadingMeta" stripe>
|
||||
<el-table-column prop="name" label="姓名" />
|
||||
<el-table-column prop="phone" label="手机" />
|
||||
<el-table-column prop="role" label="角色" width="100" />
|
||||
<el-table-column label="角色" width="100">
|
||||
<template #default="{ row }">{{ row.role === 'boss' ? '老板' : '员工' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isBoss" label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.role === 'staff'"
|
||||
link
|
||||
type="danger"
|
||||
@click="removeStaff(row)"
|
||||
>删除</el-button>
|
||||
<el-button v-if="row.role === 'staff'" link type="danger" @click="removeStaff(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<template v-if="isBoss">
|
||||
<h3 class="table-title invitation-title">邀请记录</h3>
|
||||
<el-table :data="invitations" v-loading="loadingMeta" stripe empty-text="尚未创建员工邀请">
|
||||
<el-table-column prop="invitedName" label="受邀人" />
|
||||
<el-table-column prop="maskedPhone" label="手机号" width="150" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="invitationTagType(row.status)" effect="light" round>
|
||||
{{ invitationStatusLabel(row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效期至" min-width="180">
|
||||
<template #default="{ row }">{{ formatDateTime(row.expiresAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.status === 'pending'" link type="danger" @click="revokeInvitation(row)">撤销</el-button>
|
||||
<span v-else class="muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="inviteDialogVisible" title="邀请已创建" width="560px" :close-on-click-modal="false">
|
||||
<el-alert
|
||||
type="warning"
|
||||
:closable="false"
|
||||
title="安全提示:系统不会再次显示原始邀请口令,请现在复制并只发给受邀员工。"
|
||||
show-icon
|
||||
/>
|
||||
<el-input v-model="inviteMessage" class="invite-message" type="textarea" :rows="9" readonly />
|
||||
<template #footer>
|
||||
<el-button @click="inviteDialogVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="copyInviteMessage">复制邀请消息</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
completeOnboarding,
|
||||
createServiceType,
|
||||
createStaff,
|
||||
createStaffInvitation,
|
||||
deleteServiceType,
|
||||
deleteStaff,
|
||||
getAdminStore,
|
||||
getOnboardingStatus,
|
||||
getServiceTypeList,
|
||||
getStaffInvitations,
|
||||
getStaffList,
|
||||
revokeStaffInvitation,
|
||||
updateServiceType,
|
||||
updateStore,
|
||||
type OnboardingStatus,
|
||||
type StaffInvitation,
|
||||
} from '@/api'
|
||||
import { getStore as getCachedStore, getUser, setSession, getToken, type AdminStore } from '@/utils/session'
|
||||
|
||||
@ -133,6 +225,26 @@ const isBoss = computed(() => user?.role === 'boss')
|
||||
const loadingStore = ref(false)
|
||||
const savingStore = ref(false)
|
||||
const loadingMeta = ref(false)
|
||||
const loadingOnboarding = ref(false)
|
||||
const completingOnboarding = ref(false)
|
||||
const creatingInvite = ref(false)
|
||||
|
||||
const onboarding = ref<OnboardingStatus | null>(null)
|
||||
const onboardingProgress = computed(() => {
|
||||
const current = onboarding.value
|
||||
if (!current || !current.totalStepCount) return 0
|
||||
return Math.round((current.completedStepCount / current.totalStepCount) * 100)
|
||||
})
|
||||
const onboardingLabel = computed(() => {
|
||||
if (onboarding.value?.status === 'completed') return '已开通'
|
||||
if (onboarding.value?.status === 'unknown') return '历史门店待确认'
|
||||
return '开通中'
|
||||
})
|
||||
const onboardingTagType = computed<'success' | 'warning' | 'info'>(() => {
|
||||
if (onboarding.value?.status === 'completed') return 'success'
|
||||
if (onboarding.value?.status === 'unknown') return 'warning'
|
||||
return 'info'
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
@ -140,7 +252,6 @@ const form = reactive({
|
||||
phone: '',
|
||||
address: '',
|
||||
intro: '',
|
||||
inviteCode: '',
|
||||
bookingDayStart: '09:00',
|
||||
bookingLastSlotStart: '21:30',
|
||||
bookingCapacity: 1,
|
||||
@ -148,57 +259,77 @@ const form = reactive({
|
||||
|
||||
const serviceTypes = ref<Array<Record<string, unknown>>>([])
|
||||
const staff = ref<Array<Record<string, unknown>>>([])
|
||||
const invitations = ref<StaffInvitation[]>([])
|
||||
const newServiceName = ref('')
|
||||
const newServiceDuration = ref(60)
|
||||
const newStaffName = ref('')
|
||||
const newStaffPhone = ref('')
|
||||
const inviteValidDays = ref(7)
|
||||
const inviteDialogVisible = ref(false)
|
||||
const inviteMessage = ref('')
|
||||
|
||||
function normTime(v: unknown): string {
|
||||
if (v == null) return '09:00'
|
||||
const s = String(v)
|
||||
return s.length >= 5 ? s.slice(0, 5) : s
|
||||
function normTime(value: unknown): string {
|
||||
if (value == null) return '09:00'
|
||||
const normalized = String(value)
|
||||
return normalized.length >= 5 ? normalized.slice(0, 5) : normalized
|
||||
}
|
||||
|
||||
async function loadStore() {
|
||||
loadingStore.value = true
|
||||
try {
|
||||
const res = await getAdminStore()
|
||||
if (res.code !== 200 || !res.data) {
|
||||
ElMessage.error(res.message || '加载门店失败')
|
||||
const response = await getAdminStore()
|
||||
if (response.code !== 200 || !response.data) {
|
||||
ElMessage.error(response.message || '加载门店失败')
|
||||
return
|
||||
}
|
||||
const d = res.data
|
||||
form.id = Number(d.id)
|
||||
form.name = String(d.name || '')
|
||||
form.phone = String(d.phone || '')
|
||||
form.address = String(d.address || '')
|
||||
form.intro = String(d.intro || '')
|
||||
form.inviteCode = String(d.inviteCode || '')
|
||||
form.bookingDayStart = normTime(d.bookingDayStart)
|
||||
form.bookingLastSlotStart = normTime(d.bookingLastSlotStart)
|
||||
form.bookingCapacity = Number(d.bookingCapacity || 1)
|
||||
const data = response.data
|
||||
form.id = Number(data.id)
|
||||
form.name = String(data.name || '')
|
||||
form.phone = String(data.phone || '')
|
||||
form.address = String(data.address || '')
|
||||
form.intro = String(data.intro || '')
|
||||
form.bookingDayStart = normTime(data.bookingDayStart)
|
||||
form.bookingLastSlotStart = normTime(data.bookingLastSlotStart)
|
||||
form.bookingCapacity = Number(data.bookingCapacity || 1)
|
||||
const token = getToken()
|
||||
const u = getUser()
|
||||
if (token && u) {
|
||||
const store: AdminStore = {
|
||||
id: form.id,
|
||||
name: form.name,
|
||||
inviteCode: form.inviteCode,
|
||||
}
|
||||
setSession(token, u, store)
|
||||
const currentUser = getUser()
|
||||
if (token && currentUser) {
|
||||
const store: AdminStore = { id: form.id, name: form.name }
|
||||
setSession(token, currentUser, store)
|
||||
}
|
||||
} finally {
|
||||
loadingStore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOnboarding() {
|
||||
loadingOnboarding.value = true
|
||||
try {
|
||||
const response = await getOnboardingStatus()
|
||||
onboarding.value = response.code === 200 && response.data ? response.data : null
|
||||
} finally {
|
||||
loadingOnboarding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
loadingMeta.value = true
|
||||
try {
|
||||
const storeId = getUser()?.storeId ?? getCachedStore()?.id
|
||||
const [st, sf] = await Promise.all([getServiceTypeList(storeId), getStaffList()])
|
||||
serviceTypes.value = Array.isArray(st.data) ? (st.data as Array<Record<string, unknown>>) : []
|
||||
staff.value = Array.isArray(sf.data) ? (sf.data as Array<Record<string, unknown>>) : []
|
||||
const requests = [getServiceTypeList(storeId), getStaffList()] as const
|
||||
const [serviceResponse, staffResponse] = await Promise.all(requests)
|
||||
serviceTypes.value = Array.isArray(serviceResponse.data)
|
||||
? (serviceResponse.data as Array<Record<string, unknown>>)
|
||||
: []
|
||||
staff.value = Array.isArray(staffResponse.data)
|
||||
? (staffResponse.data as Array<Record<string, unknown>>)
|
||||
: []
|
||||
if (isBoss.value) {
|
||||
const invitationResponse = await getStaffInvitations()
|
||||
invitations.value = invitationResponse.code === 200 && Array.isArray(invitationResponse.data)
|
||||
? invitationResponse.data
|
||||
: []
|
||||
}
|
||||
} finally {
|
||||
loadingMeta.value = false
|
||||
}
|
||||
@ -208,7 +339,7 @@ async function saveStore() {
|
||||
if (!form.id) return
|
||||
savingStore.value = true
|
||||
try {
|
||||
const res = await updateStore({
|
||||
const response = await updateStore({
|
||||
id: form.id,
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
@ -218,48 +349,64 @@ async function saveStore() {
|
||||
bookingLastSlotStart: form.bookingLastSlotStart,
|
||||
bookingCapacity: form.bookingCapacity,
|
||||
})
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '保存失败')
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '保存失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已保存')
|
||||
await loadStore()
|
||||
ElMessage.success('已保存,开通清单已刷新')
|
||||
await Promise.all([loadStore(), loadOnboarding()])
|
||||
} finally {
|
||||
savingStore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvite() {
|
||||
if (!form.inviteCode) return
|
||||
await navigator.clipboard.writeText(form.inviteCode)
|
||||
ElMessage.success('邀请码已复制')
|
||||
async function finishOnboarding() {
|
||||
await ElMessageBox.confirm('确认门店资料、号源容量与服务项目已可用于真实接待?', '完成门店开通', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认完成',
|
||||
})
|
||||
completingOnboarding.value = true
|
||||
try {
|
||||
const response = await completeOnboarding()
|
||||
if (response.code !== 200 || !response.data) {
|
||||
ElMessage.error(response.message || '暂不能完成开通')
|
||||
return
|
||||
}
|
||||
onboarding.value = response.data
|
||||
ElMessage.success('门店已完成开通,可进入真实试点')
|
||||
} finally {
|
||||
completingOnboarding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openStep(stepId: string) {
|
||||
tab.value = stepId === 'services' ? 'service' : stepId === 'team' ? 'staff' : 'store'
|
||||
}
|
||||
|
||||
async function addService() {
|
||||
const name = newServiceName.value.trim()
|
||||
if (!name) return
|
||||
const duration = Number(newServiceDuration.value)
|
||||
const res = await createServiceType(name, duration)
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '新增失败')
|
||||
const response = await createServiceType(name, Number(newServiceDuration.value))
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '新增失败')
|
||||
return
|
||||
}
|
||||
newServiceName.value = ''
|
||||
newServiceDuration.value = 60
|
||||
ElMessage.success('已新增')
|
||||
await loadMeta()
|
||||
await Promise.all([loadMeta(), loadOnboarding()])
|
||||
}
|
||||
|
||||
async function editService(row: Record<string, unknown>) {
|
||||
const { value } = await ElMessageBox.prompt('新名称', '改名', { inputValue: String(row.name || '') })
|
||||
const namePrompt = await ElMessageBox.prompt('新名称', '编辑服务项目', { inputValue: String(row.name || '') })
|
||||
const durationPrompt = await ElMessageBox.prompt('请输入 30~480 分钟,且为 30 的倍数', '预计服务时长', {
|
||||
inputValue: String(row.durationMinutes || 60),
|
||||
inputPattern: /^(30|60|90|120|150|180|210|240|270|300|330|360|390|420|450|480)$/,
|
||||
inputErrorMessage: '请输入 30~480 的 30 分钟倍数',
|
||||
})
|
||||
const res = await updateServiceType(Number(row.id), value, Number(durationPrompt.value))
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '改名失败')
|
||||
const response = await updateServiceType(Number(row.id), namePrompt.value, Number(durationPrompt.value))
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '更新失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已更新')
|
||||
@ -268,51 +415,254 @@ async function editService(row: Record<string, unknown>) {
|
||||
|
||||
async function removeService(row: Record<string, unknown>) {
|
||||
await ElMessageBox.confirm(`删除服务类型「${row.name}」?`, '确认', { type: 'warning' })
|
||||
const res = await deleteServiceType(Number(row.id))
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '删除失败')
|
||||
const response = await deleteServiceType(Number(row.id))
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await loadMeta()
|
||||
await Promise.all([loadMeta(), loadOnboarding()])
|
||||
}
|
||||
|
||||
async function addStaff() {
|
||||
if (!newStaffName.value.trim() || !/^1\d{10}$/.test(newStaffPhone.value)) {
|
||||
ElMessage.warning('请填写姓名与正确手机号')
|
||||
async function addInvitation() {
|
||||
const name = newStaffName.value.trim()
|
||||
const phone = newStaffPhone.value.trim()
|
||||
if (!name || !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
ElMessage.warning('请填写员工姓名与本人微信手机号')
|
||||
return
|
||||
}
|
||||
const res = await createStaff({ name: newStaffName.value.trim(), phone: newStaffPhone.value })
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '创建失败')
|
||||
creatingInvite.value = true
|
||||
try {
|
||||
const response = await createStaffInvitation({ name, phone, validDays: inviteValidDays.value })
|
||||
if (response.code !== 200 || !response.data?.inviteToken || !response.data.invitePath) {
|
||||
ElMessage.error(response.message || '邀请创建失败')
|
||||
return
|
||||
}
|
||||
inviteMessage.value = buildInviteMessage(response.data)
|
||||
inviteDialogVisible.value = true
|
||||
newStaffName.value = ''
|
||||
newStaffPhone.value = ''
|
||||
ElMessage.success('已创建')
|
||||
await Promise.all([loadMeta(), loadOnboarding()])
|
||||
} finally {
|
||||
creatingInvite.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function buildInviteMessage(invitation: StaffInvitation): string {
|
||||
return [
|
||||
'【宠小它员工邀请】',
|
||||
`${invitation.storeName} 邀请 ${invitation.invitedName} 加入门店。`,
|
||||
`受邀手机号:${invitation.maskedPhone}`,
|
||||
`有效期至:${formatDateTime(invitation.expiresAt)}`,
|
||||
'',
|
||||
'请在微信中打开“宠小它”小程序,通过员工邀请入口加入。',
|
||||
`小程序路径:${invitation.invitePath}`,
|
||||
`邀请口令:${invitation.inviteToken}`,
|
||||
'',
|
||||
'口令仅限本人使用;微信手机号不一致时系统会拒绝加入。',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async function copyInviteMessage() {
|
||||
await navigator.clipboard.writeText(inviteMessage.value)
|
||||
ElMessage.success('邀请消息已复制')
|
||||
}
|
||||
|
||||
async function revokeInvitation(invitation: StaffInvitation) {
|
||||
await ElMessageBox.confirm(`撤销发给「${invitation.invitedName}」的邀请?`, '撤销邀请', { type: 'warning' })
|
||||
const response = await revokeStaffInvitation(invitation.invitationId)
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '撤销失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('邀请已撤销')
|
||||
await loadMeta()
|
||||
}
|
||||
|
||||
async function removeStaff(row: Record<string, unknown>) {
|
||||
await ElMessageBox.confirm(`删除员工「${row.name}」?`, '确认', { type: 'warning' })
|
||||
const res = await deleteStaff(Number(row.id))
|
||||
if (res.code !== 200) {
|
||||
ElMessage.error(res.message || '删除失败')
|
||||
await ElMessageBox.confirm(`删除员工「${row.name}」?删除后其现有会话将无法再访问门店。`, '确认', { type: 'warning' })
|
||||
const response = await deleteStaff(Number(row.id))
|
||||
if (response.code !== 200) {
|
||||
ElMessage.error(response.message || '删除失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('已删除')
|
||||
await loadMeta()
|
||||
await Promise.all([loadMeta(), loadOnboarding()])
|
||||
}
|
||||
|
||||
function invitationStatusLabel(status: StaffInvitation['status']): string {
|
||||
return { pending: '待接受', accepted: '已加入', revoked: '已撤销', expired: '已过期' }[status]
|
||||
}
|
||||
|
||||
function invitationTagType(status: StaffInvitation['status']): 'success' | 'warning' | 'info' | 'danger' {
|
||||
if (status === 'accepted') return 'success'
|
||||
if (status === 'pending') return 'warning'
|
||||
if (status === 'revoked') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) return '—'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return value.replace('T', ' ')
|
||||
return date.toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStore(), loadMeta()])
|
||||
await Promise.all([loadStore(), loadMeta(), loadOnboarding()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invite-row {
|
||||
.settings-page {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.onboarding-card {
|
||||
padding: 22px 24px;
|
||||
color: #20314f;
|
||||
background:
|
||||
radial-gradient(circle at 90% 0, rgb(47 111 237 / 12%), transparent 32%),
|
||||
#fff;
|
||||
border: 1px solid #dfe8f6;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.onboarding-head,
|
||||
.title-row,
|
||||
.complete-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #2f6fed;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title-row h2 {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.onboarding-head p,
|
||||
.invite-builder p {
|
||||
margin: 0;
|
||||
color: #68768a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.progress-copy {
|
||||
display: grid;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.progress-copy strong {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.progress-copy span {
|
||||
color: #7a8798;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.onboarding-card :deep(.el-progress) {
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.step-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.step-card {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-height: 104px;
|
||||
padding: 13px;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: #f7f9fc;
|
||||
border: 1px solid #e6ebf2;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.step-card.done {
|
||||
background: #f2fbf6;
|
||||
border-color: #cdebd8;
|
||||
}
|
||||
|
||||
.step-state {
|
||||
color: #2f6fed;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.step-card.done .step-state {
|
||||
color: #24a35a;
|
||||
}
|
||||
|
||||
.step-card small {
|
||||
color: #748196;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.complete-row {
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
color: #68768a;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
margin-left: 10px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.invite-builder {
|
||||
margin-bottom: 22px;
|
||||
padding: 18px;
|
||||
background: #f6f9ff;
|
||||
border: 1px solid #dce7fb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.invite-builder h3,
|
||||
.table-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.invite-form {
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
.invitation-title {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: #a1a9b6;
|
||||
}
|
||||
|
||||
.invite-message {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.step-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user