feat: 新增排班管理页面

This commit is contained in:
MaDaLei 2026-04-17 12:26:37 +08:00
parent 6fcb1a828b
commit 3be6d0bf9b
6 changed files with 478 additions and 1 deletions

View File

@ -123,6 +123,14 @@ export const getStoreDetail = (id) => get('/store/get', { id })
// 更新店铺
export const updateStore = (data) => put('/store/update', data)
// 门店日程(某日预约 + 手动占用)
export const getScheduleDay = (storeId, date) => get('/schedule/day', { storeId, date })
/** blockType: walk_in | blockedslotStart: ISO 本地时间 */
export const createScheduleBlock = (data) => post('/schedule/block', data)
export const deleteScheduleBlock = (id) => del(`/schedule/block?id=${id}`)
// 上传图片uniapp 版:使用 uni.uploadFile
export const uploadImage = (filePath) =>
new Promise((resolve, reject) => {

View File

@ -40,6 +40,12 @@ const ICON_MAP = {
pin: [
'M15 10.5a3 3 0 11-6 0 3 3 0 016 0z',
'M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z'
],
calendar: [
'M7.5 4H16.5C17.33 4 18 4.67 18 5.5V18.5C18 19.33 17.33 20 16.5 20H7.5C6.67 20 6 19.33 6 18.5V5.5C6 4.67 6.67 4 7.5 4Z',
'M8 2.5V5',
'M16 2.5V5',
'M6 9H18'
]
}

View File

@ -9,6 +9,7 @@
{"path": "pages/mine/Staff"},
{"path": "pages/mine/ServiceType"},
{"path": "pages/mine/Store"},
{"path": "pages/mine/Schedule"},
{"path": "pages/mine/MyReports"},
{"path": "pages/mine/MyOrders"},
{"path": "pages/mine/MyPets"},

View File

@ -34,6 +34,11 @@
<text class="menu-text">店铺设置</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goSchedulePage">
<view class="menu-icon-wrap"><AppIcon name="calendar" :size="16" color="#666660" /></view>
<text class="menu-text">门店日程</text>
<text class="menu-arrow"></text>
</view>
</view>
</view>
@ -46,6 +51,11 @@
<text class="menu-text">店铺设置</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goSchedulePage">
<view class="menu-icon-wrap"><AppIcon name="calendar" :size="16" color="#666660" /></view>
<text class="menu-text">门店日程</text>
<text class="menu-arrow"></text>
</view>
</view>
</view>
@ -91,6 +101,7 @@ const { goPage, openAppPage } = useNavigator()
const goStaffPage = () => uni.navigateTo({ url: '/pages/mine/Staff' })
const goServiceTypePage = () => uni.navigateTo({ url: '/pages/mine/ServiceType' })
const goStorePage = () => uni.navigateTo({ url: '/pages/mine/Store' })
const goSchedulePage = () => uni.navigateTo({ url: '/pages/mine/Schedule' })
const openProfile = () => uni.navigateTo({ url: '/pages/mine/Profile' })
const goMyPetsPage = () => uni.navigateTo({ url: '/pages/mine/MyPets' })
const goMyOrdersPage = () => uni.navigateTo({ url: '/pages/mine/MyOrders' })

450
src/pages/mine/Schedule.vue Normal file
View File

@ -0,0 +1,450 @@
<template>
<view class="page-shell sched-page">
<view class="sched-nav nav-gradient" :style="navSafeStyle">
<view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">门店日程</text>
<view class="nav-placeholder"></view>
</view>
<view v-if="!allowed" class="sched-deny">
<text class="sched-deny-text">仅店长与员工可查看门店日程</text>
</view>
<view v-else class="sched-body">
<view class="sched-hero">
<view class="sched-hero-icon" aria-hidden="true">
<AppIcon name="calendar" :size="22" color="#15803d" />
</view>
<view class="sched-hero-text">
<text class="sched-hero-title">时段一览</text>
<text class="sched-hero-desc">绿色为客户预约橙色为到店占用灰色为暂停线上预约点空白格可手动占用点占用可取消</text>
</view>
</view>
<view class="sched-date-bar">
<view class="sched-date-btn" @click="shiftDay(-1)"></view>
<picker mode="date" :value="dateStr" @change="onDatePick">
<view class="sched-date-mid">
<text class="sched-date-label">{{ dateStr }}</text>
<text class="sched-date-sub">{{ weekdayText }}</text>
</view>
</picker>
<view class="sched-date-btn" @click="shiftDay(1)"></view>
</view>
<view v-if="loading" class="sched-loading"><text>加载中</text></view>
<view v-else class="sched-list">
<view
v-for="(row, idx) in rows"
:key="idx"
:class="['sched-row', rowRowClass(row)]"
@click="onRowTap(row)"
>
<view class="sched-time-col">
<text class="sched-time">{{ row.time }}</text>
<text class="sched-range">{{ slotRangeLabel(row) }}</text>
</view>
<view class="sched-main">
<template v-if="row.kind === 'appointment' && row.appointment">
<text class="sched-line-title">{{ row.appointment.petName || '宠物' }} · {{ row.appointment.serviceType || '服务' }}</text>
<text class="sched-line-sub">{{ apptStatusText(row.appointment.status) }}</text>
</template>
<template v-else-if="row.kind === 'block' && row.block">
<text class="sched-line-title">{{ blockTitle(row.block) }}</text>
<text v-if="row.block.note" class="sched-line-sub">{{ row.block.note }}</text>
</template>
<template v-else>
<text :class="['sched-empty', row.past ? 'sched-empty--past' : '']">{{ row.past ? '已过去' : '空闲 · 点击占用' }}</text>
</template>
</view>
</view>
</view>
<view class="sched-foot-hint">营业时间一致每半小时一档占用后客户端该档不可再约</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import AppIcon from '../../components/AppIcon.vue'
import { getScheduleDay, createScheduleBlock, deleteScheduleBlock } from '../../api/index.js'
import { getStoreSession, getUserSession } from '../../utils/session.js'
import { formatHalfHourSlotRange } from '../../utils/halfHourTime.js'
import { getAppointmentStatusText } from '../../utils/appointment.js'
const userInfo = getUserSession()
const storeInfo = getStoreSession()
const allowed = computed(() => userInfo.role === 'boss' || userInfo.role === 'staff')
const loading = ref(false)
const dateStr = ref(todayYmd())
const rows = ref([])
const meta = ref({ dayStart: '', lastSlotStart: '' })
const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44
const menuRect = uni.getMenuButtonBoundingClientRect?.()
if (menuRect && menuRect.top && menuRect.height) {
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4)
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height
}
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
})()
const weekdayText = computed(() => {
const [y, m, d] = dateStr.value.split('-').map(Number)
const dt = new Date(y, m - 1, d)
const w = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][dt.getDay()]
return w
})
function todayYmd() {
const t = new Date()
const y = t.getFullYear()
const m = String(t.getMonth() + 1).padStart(2, '0')
const d = String(t.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
function addDaysYmd(ymd, delta) {
const [y, m, d] = ymd.split('-').map(Number)
const dt = new Date(y, m - 1, d + delta)
const yy = dt.getFullYear()
const mm = String(dt.getMonth() + 1).padStart(2, '0')
const dd = String(dt.getDate()).padStart(2, '0')
return `${yy}-${mm}-${dd}`
}
function goBack() {
const pages = getCurrentPages()
if (pages.length > 1) uni.navigateBack()
else uni.switchTab({ url: '/pages/mine/Mine' })
}
function apptStatusText(s) {
return getAppointmentStatusText(s)
}
function blockTitle(block) {
if (block.blockType === 'blocked') return '暂停线上预约'
return '到店占用'
}
function slotRangeLabel(row) {
return formatHalfHourSlotRange(row.time)
}
function rowRowClass(row) {
if (row.kind === 'appointment') return 'sched-row--appt'
if (row.kind === 'block') {
return row.block && row.block.blockType === 'blocked' ? 'sched-row--blocked' : 'sched-row--walkin'
}
return 'sched-row--empty'
}
async function loadDay() {
if (!allowed.value || !storeInfo.id) return
loading.value = true
const res = await getScheduleDay(storeInfo.id, dateStr.value)
loading.value = false
if (res.code === 200 && res.data) {
rows.value = res.data.rows || []
meta.value = {
dayStart: res.data.dayStart || '',
lastSlotStart: res.data.lastSlotStart || ''
}
} else {
rows.value = []
uni.showToast({ title: res.message || '加载失败', icon: 'none' })
}
}
function shiftDay(delta) {
dateStr.value = addDaysYmd(dateStr.value, delta)
loadDay()
}
function onDatePick(e) {
const v = e.detail.value
if (v) {
dateStr.value = v
loadDay()
}
}
function onRowTap(row) {
if (row.kind === 'appointment' && row.appointment && row.appointment.id) {
uni.navigateTo({ url: `/pages/appointment/AppointmentDetail?id=${row.appointment.id}` })
return
}
if (row.kind === 'block' && row.block && row.block.id) {
uni.showModal({
title: '取消占用',
content: `确定取消「${blockTitle(row.block)}${formatHalfHourSlotRange(row.time)} 吗?`,
success: async (r) => {
if (!r.confirm) return
const res = await deleteScheduleBlock(row.block.id)
if (res.code === 200) {
uni.showToast({ title: '已取消', icon: 'success' })
loadDay()
} else {
uni.showToast({ title: res.message || '操作失败', icon: 'none' })
}
}
})
return
}
if (row.kind === 'empty') {
if (row.past) {
uni.showToast({ title: '已过去的时段无法占用', icon: 'none' })
return
}
uni.showActionSheet({
itemList: ['到店占用(未走线上预约)', '暂停线上预约(外出等)'],
success: async ({ tapIndex }) => {
const blockType = tapIndex === 0 ? 'walk_in' : 'blocked'
const slotStart = slotStartForApi(row)
const res = await createScheduleBlock({
storeId: storeInfo.id,
slotStart,
blockType,
note: '',
createdByUserId: userInfo.id
})
if (res.code === 200) {
uni.showToast({ title: '已占用', icon: 'success' })
loadDay()
} else {
uni.showToast({ title: res.message || '失败', icon: 'none' })
}
}
})
}
}
/** 与后端 LocalDateTime 对齐,无 Z 后缀 */
function slotStartForApi(row) {
const s = row.slotStart
if (s && typeof s === 'string') {
return s.length >= 19 ? s.slice(0, 19) : s
}
return `${dateStr.value}T${row.time}:00`
}
onMounted(() => {
if (!allowed.value) return
if (!storeInfo.id) {
uni.showToast({ title: '缺少门店信息,请重新登录', icon: 'none' })
return
}
loadDay()
})
</script>
<style scoped>
.sched-page {
min-height: 100vh;
background: var(--pet-bg, #f5f7fb);
padding-bottom: 48px;
}
.nav-placeholder {
width: 32px;
}
.sched-nav {
padding: 14px 16px 12px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.nav-back {
font-size: 20px;
color: #fff;
}
.nav-title {
font-size: 18px;
font-weight: 700;
color: #fff;
}
.sched-deny {
padding: 48px 24px;
}
.sched-deny-text {
font-size: 15px;
color: #666660;
text-align: center;
display: block;
}
.sched-body {
padding: 12px 14px 28px;
}
.sched-hero {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 16px 14px;
border-radius: 16px;
background: linear-gradient(135deg, #ffffff 0%, #ecfdf3 55%, #f0fdf4 100%);
border: 1px solid #d8f0e2;
margin-bottom: 14px;
}
.sched-hero-icon {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid #bbf7d0;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.sched-hero-title {
display: block;
font-size: 17px;
font-weight: 700;
color: #14532d;
}
.sched-hero-desc {
display: block;
margin-top: 6px;
font-size: 13px;
line-height: 1.55;
color: #4b5563;
}
.sched-date-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 14px;
border: 1px solid #e6ecf4;
padding: 8px 4px;
margin-bottom: 12px;
}
.sched-date-btn {
width: 44px;
text-align: center;
font-size: 22px;
font-weight: 700;
color: #2db96d;
line-height: 40px;
}
.sched-date-mid {
flex: 1;
text-align: center;
padding: 6px 0;
}
.sched-date-label {
display: block;
font-size: 17px;
font-weight: 800;
color: #1a1a1a;
}
.sched-date-sub {
display: block;
font-size: 12px;
color: #94a3b8;
margin-top: 2px;
}
.sched-loading {
padding: 32px;
text-align: center;
color: #94a3b8;
font-size: 14px;
}
.sched-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.sched-row {
display: flex;
gap: 12px;
background: #fff;
border-radius: 14px;
border: 1px solid #e6ecf4;
padding: 12px 14px;
align-items: flex-start;
}
.sched-row--appt {
border-color: #bbf7d0;
background: #f7fdf9;
}
.sched-row--walkin {
border-color: #fed7aa;
background: #fffbeb;
}
.sched-row--blocked {
border-color: #e2e8f0;
background: #f8fafc;
}
.sched-row--empty {
opacity: 0.98;
}
.sched-time-col {
width: 72px;
flex-shrink: 0;
}
.sched-time {
display: block;
font-size: 16px;
font-weight: 800;
color: #1a1a1a;
}
.sched-range {
display: block;
font-size: 11px;
color: #94a3b8;
margin-top: 2px;
}
.sched-main {
flex: 1;
min-width: 0;
}
.sched-line-title {
display: block;
font-size: 15px;
font-weight: 700;
color: #1a1a1a;
line-height: 1.35;
}
.sched-line-sub {
display: block;
font-size: 13px;
color: #64748b;
margin-top: 4px;
}
.sched-empty {
font-size: 14px;
color: #2db96d;
font-weight: 600;
}
.sched-empty--past {
color: #cbd5e1;
font-weight: 500;
}
.sched-foot-hint {
margin-top: 16px;
font-size: 12px;
line-height: 1.5;
color: #94a3b8;
text-align: center;
padding: 0 8px;
}
</style>

View File

@ -13,7 +13,8 @@ const SUB_ROUTE_MAP = {
profile: '/pages/mine/Profile',
myReports: '/pages/mine/MyReports',
myOrders: '/pages/mine/MyOrders',
myPets: '/pages/mine/MyPets'
myPets: '/pages/mine/MyPets',
schedule: '/pages/mine/Schedule'
}
/**