diff --git a/src/api/index.ts b/src/api/index.ts index 6c29675..1526e93 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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('/admin/onboarding') + +export const completeOnboarding = () => apiPost('/admin/onboarding/complete') + +export const getStaffInvitations = () => apiGet('/admin/staff-invitations') + +export const createStaffInvitation = (data: { name: string; phone: string; validDays: number }) => + apiPost('/admin/staff-invitations', data) + +export const revokeStaffInvitation = (invitationId: string) => + apiDelete('/admin/staff-invitations', { invitationId }) export const deleteStaff = (staffId: number) => apiDelete('/user/staff', { staffId }) diff --git a/src/utils/session.ts b/src/utils/session.ts index 9fd7e0d..9ea321d 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -13,7 +13,6 @@ export type AdminUser = { export type AdminStore = { id: number name?: string - inviteCode?: string } export function getToken(): string | null { diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index 2003af0..25b713a 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -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' diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index 58fe121..49fb2e6 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -1,113 +1,199 @@ @@ -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(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>>([]) const staff = ref>>([]) +const invitations = ref([]) 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>) : [] - staff.value = Array.isArray(sf.data) ? (sf.data as Array>) : [] + const requests = [getServiceTypeList(storeId), getStaffList()] as const + const [serviceResponse, staffResponse] = await Promise.all(requests) + serviceTypes.value = Array.isArray(serviceResponse.data) + ? (serviceResponse.data as Array>) + : [] + staff.value = Array.isArray(staffResponse.data) + ? (staffResponse.data as Array>) + : [] + 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) { - 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) { async function removeService(row: Record) { 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) { - 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()]) })