feat: 新增排班管理页面
This commit is contained in:
parent
6fcb1a828b
commit
3be6d0bf9b
@ -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 | blocked;slotStart: 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) => {
|
||||
|
||||
@ -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'
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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
450
src/pages/mine/Schedule.vue
Normal 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>
|
||||
@ -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'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user