diff --git a/store-pc/src/components/BookingCalendarDialog.vue b/store-pc/src/components/BookingCalendarDialog.vue index 91d9cde..8e3b324 100644 --- a/store-pc/src/components/BookingCalendarDialog.vue +++ b/store-pc/src/components/BookingCalendarDialog.vue @@ -16,10 +16,11 @@ + + + @@ -56,36 +74,32 @@ import FullCalendar from '@fullcalendar/vue' import dayGridPlugin from '@fullcalendar/daygrid' import timeGridPlugin from '@fullcalendar/timegrid' import interactionPlugin from '@fullcalendar/interaction' +import BookingConsumeDialog from '@/components/booking-consume-dialog.vue' +import BookingConsumeDetailDialog from '@/components/booking-consume-detail-dialog.vue' -const MOCK_BOOKING = [ - { id: '1', storeName: '保利', yyrName: '贾琳', gkxm: '范佳佳', yysj: '2026-02-11T11:00:00', yyjs: '2026-02-11T11:30:00', F_Status: '已预约', yyjksName: '贾琳' }, - { id: '2', storeName: '保利', yyrName: '贾琳', gkxm: '黄仕碧', yysj: '2026-02-11T15:00:00', yyjs: '2026-02-11T15:30:00', F_Status: '已预约', yyjksName: '贾琳' }, - { id: '3', storeName: '保利', yyrName: '贾琳', gkxm: '赵丽', yysj: '2026-02-11T17:00:00', yyjs: '2026-02-11T17:30:00', F_Status: '已确认', yyjksName: '贾琳' }, - { id: '4', storeName: '468', yyrName: '刘恬恬', gkxm: '王英', yysj: '2026-02-11T14:00:00', yyjs: '2026-02-11T14:30:00', F_Status: '已预约', yyjksName: '刘恬恬' }, - { id: '5', storeName: '保利', yyrName: '贾琳', gkxm: '罗建琼', yysj: '2026-02-11T10:30:00', yyjs: '2026-02-11T11:00:00', F_Status: '已确认', yyjksName: '贾琳' }, - { id: '6', storeName: '保利', yyrName: '贾琳', gkxm: '沈丽', yysj: '2026-02-10T17:00:00', yyjs: '2026-02-10T17:30:00', F_Status: '已预约', yyjksName: '贾琳' }, - { id: '7', storeName: '静居寺', yyrName: '董顺秀', gkxm: '陈晴', yysj: '2026-02-11T09:30:00', yyjs: '2026-02-11T10:00:00', F_Status: '已取消', yyjksName: '董顺秀' }, - { id: '8', storeName: '静居寺', yyrName: '董顺秀', gkxm: '胡蝶', yysj: '2026-02-10T17:00:00', yyjs: '2026-02-10T17:30:00', F_Status: '已预约', yyjksName: '董顺秀' }, - { id: '9', storeName: '静居寺', yyrName: '董顺秀', gkxm: '魏海燕', yysj: '2026-02-10T14:00:00', yyjs: '2026-02-10T14:30:00', F_Status: '已确认', yyjksName: '董顺秀' }, - { id: '10', storeName: '静居寺', yyrName: '董顺秀', gkxm: '肖丛娇', yysj: '2026-02-10T11:00:00', yyjs: '2026-02-10T11:30:00', F_Status: '已预约', yyjksName: '董顺秀' } -] +const STORAGE_KEY = 'store_pc_pre_consume_bookings' export default { name: 'BookingCalendarDialog', - components: { FullCalendar }, + components: { FullCalendar, BookingConsumeDialog, BookingConsumeDetailDialog }, props: { visible: { type: Boolean, default: false } }, data() { return { loading: false, - mockData: MOCK_BOOKING, - query: { F_Status: undefined }, + query: { status: undefined }, calendarPlugins: [dayGridPlugin, timeGridPlugin, interactionPlugin], calendarEvents: [], calendarHeader: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }, buttonText: { today: '今日', month: '月', week: '周', day: '日' }, startTime: null, endTime: null, - calendarHeight: 600 + calendarHeight: 600, + consumeVisible: false, + consumePrefill: {}, + detailVisible: false, + selectedBooking: null, + bookingList: [], + bookingByDate: {} } }, computed: { @@ -97,7 +111,27 @@ export default { watch: { visible(v) { if (v) { - this.$nextTick(() => { this.calcHeight(); this.initData() }) + this.setCurrentMonthRange() + this.refreshBookingDataSync() + this._installBookingItemClickGuard() + this.$nextTick(() => { + this.calcHeight() + this.initData() + }) + this._clearFirstOpenTimers() + const delays = [150, 300, 500, 800] + delays.forEach((ms, i) => { + const t = setTimeout(() => { + if (!this.visibleProxy) return + this.refreshBookingDataSync() + this.patchAllCells() + }, ms) + if (!this._firstOpenPatchTimers) this._firstOpenPatchTimers = [] + this._firstOpenPatchTimers.push(t) + }) + } else { + this._clearFirstOpenTimers() + this._removeBookingItemClickGuard() } } }, @@ -106,8 +140,128 @@ export default { }, beforeDestroy() { window.removeEventListener('resize', this.calcHeight) + this._clearFirstOpenTimers() + this._removeBookingItemClickGuard() }, methods: { + readStorage() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return [] + const arr = JSON.parse(raw) + return Array.isArray(arr) ? arr : [] + } catch (e) { + return [] + } + }, + writeStorage(list) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(list || [])) + } catch (e) {} + }, + buildDemoDataForCalendar(visibleMonthDate) { + // 必须按“当前日历可见月份”生成示例,不能用 activeStart(月视图可能是上月末尾) + // 优先用传入的可见月基准日,否则 api.getDate(),再否则 startTime/endTime 中点 + let base = visibleMonthDate instanceof Date ? visibleMonthDate : null + if (!base) { + const api = this.$refs.fullCalendar && this.$refs.fullCalendar.getApi && this.$refs.fullCalendar.getApi() + base = api && api.getDate ? api.getDate() : null + } + if (!base) { + const s = this.startTime instanceof Date ? this.startTime.getTime() : null + const e = this.endTime instanceof Date ? this.endTime.getTime() : null + base = (s && e) ? new Date((s + e) / 2) : new Date() + } + const y = base.getFullYear() + const m = base.getMonth() + 1 + const mm = String(m).padStart(2, '0') + const d8 = `${y}-${mm}-08` + const d9 = `${y}-${mm}-09` + const mk = (id, date, startTime, endTime, memberName, roomName, status, therapistIds, items) => ({ + id, + memberId: 'cust_demo', + memberName, + date, + startTime, + endTime, + roomId: 'R001', + roomName, + therapistIds, + therapistNames: [], + status, + colorKey: 'blue', + remark: '', + items: items || [], + itemLabels: (items || []).map(x => `${x.label}×${x.count || 1}`) + }) + return [ + mk( + `PCB_DEMO_${d8}_01`, + d8, + '09:00', + '10:30', + '林小纤', + '1号房', + 'booked', + ['E001', 'E002'], + [ + { projectId: 'item001', label: '面部深层护理(次卡)', count: 1, workers: [{ workerId: 'E001' }] }, + { projectId: 'item003', label: '眼周护理套餐', count: 1, workers: [{ workerId: 'E002' }] } + ] + ), + mk( + `PCB_DEMO_${d8}_02`, + d8, + '14:00', + '15:30', + '王丽', + 'VIP房', + 'serving', + ['E001'], + [{ projectId: 'item002', label: '肩颈调理(疗程)', count: 2, workers: [{ workerId: 'E001' }] }] + ), + mk( + `PCB_DEMO_${d9}_01`, + d9, + '11:00', + '12:00', + '张敏', + '2号房', + 'converted', + ['E004'], + [{ projectId: 'item001', label: '面部深层护理(次卡)', count: 1, workers: [{ workerId: 'E004' }] }] + ) + ] + }, + esc(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + }, + formatDate(d) { + const dt = d instanceof Date ? d : new Date(d) + const y = dt.getFullYear() + const m = String(dt.getMonth() + 1).padStart(2, '0') + const day = String(dt.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` + }, + statusText(status) { + if (status === 'booked') return '已预约' + if (status === 'serving') return '服务中' + if (status === 'converted') return '已完成' + if (status === 'cancelled') return '已取消' + return '无' + }, + statusColor(status) { + if (status === 'booked') return '#409EFF' + if (status === 'serving') return '#67C23A' + if (status === 'converted') return '#909399' + if (status === 'cancelled') return '#F56C6C' + return '#909399' + }, calcHeight() { this.$nextTick(() => { const el = this.$el && this.$el.querySelector('.dialog-content') @@ -116,44 +270,297 @@ export default { } }) }, + onDayRender(info) { + const dateStr = this.formatDate(info.date) + const frame = info.el && (info.el.querySelector('.fc-daygrid-day-frame') || info.el.querySelector('.fc-daygrid-day-events') || info.el) + if (!frame) return + let mount = frame.querySelector('.pc-booking-mount') + if (!mount) { + mount = document.createElement('div') + mount.className = 'pc-booking-mount' + frame.appendChild(mount) + } + mount.innerHTML = this.renderDayCell(dateStr) + }, datesRender(info) { const view = info.view this.startTime = view.activeStart this.endTime = view.activeEnd + this.refreshBookingDataSync() this.initData() }, - initData() { - this.loading = true - setTimeout(() => { - let filtered = [...this.mockData] - if (this.query.F_Status) filtered = filtered.filter(r => r.F_Status === this.query.F_Status) - if (this.startTime && this.endTime) { - filtered = filtered.filter(r => { - const t = new Date(r.yysj).getTime() - return t >= this.startTime.getTime() && t < this.endTime.getTime() + getCalendarRoot() { + // append-to-body 时日历在 document.body,优先从 document 查找 + const fromDoc = document.querySelector('.booking-calendar-dialog .store-calendar') + return fromDoc || (this.$el && this.$el.querySelector('.store-calendar')) || null + }, + getVisibleMonthDate() { + if (this.startTime && this.endTime) { + const mid = (this.startTime.getTime() + this.endTime.getTime()) / 2 + return new Date(mid) + } + return new Date() + }, + refreshBookingDataSync() { + const visibleMonth = this.getVisibleMonthDate() + let list = this.readStorage() + if (!list || list.length === 0) { + list = this.buildDemoDataForCalendar(visibleMonth) + this.writeStorage(list) + } + if (this.query.status) list = list.filter(r => r && r.status === this.query.status) + if (this.startTime && this.endTime) { + const start = this.startTime.getTime() + const end = this.endTime.getTime() + list = list.filter(r => { + const d = r && r.date ? new Date(r.date) : null + if (!d || Number.isNaN(d.getTime())) return false + const t = d.getTime() + return t >= start && t < end + }) + if (list.length === 0) { + const existing = this.readStorage() + const demo = this.buildDemoDataForCalendar(visibleMonth) + const y = visibleMonth.getFullYear() + const m = visibleMonth.getMonth() + const monthStart = new Date(y, m, 1).getTime() + const monthEnd = new Date(y, m + 1, 0, 23, 59, 59, 999).getTime() + const outside = (existing || []).filter(r => { + const t = r && r.date ? new Date(r.date).getTime() : NaN + return Number.isNaN(t) || t < monthStart || t > monthEnd }) + this.writeStorage([...outside, ...demo]) + list = demo + if (this.query.status) list = list.filter(r => r && r.status === this.query.status) } - this.calendarEvents = filtered.map(item => { - let color = '#409EFF' - if (item.F_Status === '已确认') color = '#67C23A' - else if (item.F_Status === '已取消') color = '#F56C6C' - let title = `${item.gkxm || '无'} - ${item.yyrName || '无'}` - if (item.yyjksName) title += ` (${item.yyjksName})` - return { - id: item.id, - title, - start: item.yysj ? new Date(item.yysj).toISOString() : new Date().toISOString(), - end: item.yyjs ? new Date(item.yyjs).toISOString() : new Date().toISOString(), - color, - editable: false, - allDay: false - } + } + this.bookingList = list + const map = {} + list.forEach(r => { + const key = r && r.date ? r.date : '' + if (!key) return + if (!map[key]) map[key] = [] + map[key].push(r) + }) + Object.keys(map).forEach(k => { + map[k] = map[k].slice().sort((a, b) => { + const at = (a && a.startTime) || '' + const bt = (b && b.startTime) || '' + return String(at).localeCompare(String(bt)) }) + }) + this.bookingByDate = map + this.calendarEvents = list.map(item => { + const dateStr = item.date || this.formatDate(new Date()) + const start = item.startTime ? `${dateStr}T${item.startTime}:00` : `${dateStr}T00:00:00` + const end = item.endTime ? `${dateStr}T${item.endTime}:00` : start + return { + id: item.id, + title: '', + start, + end, + color: 'transparent', + textColor: 'transparent', + classNames: ['pc-booking-hidden-event'], + editable: false, + allDay: false + } + }) + }, + _clearFirstOpenTimers() { + if (this._firstOpenPatchTimers && this._firstOpenPatchTimers.length) { + this._firstOpenPatchTimers.forEach(t => clearTimeout(t)) + this._firstOpenPatchTimers = [] + } + }, + setCurrentMonthRange() { + const now = new Date() + const y = now.getFullYear() + const m = now.getMonth() + this.startTime = new Date(y, m, 1, 0, 0, 0, 0) + this.endTime = new Date(y, m + 1, 0, 23, 59, 59, 999) + }, + initData() { + this.loading = true + const run = () => { + this.refreshBookingDataSync() this.loading = false - }, 300) + this.$nextTick(() => { + this.installDayCellRenderer() + this.patchAllCells() + const api = this.$refs.fullCalendar && this.$refs.fullCalendar.getApi && this.$refs.fullCalendar.getApi() + api && api.rerenderDates && api.rerenderDates() + this.$nextTick(() => { this.patchAllCells() }) + setTimeout(() => { this.patchAllCells() }, 150) + }) + } + // append-to-body 时 FullCalendar DOM 稍晚就绪,用 250ms 保证 getCalendarRoot/patchAllCells 能拿到日格 + const delay = this.startTime && this.endTime ? 250 : 400 + setTimeout(run, delay) + }, + _installBookingItemClickGuard() { + this._removeBookingItemClickGuard() + if (this._pcBookingGuardInstalled) return + const handler = (e) => { + if (!this.visibleProxy) return + const target = e.target + if (!target || typeof target.closest !== 'function') return + const itemEl = target.closest('.pc-booking-item') + if (!itemEl) return + const dialog = target.closest('.booking-calendar-dialog') + if (!dialog) return + e.preventDefault() + e.stopPropagation() + const id = itemEl.getAttribute('data-id') + const rec = (this.bookingList || []).find(x => x && x.id === id) + if (rec) { + this.selectedBooking = { ...rec } + this.detailVisible = true + } + } + document.addEventListener('click', handler, true) + this._pcBookingGuardHandler = handler + this._pcBookingGuardTarget = document + this._pcBookingGuardInstalled = true + }, + _removeBookingItemClickGuard() { + if (this._pcBookingGuardTarget && this._pcBookingGuardHandler) { + this._pcBookingGuardTarget.removeEventListener('click', this._pcBookingGuardHandler, true) + this._pcBookingGuardTarget = null + this._pcBookingGuardHandler = null + } + this._pcBookingGuardInstalled = false + }, + installDayCellRenderer() { + const root = this.getCalendarRoot() + if (!root) return + if (this._pcBookingDelegationInstalled) return + this._pcBookingDelegationInstalled = true + + // 监听 FullCalendar 月视图切换导致的 DOM 变动,及时重绘日格摘要 + this._pcBookingMutation && this._pcBookingMutation.disconnect && this._pcBookingMutation.disconnect() + this._pcBookingMutation = new MutationObserver(() => { + this.patchAllCells() + }) + this._pcBookingMutation.observe(root, { childList: true, subtree: true }) + }, + renderDayCell(dateStr) { + const list = (this.bookingByDate && this.bookingByDate[dateStr]) ? this.bookingByDate[dateStr] : [] + const total = list.length + if (!total) return '' + + const maxShow = 2 + const showList = list.slice(0, maxShow) + const rest = total - showList.length + const itemsHtml = showList.map(r => { + const time = `${this.esc(r.startTime || '')}-${this.esc(r.endTime || '')}`.replace(/^-|-$/g, '') + const member = this.esc(r.memberName || '无') + const room = this.esc(r.roomName || '无') + const stText = this.esc(this.statusText(r.status)) + const stColor = this.statusColor(r.status) + const brief = [time, member, room, stText].filter(Boolean).join(' ') + return `
+ + ${brief} +
` + }).join('') + + const moreHtml = rest > 0 ? `
+${rest}
` : '' + return `
+
${total}
+
${itemsHtml}${moreHtml}
+
` + }, + patchAllCells() { + const root = this.getCalendarRoot() + if (!root) return + const cells = root.querySelectorAll('.fc-daygrid-day') + cells.forEach(cell => { + const dateStr = cell.getAttribute('data-date') + if (!dateStr) return + let mount = cell.querySelector('.pc-booking-mount') + if (!mount) { + // 不能挂在 day-top(高度小/可能 overflow),挂到 day-frame/事件容器里更稳定 + const top = cell.querySelector('.fc-daygrid-day-frame') || + cell.querySelector('.fc-daygrid-day-events') || + cell + mount = document.createElement('div') + mount.className = 'pc-booking-mount' + top.appendChild(mount) + } + mount.innerHTML = this.renderDayCell(dateStr) + }) + }, + handleDateClick(arg) { + // 若点击的是预约条目,不打开新建预约,由 capture 层只打开详情 + if (arg && arg.jsEvent && arg.jsEvent.target && arg.jsEvent.target.closest && arg.jsEvent.target.closest('.pc-booking-item')) { + return + } + // 点击空白日期格:打开新建预约,预填点击的日期 + const dateStr = arg && arg.date ? this.formatDate(arg.date) : this.formatDate(new Date()) + this.consumePrefill = { date: dateStr } + this.consumeVisible = true + }, + handleEventClick(arg) { + // 点击日历上已有预约事件(透明占位):打开预约详情 + const id = arg && arg.event ? arg.event.id : '' + if (!id) return + const rec = (this.bookingList || []).find(x => x && x.id === id) + if (!rec) return + this.selectedBooking = { ...rec } + this.detailVisible = true + }, + handleConsumeSaved() { + this.consumeVisible = false + this.initData() + }, + handleDetailCancel(b) { + if (!b) return + this.$confirm(`确定取消「${b.memberName || '该会员'}」在 ${b.date} ${b.startTime || ''}-${b.endTime || ''} 的预约吗?`, '取消预约确认', { + confirmButtonText: '确定', + cancelButtonText: '再想想', + type: 'warning' + }).then(() => { + this.updateBookingInStorage(b.id, { ...b, status: 'cancelled' }) + this.detailVisible = false + this.selectedBooking = null + this.initData() + this.$message.success('已取消预约') + }).catch(() => {}) + }, + handleDetailEdit(b) { + if (!b) return + this.consumePrefill = { ...b } + this.detailVisible = false + this.$nextTick(() => { + this.consumeVisible = true + }) + }, + handleDetailStart(b) { + if (!b) return + this.updateBookingInStorage(b.id, { ...b, status: 'serving' }) + this.selectedBooking = { ...this.selectedBooking, status: 'serving' } + this.initData() + this.$message.success('已开始服务') + }, + handleDetailConvert(b) { + if (!b) return + this.updateBookingInStorage(b.id, { ...b, status: 'converted' }) + this.detailVisible = false + this.selectedBooking = null + this.initData() + this.$message.success('已转消耗开单') + }, + updateBookingInStorage(id, updated) { + const list = this.readStorage() + const idx = list.findIndex(x => x && x.id === id) + if (idx >= 0) { + list[idx] = { ...list[idx], ...updated } + this.writeStorage(list) + } }, search() { this.initData() }, - reset() { this.query.F_Status = undefined; this.initData() } + reset() { this.query.status = undefined; this.initData() } } } @@ -173,6 +580,63 @@ export default { .fc-day-header { font-size: 14px !important; color: #64748b !important; font-weight: 700 !important; background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%) !important; border-bottom: 2px solid #e2e8f0 !important; padding: 12px 8px !important; } .fc-unthemed th, .fc-unthemed td { border-color: #f1f5f9; } } + +.booking-calendar-dialog .store-calendar { + .pc-booking-hidden-event { display: none !important; } + .pc-booking-mount { + position: relative; + margin: 4px 6px 0 4px; + pointer-events: auto; + z-index: 2; + } + .pc-booking-wrap { position: relative; } + .pc-booking-badge { + position: absolute; + top: -2px; + right: 6px; + min-width: 16px; + height: 16px; + line-height: 16px; + padding: 0 5px; + border-radius: 999px; + background: rgba(37, 99, 235, 0.12); + color: #2563eb; + font-size: 11px; + font-weight: 700; + text-align: center; + } + .pc-booking-list { margin-top: 18px; } + .pc-booking-item { + display: flex; + align-items: center; + gap: 6px; + margin: 2px 0; + padding: 2px 6px; + border-radius: 8px; + background: rgba(241, 245, 249, 0.55); + cursor: pointer; + user-select: none; + transition: background 0.15s; + &:hover { background: rgba(219, 234, 254, 0.8); } + } + .pc-booking-dot { width: 6px; height: 6px; border-radius: 999px; flex-shrink: 0; } + .pc-booking-brief { + font-size: 11px; + color: #334155; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: 16px; + max-width: 140px; + } + .pc-booking-more { + font-size: 11px; + color: #64748b; + padding: 0 6px; + margin-top: 2px; + white-space: nowrap; + } +} + + + + diff --git a/store-pc/src/components/EmployeeScheduleDialog.vue b/store-pc/src/components/EmployeeScheduleDialog.vue index 9064abb..6f8720d 100644 --- a/store-pc/src/components/EmployeeScheduleDialog.vue +++ b/store-pc/src/components/EmployeeScheduleDialog.vue @@ -31,11 +31,19 @@ >{{ d.weekday }} {{ d.dateStr }}
- 已预约 - 已确认 - 请假 - 休假 - 休息 + + 预约状态 + 已预约 + 服务中 + 已完成 + + + + 人事状态 + 请假 + 休假 + + 30分钟/格
@@ -103,34 +111,6 @@ - - -
-
-
预约详情
- -
-
-
会员姓名{{ selectedBooking.customerName }}
-
预约时间{{ selectedBooking.timeRange }}
-
预约项目{{ selectedBooking.project || '—' }}
-
状态{{ selectedBooking.status }}
-
备注{{ selectedBooking.remark }}
-
- -
-
- - - + @@ -159,13 +140,14 @@