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 getStaffList = () => apiGet('/user/staff-list')
|
||||||
|
|
||||||
export const createStaff = (data: { name: string; phone: string }) =>
|
export type StaffInvitation = {
|
||||||
apiPost('/user/create-staff', data)
|
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) =>
|
export const deleteStaff = (staffId: number) =>
|
||||||
apiDelete('/user/staff', { staffId })
|
apiDelete('/user/staff', { staffId })
|
||||||
|
|||||||
@ -13,7 +13,6 @@ export type AdminUser = {
|
|||||||
export type AdminStore = {
|
export type AdminStore = {
|
||||||
id: number
|
id: number
|
||||||
name?: string
|
name?: string
|
||||||
inviteCode?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
|
|||||||
@ -94,7 +94,7 @@ async function onLogin() {
|
|||||||
}
|
}
|
||||||
const storeRaw = res.data.store
|
const storeRaw = res.data.store
|
||||||
const store: AdminStore | null = storeRaw
|
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
|
: null
|
||||||
setSession(res.data.sessionToken, user, store)
|
setSession(res.data.sessionToken, user, store)
|
||||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/workbench'
|
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/workbench'
|
||||||
|
|||||||
@ -1,113 +1,199 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-card">
|
<div class="settings-page">
|
||||||
<h2 class="page-title">门店设置</h2>
|
<section class="onboarding-card" v-loading="loadingOnboarding">
|
||||||
<el-tabs v-model="tab">
|
<div class="onboarding-head">
|
||||||
<el-tab-pane label="资料" name="store">
|
<div>
|
||||||
<el-form label-width="120px" style="max-width: 560px" v-loading="loadingStore">
|
<div class="eyebrow">PILOT READINESS</div>
|
||||||
<el-form-item label="店名">
|
<div class="title-row">
|
||||||
<el-input v-model="form.name" :disabled="!isBoss" />
|
<h2>门店开通清单</h2>
|
||||||
</el-form-item>
|
<el-tag :type="onboardingTagType" effect="light" round>{{ onboardingLabel }}</el-tag>
|
||||||
<el-form-item label="电话">
|
</div>
|
||||||
<el-input v-model="form.phone" :disabled="!isBoss" />
|
<p>完成 3 个必填项即可进入真实试点;单人门店不强制邀请员工。</p>
|
||||||
</el-form-item>
|
</div>
|
||||||
<el-form-item label="地址">
|
<div class="progress-copy">
|
||||||
<el-input v-model="form.address" :disabled="!isBoss" />
|
<strong>{{ onboarding?.completedStepCount || 0 }}/{{ onboarding?.totalStepCount || 4 }}</strong>
|
||||||
</el-form-item>
|
<span>步骤完成</span>
|
||||||
<el-form-item label="简介">
|
</div>
|
||||||
<el-input v-model="form.intro" type="textarea" :rows="3" :disabled="!isBoss" />
|
</div>
|
||||||
</el-form-item>
|
<el-progress
|
||||||
<el-form-item label="营业开始">
|
:percentage="onboardingProgress"
|
||||||
<el-time-select
|
:stroke-width="8"
|
||||||
v-model="form.bookingDayStart"
|
:show-text="false"
|
||||||
start="06:00"
|
:status="onboarding?.status === 'completed' ? 'success' : undefined"
|
||||||
step="00:30"
|
/>
|
||||||
end="22:00"
|
<div class="step-grid">
|
||||||
:disabled="!isBoss"
|
<button
|
||||||
/>
|
v-for="step in onboarding?.steps || []"
|
||||||
</el-form-item>
|
:key="step.id"
|
||||||
<el-form-item label="末档开始">
|
type="button"
|
||||||
<el-time-select
|
class="step-card"
|
||||||
v-model="form.bookingLastSlotStart"
|
:class="{ done: step.completed }"
|
||||||
start="06:00"
|
@click="openStep(step.id)"
|
||||||
step="00:30"
|
>
|
||||||
end="23:30"
|
<span class="step-state">{{ step.completed ? '✓' : step.required ? '必填' : '选填' }}</span>
|
||||||
:disabled="!isBoss"
|
<strong>{{ step.title }}</strong>
|
||||||
/>
|
<small>{{ step.hint }}</small>
|
||||||
</el-form-item>
|
</button>
|
||||||
<el-form-item label="并发接待数">
|
</div>
|
||||||
<el-input-number v-model="form.bookingCapacity" :min="1" :max="10" :disabled="!isBoss" />
|
<div v-if="isBoss" class="complete-row">
|
||||||
<span style="margin-left: 10px; color: var(--el-text-color-secondary)">同一时段最多同时服务的顾客数</span>
|
<span v-if="!onboarding?.readyToComplete">
|
||||||
</el-form-item>
|
尚缺:{{ onboarding?.missingRequiredSteps.join('、') || '加载中' }}
|
||||||
<el-form-item label="邀请码">
|
</span>
|
||||||
<div class="invite-row">
|
<span v-else-if="onboarding?.status === 'completed'">本店已完成开通,可开展真实试点。</span>
|
||||||
<el-input v-model="form.inviteCode" readonly />
|
<span v-else>必填项已齐,可以显式完成门店开通。</span>
|
||||||
<el-button @click="copyInvite">复制</el-button>
|
<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: 620px" v-loading="loadingStore">
|
||||||
|
<el-form-item label="店名">
|
||||||
|
<el-input v-model="form.name" :disabled="!isBoss" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="电话">
|
||||||
|
<el-input v-model="form.phone" :disabled="!isBoss" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="地址">
|
||||||
|
<el-input v-model="form.address" :disabled="!isBoss" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="简介">
|
||||||
|
<el-input v-model="form.intro" type="textarea" :rows="3" :disabled="!isBoss" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="营业开始">
|
||||||
|
<el-time-select
|
||||||
|
v-model="form.bookingDayStart"
|
||||||
|
start="06:00"
|
||||||
|
step="00:30"
|
||||||
|
end="22:00"
|
||||||
|
:disabled="!isBoss"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="末档开始">
|
||||||
|
<el-time-select
|
||||||
|
v-model="form.bookingLastSlotStart"
|
||||||
|
start="06:00"
|
||||||
|
step="00:30"
|
||||||
|
end="23:30"
|
||||||
|
:disabled="!isBoss"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="并发接待数">
|
||||||
|
<el-input-number v-model="form.bookingCapacity" :min="1" :max="10" :disabled="!isBoss" />
|
||||||
|
<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-form-item>
|
||||||
|
<el-alert v-else type="info" :closable="false" title="员工可查看;改资料仅老板" />
|
||||||
|
</el-form>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="服务类型" name="service">
|
||||||
|
<div class="filter-row" v-if="isBoss">
|
||||||
|
<el-input v-model="newServiceName" placeholder="新服务类型名称" style="width: 220px" />
|
||||||
|
<el-input-number v-model="newServiceDuration" :min="30" :max="480" :step="30" />
|
||||||
|
<span>分钟</span>
|
||||||
|
<el-button type="primary" @click="addService">新增</el-button>
|
||||||
|
</div>
|
||||||
|
<el-table :data="serviceTypes" v-loading="loadingMeta" stripe>
|
||||||
|
<el-table-column prop="name" label="名称" />
|
||||||
|
<el-table-column label="范围" width="120">
|
||||||
|
<template #default="{ row }">{{ row.storeId == null ? '系统默认' : '本店' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="预计时长" width="140">
|
||||||
|
<template #default="{ row }">{{ row.durationMinutes }} 分钟</template>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="员工与邀请" name="staff">
|
||||||
|
<section v-if="isBoss" class="invite-builder">
|
||||||
|
<div>
|
||||||
|
<h3>创建一次性员工邀请</h3>
|
||||||
|
<p>邀请绑定手机号,默认 7 天有效,可撤销且只能使用一次。原始邀请口令仅本次显示。</p>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
<div class="filter-row invite-form">
|
||||||
<el-form-item v-if="isBoss">
|
<el-input v-model="newStaffName" placeholder="员工姓名" style="width: 150px" />
|
||||||
<el-button type="primary" :loading="savingStore" @click="saveStore">保存</el-button>
|
<el-input v-model="newStaffPhone" placeholder="本人微信手机号" style="width: 180px" maxlength="11" />
|
||||||
</el-form-item>
|
<el-select v-model="inviteValidDays" style="width: 120px">
|
||||||
<el-alert v-else type="info" :closable="false" title="员工可查看;改资料仅老板" />
|
<el-option label="3 天有效" :value="3" />
|
||||||
</el-form>
|
<el-option label="7 天有效" :value="7" />
|
||||||
</el-tab-pane>
|
<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>
|
||||||
|
|
||||||
<el-tab-pane label="服务类型" name="service">
|
<h3 class="table-title">门店成员</h3>
|
||||||
<div class="filter-row" v-if="isBoss">
|
<el-table :data="staff" v-loading="loadingMeta" stripe>
|
||||||
<el-input v-model="newServiceName" placeholder="新服务类型名称" style="width: 220px" />
|
<el-table-column prop="name" label="姓名" />
|
||||||
<el-input-number v-model="newServiceDuration" :min="30" :max="480" :step="30" />
|
<el-table-column prop="phone" label="手机" />
|
||||||
<span>分钟</span>
|
<el-table-column label="角色" width="100">
|
||||||
<el-button type="primary" @click="addService">新增</el-button>
|
<template #default="{ row }">{{ row.role === 'boss' ? '老板' : '员工' }}</template>
|
||||||
</div>
|
</el-table-column>
|
||||||
<el-table :data="serviceTypes" v-loading="loadingMeta" stripe>
|
<el-table-column v-if="isBoss" label="操作" width="100">
|
||||||
<el-table-column prop="name" label="名称" />
|
<template #default="{ row }">
|
||||||
<el-table-column label="范围" width="120">
|
<el-button v-if="row.role === 'staff'" link type="danger" @click="removeStaff(row)">删除</el-button>
|
||||||
<template #default="{ row }">{{ row.storeId == null ? '系统默认' : '本店' }}</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="预计时长" width="140">
|
</el-table>
|
||||||
<template #default="{ row }">{{ row.durationMinutes }} 分钟</template>
|
|
||||||
</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>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-tab-pane>
|
|
||||||
|
|
||||||
<el-tab-pane label="员工" name="staff">
|
<template v-if="isBoss">
|
||||||
<div class="filter-row" v-if="isBoss">
|
<h3 class="table-title invitation-title">邀请记录</h3>
|
||||||
<el-input v-model="newStaffName" placeholder="姓名" style="width: 140px" />
|
<el-table :data="invitations" v-loading="loadingMeta" stripe empty-text="尚未创建员工邀请">
|
||||||
<el-input v-model="newStaffPhone" placeholder="手机号" style="width: 160px" maxlength="11" />
|
<el-table-column prop="invitedName" label="受邀人" />
|
||||||
<el-button type="primary" @click="addStaff">创建员工</el-button>
|
<el-table-column prop="maskedPhone" label="手机号" width="150" />
|
||||||
</div>
|
<el-table-column label="状态" width="110">
|
||||||
<el-table :data="staff" v-loading="loadingMeta" stripe>
|
<template #default="{ row }">
|
||||||
<el-table-column prop="name" label="姓名" />
|
<el-tag :type="invitationTagType(row.status)" effect="light" round>
|
||||||
<el-table-column prop="phone" label="手机" />
|
{{ invitationStatusLabel(row.status) }}
|
||||||
<el-table-column prop="role" label="角色" width="100" />
|
</el-tag>
|
||||||
<el-table-column v-if="isBoss" label="操作" width="100">
|
</template>
|
||||||
<template #default="{ row }">
|
</el-table-column>
|
||||||
<el-button
|
<el-table-column label="有效期至" min-width="180">
|
||||||
v-if="row.role === 'staff'"
|
<template #default="{ row }">{{ formatDateTime(row.expiresAt) }}</template>
|
||||||
link
|
</el-table-column>
|
||||||
type="danger"
|
<el-table-column label="操作" width="110">
|
||||||
@click="removeStaff(row)"
|
<template #default="{ row }">
|
||||||
>删除</el-button>
|
<el-button v-if="row.status === 'pending'" link type="danger" @click="revokeInvitation(row)">撤销</el-button>
|
||||||
</template>
|
<span v-else class="muted">—</span>
|
||||||
</el-table-column>
|
</template>
|
||||||
</el-table>
|
</el-table-column>
|
||||||
</el-tab-pane>
|
</el-table>
|
||||||
</el-tabs>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -115,15 +201,21 @@
|
|||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
|
completeOnboarding,
|
||||||
createServiceType,
|
createServiceType,
|
||||||
createStaff,
|
createStaffInvitation,
|
||||||
deleteServiceType,
|
deleteServiceType,
|
||||||
deleteStaff,
|
deleteStaff,
|
||||||
getAdminStore,
|
getAdminStore,
|
||||||
|
getOnboardingStatus,
|
||||||
getServiceTypeList,
|
getServiceTypeList,
|
||||||
|
getStaffInvitations,
|
||||||
getStaffList,
|
getStaffList,
|
||||||
|
revokeStaffInvitation,
|
||||||
updateServiceType,
|
updateServiceType,
|
||||||
updateStore,
|
updateStore,
|
||||||
|
type OnboardingStatus,
|
||||||
|
type StaffInvitation,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import { getStore as getCachedStore, getUser, setSession, getToken, type AdminStore } from '@/utils/session'
|
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 loadingStore = ref(false)
|
||||||
const savingStore = ref(false)
|
const savingStore = ref(false)
|
||||||
const loadingMeta = 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({
|
const form = reactive({
|
||||||
id: 0,
|
id: 0,
|
||||||
@ -140,7 +252,6 @@ const form = reactive({
|
|||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
address: '',
|
||||||
intro: '',
|
intro: '',
|
||||||
inviteCode: '',
|
|
||||||
bookingDayStart: '09:00',
|
bookingDayStart: '09:00',
|
||||||
bookingLastSlotStart: '21:30',
|
bookingLastSlotStart: '21:30',
|
||||||
bookingCapacity: 1,
|
bookingCapacity: 1,
|
||||||
@ -148,57 +259,77 @@ const form = reactive({
|
|||||||
|
|
||||||
const serviceTypes = ref<Array<Record<string, unknown>>>([])
|
const serviceTypes = ref<Array<Record<string, unknown>>>([])
|
||||||
const staff = ref<Array<Record<string, unknown>>>([])
|
const staff = ref<Array<Record<string, unknown>>>([])
|
||||||
|
const invitations = ref<StaffInvitation[]>([])
|
||||||
const newServiceName = ref('')
|
const newServiceName = ref('')
|
||||||
const newServiceDuration = ref(60)
|
const newServiceDuration = ref(60)
|
||||||
const newStaffName = ref('')
|
const newStaffName = ref('')
|
||||||
const newStaffPhone = ref('')
|
const newStaffPhone = ref('')
|
||||||
|
const inviteValidDays = ref(7)
|
||||||
|
const inviteDialogVisible = ref(false)
|
||||||
|
const inviteMessage = ref('')
|
||||||
|
|
||||||
function normTime(v: unknown): string {
|
function normTime(value: unknown): string {
|
||||||
if (v == null) return '09:00'
|
if (value == null) return '09:00'
|
||||||
const s = String(v)
|
const normalized = String(value)
|
||||||
return s.length >= 5 ? s.slice(0, 5) : s
|
return normalized.length >= 5 ? normalized.slice(0, 5) : normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStore() {
|
async function loadStore() {
|
||||||
loadingStore.value = true
|
loadingStore.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getAdminStore()
|
const response = await getAdminStore()
|
||||||
if (res.code !== 200 || !res.data) {
|
if (response.code !== 200 || !response.data) {
|
||||||
ElMessage.error(res.message || '加载门店失败')
|
ElMessage.error(response.message || '加载门店失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const d = res.data
|
const data = response.data
|
||||||
form.id = Number(d.id)
|
form.id = Number(data.id)
|
||||||
form.name = String(d.name || '')
|
form.name = String(data.name || '')
|
||||||
form.phone = String(d.phone || '')
|
form.phone = String(data.phone || '')
|
||||||
form.address = String(d.address || '')
|
form.address = String(data.address || '')
|
||||||
form.intro = String(d.intro || '')
|
form.intro = String(data.intro || '')
|
||||||
form.inviteCode = String(d.inviteCode || '')
|
form.bookingDayStart = normTime(data.bookingDayStart)
|
||||||
form.bookingDayStart = normTime(d.bookingDayStart)
|
form.bookingLastSlotStart = normTime(data.bookingLastSlotStart)
|
||||||
form.bookingLastSlotStart = normTime(d.bookingLastSlotStart)
|
form.bookingCapacity = Number(data.bookingCapacity || 1)
|
||||||
form.bookingCapacity = Number(d.bookingCapacity || 1)
|
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
const u = getUser()
|
const currentUser = getUser()
|
||||||
if (token && u) {
|
if (token && currentUser) {
|
||||||
const store: AdminStore = {
|
const store: AdminStore = { id: form.id, name: form.name }
|
||||||
id: form.id,
|
setSession(token, currentUser, store)
|
||||||
name: form.name,
|
|
||||||
inviteCode: form.inviteCode,
|
|
||||||
}
|
|
||||||
setSession(token, u, store)
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loadingStore.value = false
|
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() {
|
async function loadMeta() {
|
||||||
loadingMeta.value = true
|
loadingMeta.value = true
|
||||||
try {
|
try {
|
||||||
const storeId = getUser()?.storeId ?? getCachedStore()?.id
|
const storeId = getUser()?.storeId ?? getCachedStore()?.id
|
||||||
const [st, sf] = await Promise.all([getServiceTypeList(storeId), getStaffList()])
|
const requests = [getServiceTypeList(storeId), getStaffList()] as const
|
||||||
serviceTypes.value = Array.isArray(st.data) ? (st.data as Array<Record<string, unknown>>) : []
|
const [serviceResponse, staffResponse] = await Promise.all(requests)
|
||||||
staff.value = Array.isArray(sf.data) ? (sf.data as Array<Record<string, unknown>>) : []
|
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 {
|
} finally {
|
||||||
loadingMeta.value = false
|
loadingMeta.value = false
|
||||||
}
|
}
|
||||||
@ -208,7 +339,7 @@ async function saveStore() {
|
|||||||
if (!form.id) return
|
if (!form.id) return
|
||||||
savingStore.value = true
|
savingStore.value = true
|
||||||
try {
|
try {
|
||||||
const res = await updateStore({
|
const response = await updateStore({
|
||||||
id: form.id,
|
id: form.id,
|
||||||
name: form.name,
|
name: form.name,
|
||||||
phone: form.phone,
|
phone: form.phone,
|
||||||
@ -218,48 +349,64 @@ async function saveStore() {
|
|||||||
bookingLastSlotStart: form.bookingLastSlotStart,
|
bookingLastSlotStart: form.bookingLastSlotStart,
|
||||||
bookingCapacity: form.bookingCapacity,
|
bookingCapacity: form.bookingCapacity,
|
||||||
})
|
})
|
||||||
if (res.code !== 200) {
|
if (response.code !== 200) {
|
||||||
ElMessage.error(res.message || '保存失败')
|
ElMessage.error(response.message || '保存失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessage.success('已保存')
|
ElMessage.success('已保存,开通清单已刷新')
|
||||||
await loadStore()
|
await Promise.all([loadStore(), loadOnboarding()])
|
||||||
} finally {
|
} finally {
|
||||||
savingStore.value = false
|
savingStore.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyInvite() {
|
async function finishOnboarding() {
|
||||||
if (!form.inviteCode) return
|
await ElMessageBox.confirm('确认门店资料、号源容量与服务项目已可用于真实接待?', '完成门店开通', {
|
||||||
await navigator.clipboard.writeText(form.inviteCode)
|
type: 'warning',
|
||||||
ElMessage.success('邀请码已复制')
|
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() {
|
async function addService() {
|
||||||
const name = newServiceName.value.trim()
|
const name = newServiceName.value.trim()
|
||||||
if (!name) return
|
if (!name) return
|
||||||
const duration = Number(newServiceDuration.value)
|
const response = await createServiceType(name, Number(newServiceDuration.value))
|
||||||
const res = await createServiceType(name, duration)
|
if (response.code !== 200) {
|
||||||
if (res.code !== 200) {
|
ElMessage.error(response.message || '新增失败')
|
||||||
ElMessage.error(res.message || '新增失败')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
newServiceName.value = ''
|
newServiceName.value = ''
|
||||||
newServiceDuration.value = 60
|
newServiceDuration.value = 60
|
||||||
ElMessage.success('已新增')
|
ElMessage.success('已新增')
|
||||||
await loadMeta()
|
await Promise.all([loadMeta(), loadOnboarding()])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function editService(row: Record<string, unknown>) {
|
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 的倍数', '预计服务时长', {
|
const durationPrompt = await ElMessageBox.prompt('请输入 30~480 分钟,且为 30 的倍数', '预计服务时长', {
|
||||||
inputValue: String(row.durationMinutes || 60),
|
inputValue: String(row.durationMinutes || 60),
|
||||||
inputPattern: /^(30|60|90|120|150|180|210|240|270|300|330|360|390|420|450|480)$/,
|
inputPattern: /^(30|60|90|120|150|180|210|240|270|300|330|360|390|420|450|480)$/,
|
||||||
inputErrorMessage: '请输入 30~480 的 30 分钟倍数',
|
inputErrorMessage: '请输入 30~480 的 30 分钟倍数',
|
||||||
})
|
})
|
||||||
const res = await updateServiceType(Number(row.id), value, Number(durationPrompt.value))
|
const response = await updateServiceType(Number(row.id), namePrompt.value, Number(durationPrompt.value))
|
||||||
if (res.code !== 200) {
|
if (response.code !== 200) {
|
||||||
ElMessage.error(res.message || '改名失败')
|
ElMessage.error(response.message || '更新失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessage.success('已更新')
|
ElMessage.success('已更新')
|
||||||
@ -268,51 +415,254 @@ async function editService(row: Record<string, unknown>) {
|
|||||||
|
|
||||||
async function removeService(row: Record<string, unknown>) {
|
async function removeService(row: Record<string, unknown>) {
|
||||||
await ElMessageBox.confirm(`删除服务类型「${row.name}」?`, '确认', { type: 'warning' })
|
await ElMessageBox.confirm(`删除服务类型「${row.name}」?`, '确认', { type: 'warning' })
|
||||||
const res = await deleteServiceType(Number(row.id))
|
const response = await deleteServiceType(Number(row.id))
|
||||||
if (res.code !== 200) {
|
if (response.code !== 200) {
|
||||||
ElMessage.error(res.message || '删除失败')
|
ElMessage.error(response.message || '删除失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessage.success('已删除')
|
ElMessage.success('已删除')
|
||||||
await loadMeta()
|
await Promise.all([loadMeta(), loadOnboarding()])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addStaff() {
|
async function addInvitation() {
|
||||||
if (!newStaffName.value.trim() || !/^1\d{10}$/.test(newStaffPhone.value)) {
|
const name = newStaffName.value.trim()
|
||||||
ElMessage.warning('请填写姓名与正确手机号')
|
const phone = newStaffPhone.value.trim()
|
||||||
|
if (!name || !/^1[3-9]\d{9}$/.test(phone)) {
|
||||||
|
ElMessage.warning('请填写员工姓名与本人微信手机号')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const res = await createStaff({ name: newStaffName.value.trim(), phone: newStaffPhone.value })
|
creatingInvite.value = true
|
||||||
if (res.code !== 200) {
|
try {
|
||||||
ElMessage.error(res.message || '创建失败')
|
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 = ''
|
||||||
|
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
|
return
|
||||||
}
|
}
|
||||||
newStaffName.value = ''
|
ElMessage.success('邀请已撤销')
|
||||||
newStaffPhone.value = ''
|
|
||||||
ElMessage.success('已创建')
|
|
||||||
await loadMeta()
|
await loadMeta()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeStaff(row: Record<string, unknown>) {
|
async function removeStaff(row: Record<string, unknown>) {
|
||||||
await ElMessageBox.confirm(`删除员工「${row.name}」?`, '确认', { type: 'warning' })
|
await ElMessageBox.confirm(`删除员工「${row.name}」?删除后其现有会话将无法再访问门店。`, '确认', { type: 'warning' })
|
||||||
const res = await deleteStaff(Number(row.id))
|
const response = await deleteStaff(Number(row.id))
|
||||||
if (res.code !== 200) {
|
if (response.code !== 200) {
|
||||||
ElMessage.error(res.message || '删除失败')
|
ElMessage.error(response.message || '删除失败')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ElMessage.success('已删除')
|
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 () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadStore(), loadMeta()])
|
await Promise.all([loadStore(), loadMeta(), loadOnboarding()])
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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;
|
display: flex;
|
||||||
gap: 8px;
|
align-items: center;
|
||||||
width: 100%;
|
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>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user