feat: guide store onboarding and staff invites

This commit is contained in:
malei 2026-08-02 01:02:10 +08:00
parent 21cb134a29
commit c0475362cb
4 changed files with 571 additions and 180 deletions

View File

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

View File

@ -13,7 +13,6 @@ export type AdminUser = {
export type AdminStore = {
id: number
name?: string
inviteCode?: string
}
export function getToken(): string | null {

View File

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

View File

@ -1,113 +1,199 @@
<template>
<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-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 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 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: 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>
</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>
<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>
<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>
<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 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>
</template>
</el-table-column>
</el-table>
<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>
</div>
<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 v-if="isBoss" label="操作" width="100">
<template #default="{ row }">
<el-button
v-if="row.role === 'staff'"
link
type="danger"
@click="removeStaff(row)"
>删除</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<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>
@ -115,15 +201,21 @@
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('请输入 30480 分钟,且为 30 的倍数', '预计服务时长', {
inputValue: String(row.durationMinutes || 60),
inputPattern: /^(30|60|90|120|150|180|210|240|270|300|330|360|390|420|450|480)$/,
inputErrorMessage: '请输入 30480 的 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 = ''
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
}
newStaffName.value = ''
newStaffPhone.value = ''
ElMessage.success('已创建')
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>