Commit f4739ed53b544678b0eed29bfdf1108361f7f39a
1 parent
cb9a18fe
完成了基本的门店PC页面的设计
Showing
6 changed files
with
1429 additions
and
228 deletions
store-pc/src/components/BookingCalendarDialog.vue
| ... | ... | @@ -16,10 +16,11 @@ |
| 16 | 16 | <div class="dialog-search"> |
| 17 | 17 | <el-form @submit.native.prevent :inline="true" size="small"> |
| 18 | 18 | <el-form-item label="预约状态"> |
| 19 | - <el-select v-model="query.F_Status" placeholder="预约状态" clearable style="width:140px"> | |
| 20 | - <el-option label="已确认" value="已确认" /> | |
| 21 | - <el-option label="已取消" value="已取消" /> | |
| 22 | - <el-option label="已预约" value="已预约" /> | |
| 19 | + <el-select v-model="query.status" placeholder="预约状态" clearable style="width:160px"> | |
| 20 | + <el-option label="已预约" value="booked" /> | |
| 21 | + <el-option label="服务中" value="serving" /> | |
| 22 | + <el-option label="已完成" value="converted" /> | |
| 23 | + <el-option label="已取消" value="cancelled" /> | |
| 23 | 24 | </el-select> |
| 24 | 25 | </el-form-item> |
| 25 | 26 | <el-form-item> |
| ... | ... | @@ -44,10 +45,27 @@ |
| 44 | 45 | :eventLimit="true" |
| 45 | 46 | allDayText="全天" |
| 46 | 47 | :editable="false" |
| 48 | + :dayRender="onDayRender" | |
| 47 | 49 | @datesRender="datesRender" |
| 50 | + @dateClick="handleDateClick" | |
| 51 | + @eventClick="handleEventClick" | |
| 48 | 52 | /> |
| 49 | 53 | </div> |
| 50 | 54 | </div> |
| 55 | + | |
| 56 | + <booking-consume-dialog | |
| 57 | + :visible.sync="consumeVisible" | |
| 58 | + :prefill="consumePrefill" | |
| 59 | + @saved="handleConsumeSaved" | |
| 60 | + /> | |
| 61 | + <booking-consume-detail-dialog | |
| 62 | + :visible.sync="detailVisible" | |
| 63 | + :booking="selectedBooking" | |
| 64 | + @cancel="handleDetailCancel" | |
| 65 | + @edit="handleDetailEdit" | |
| 66 | + @start="handleDetailStart" | |
| 67 | + @convert="handleDetailConvert" | |
| 68 | + /> | |
| 51 | 69 | </el-dialog> |
| 52 | 70 | </template> |
| 53 | 71 | |
| ... | ... | @@ -56,36 +74,32 @@ import FullCalendar from '@fullcalendar/vue' |
| 56 | 74 | import dayGridPlugin from '@fullcalendar/daygrid' |
| 57 | 75 | import timeGridPlugin from '@fullcalendar/timegrid' |
| 58 | 76 | import interactionPlugin from '@fullcalendar/interaction' |
| 77 | +import BookingConsumeDialog from '@/components/booking-consume-dialog.vue' | |
| 78 | +import BookingConsumeDetailDialog from '@/components/booking-consume-detail-dialog.vue' | |
| 59 | 79 | |
| 60 | -const MOCK_BOOKING = [ | |
| 61 | - { id: '1', storeName: '保利', yyrName: '贾琳', gkxm: '范佳佳', yysj: '2026-02-11T11:00:00', yyjs: '2026-02-11T11:30:00', F_Status: '已预约', yyjksName: '贾琳' }, | |
| 62 | - { id: '2', storeName: '保利', yyrName: '贾琳', gkxm: '黄仕碧', yysj: '2026-02-11T15:00:00', yyjs: '2026-02-11T15:30:00', F_Status: '已预约', yyjksName: '贾琳' }, | |
| 63 | - { id: '3', storeName: '保利', yyrName: '贾琳', gkxm: '赵丽', yysj: '2026-02-11T17:00:00', yyjs: '2026-02-11T17:30:00', F_Status: '已确认', yyjksName: '贾琳' }, | |
| 64 | - { id: '4', storeName: '468', yyrName: '刘恬恬', gkxm: '王英', yysj: '2026-02-11T14:00:00', yyjs: '2026-02-11T14:30:00', F_Status: '已预约', yyjksName: '刘恬恬' }, | |
| 65 | - { id: '5', storeName: '保利', yyrName: '贾琳', gkxm: '罗建琼', yysj: '2026-02-11T10:30:00', yyjs: '2026-02-11T11:00:00', F_Status: '已确认', yyjksName: '贾琳' }, | |
| 66 | - { id: '6', storeName: '保利', yyrName: '贾琳', gkxm: '沈丽', yysj: '2026-02-10T17:00:00', yyjs: '2026-02-10T17:30:00', F_Status: '已预约', yyjksName: '贾琳' }, | |
| 67 | - { id: '7', storeName: '静居寺', yyrName: '董顺秀', gkxm: '陈晴', yysj: '2026-02-11T09:30:00', yyjs: '2026-02-11T10:00:00', F_Status: '已取消', yyjksName: '董顺秀' }, | |
| 68 | - { id: '8', storeName: '静居寺', yyrName: '董顺秀', gkxm: '胡蝶', yysj: '2026-02-10T17:00:00', yyjs: '2026-02-10T17:30:00', F_Status: '已预约', yyjksName: '董顺秀' }, | |
| 69 | - { id: '9', storeName: '静居寺', yyrName: '董顺秀', gkxm: '魏海燕', yysj: '2026-02-10T14:00:00', yyjs: '2026-02-10T14:30:00', F_Status: '已确认', yyjksName: '董顺秀' }, | |
| 70 | - { id: '10', storeName: '静居寺', yyrName: '董顺秀', gkxm: '肖丛娇', yysj: '2026-02-10T11:00:00', yyjs: '2026-02-10T11:30:00', F_Status: '已预约', yyjksName: '董顺秀' } | |
| 71 | -] | |
| 80 | +const STORAGE_KEY = 'store_pc_pre_consume_bookings' | |
| 72 | 81 | |
| 73 | 82 | export default { |
| 74 | 83 | name: 'BookingCalendarDialog', |
| 75 | - components: { FullCalendar }, | |
| 84 | + components: { FullCalendar, BookingConsumeDialog, BookingConsumeDetailDialog }, | |
| 76 | 85 | props: { visible: { type: Boolean, default: false } }, |
| 77 | 86 | data() { |
| 78 | 87 | return { |
| 79 | 88 | loading: false, |
| 80 | - mockData: MOCK_BOOKING, | |
| 81 | - query: { F_Status: undefined }, | |
| 89 | + query: { status: undefined }, | |
| 82 | 90 | calendarPlugins: [dayGridPlugin, timeGridPlugin, interactionPlugin], |
| 83 | 91 | calendarEvents: [], |
| 84 | 92 | calendarHeader: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }, |
| 85 | 93 | buttonText: { today: '今日', month: '月', week: '周', day: '日' }, |
| 86 | 94 | startTime: null, |
| 87 | 95 | endTime: null, |
| 88 | - calendarHeight: 600 | |
| 96 | + calendarHeight: 600, | |
| 97 | + consumeVisible: false, | |
| 98 | + consumePrefill: {}, | |
| 99 | + detailVisible: false, | |
| 100 | + selectedBooking: null, | |
| 101 | + bookingList: [], | |
| 102 | + bookingByDate: {} | |
| 89 | 103 | } |
| 90 | 104 | }, |
| 91 | 105 | computed: { |
| ... | ... | @@ -97,7 +111,27 @@ export default { |
| 97 | 111 | watch: { |
| 98 | 112 | visible(v) { |
| 99 | 113 | if (v) { |
| 100 | - this.$nextTick(() => { this.calcHeight(); this.initData() }) | |
| 114 | + this.setCurrentMonthRange() | |
| 115 | + this.refreshBookingDataSync() | |
| 116 | + this._installBookingItemClickGuard() | |
| 117 | + this.$nextTick(() => { | |
| 118 | + this.calcHeight() | |
| 119 | + this.initData() | |
| 120 | + }) | |
| 121 | + this._clearFirstOpenTimers() | |
| 122 | + const delays = [150, 300, 500, 800] | |
| 123 | + delays.forEach((ms, i) => { | |
| 124 | + const t = setTimeout(() => { | |
| 125 | + if (!this.visibleProxy) return | |
| 126 | + this.refreshBookingDataSync() | |
| 127 | + this.patchAllCells() | |
| 128 | + }, ms) | |
| 129 | + if (!this._firstOpenPatchTimers) this._firstOpenPatchTimers = [] | |
| 130 | + this._firstOpenPatchTimers.push(t) | |
| 131 | + }) | |
| 132 | + } else { | |
| 133 | + this._clearFirstOpenTimers() | |
| 134 | + this._removeBookingItemClickGuard() | |
| 101 | 135 | } |
| 102 | 136 | } |
| 103 | 137 | }, |
| ... | ... | @@ -106,8 +140,128 @@ export default { |
| 106 | 140 | }, |
| 107 | 141 | beforeDestroy() { |
| 108 | 142 | window.removeEventListener('resize', this.calcHeight) |
| 143 | + this._clearFirstOpenTimers() | |
| 144 | + this._removeBookingItemClickGuard() | |
| 109 | 145 | }, |
| 110 | 146 | methods: { |
| 147 | + readStorage() { | |
| 148 | + try { | |
| 149 | + const raw = localStorage.getItem(STORAGE_KEY) | |
| 150 | + if (!raw) return [] | |
| 151 | + const arr = JSON.parse(raw) | |
| 152 | + return Array.isArray(arr) ? arr : [] | |
| 153 | + } catch (e) { | |
| 154 | + return [] | |
| 155 | + } | |
| 156 | + }, | |
| 157 | + writeStorage(list) { | |
| 158 | + try { | |
| 159 | + localStorage.setItem(STORAGE_KEY, JSON.stringify(list || [])) | |
| 160 | + } catch (e) {} | |
| 161 | + }, | |
| 162 | + buildDemoDataForCalendar(visibleMonthDate) { | |
| 163 | + // 必须按“当前日历可见月份”生成示例,不能用 activeStart(月视图可能是上月末尾) | |
| 164 | + // 优先用传入的可见月基准日,否则 api.getDate(),再否则 startTime/endTime 中点 | |
| 165 | + let base = visibleMonthDate instanceof Date ? visibleMonthDate : null | |
| 166 | + if (!base) { | |
| 167 | + const api = this.$refs.fullCalendar && this.$refs.fullCalendar.getApi && this.$refs.fullCalendar.getApi() | |
| 168 | + base = api && api.getDate ? api.getDate() : null | |
| 169 | + } | |
| 170 | + if (!base) { | |
| 171 | + const s = this.startTime instanceof Date ? this.startTime.getTime() : null | |
| 172 | + const e = this.endTime instanceof Date ? this.endTime.getTime() : null | |
| 173 | + base = (s && e) ? new Date((s + e) / 2) : new Date() | |
| 174 | + } | |
| 175 | + const y = base.getFullYear() | |
| 176 | + const m = base.getMonth() + 1 | |
| 177 | + const mm = String(m).padStart(2, '0') | |
| 178 | + const d8 = `${y}-${mm}-08` | |
| 179 | + const d9 = `${y}-${mm}-09` | |
| 180 | + const mk = (id, date, startTime, endTime, memberName, roomName, status, therapistIds, items) => ({ | |
| 181 | + id, | |
| 182 | + memberId: 'cust_demo', | |
| 183 | + memberName, | |
| 184 | + date, | |
| 185 | + startTime, | |
| 186 | + endTime, | |
| 187 | + roomId: 'R001', | |
| 188 | + roomName, | |
| 189 | + therapistIds, | |
| 190 | + therapistNames: [], | |
| 191 | + status, | |
| 192 | + colorKey: 'blue', | |
| 193 | + remark: '', | |
| 194 | + items: items || [], | |
| 195 | + itemLabels: (items || []).map(x => `${x.label}×${x.count || 1}`) | |
| 196 | + }) | |
| 197 | + return [ | |
| 198 | + mk( | |
| 199 | + `PCB_DEMO_${d8}_01`, | |
| 200 | + d8, | |
| 201 | + '09:00', | |
| 202 | + '10:30', | |
| 203 | + '林小纤', | |
| 204 | + '1号房', | |
| 205 | + 'booked', | |
| 206 | + ['E001', 'E002'], | |
| 207 | + [ | |
| 208 | + { projectId: 'item001', label: '面部深层护理(次卡)', count: 1, workers: [{ workerId: 'E001' }] }, | |
| 209 | + { projectId: 'item003', label: '眼周护理套餐', count: 1, workers: [{ workerId: 'E002' }] } | |
| 210 | + ] | |
| 211 | + ), | |
| 212 | + mk( | |
| 213 | + `PCB_DEMO_${d8}_02`, | |
| 214 | + d8, | |
| 215 | + '14:00', | |
| 216 | + '15:30', | |
| 217 | + '王丽', | |
| 218 | + 'VIP房', | |
| 219 | + 'serving', | |
| 220 | + ['E001'], | |
| 221 | + [{ projectId: 'item002', label: '肩颈调理(疗程)', count: 2, workers: [{ workerId: 'E001' }] }] | |
| 222 | + ), | |
| 223 | + mk( | |
| 224 | + `PCB_DEMO_${d9}_01`, | |
| 225 | + d9, | |
| 226 | + '11:00', | |
| 227 | + '12:00', | |
| 228 | + '张敏', | |
| 229 | + '2号房', | |
| 230 | + 'converted', | |
| 231 | + ['E004'], | |
| 232 | + [{ projectId: 'item001', label: '面部深层护理(次卡)', count: 1, workers: [{ workerId: 'E004' }] }] | |
| 233 | + ) | |
| 234 | + ] | |
| 235 | + }, | |
| 236 | + esc(s) { | |
| 237 | + return String(s == null ? '' : s) | |
| 238 | + .replace(/&/g, '&') | |
| 239 | + .replace(/</g, '<') | |
| 240 | + .replace(/>/g, '>') | |
| 241 | + .replace(/"/g, '"') | |
| 242 | + .replace(/'/g, ''') | |
| 243 | + }, | |
| 244 | + formatDate(d) { | |
| 245 | + const dt = d instanceof Date ? d : new Date(d) | |
| 246 | + const y = dt.getFullYear() | |
| 247 | + const m = String(dt.getMonth() + 1).padStart(2, '0') | |
| 248 | + const day = String(dt.getDate()).padStart(2, '0') | |
| 249 | + return `${y}-${m}-${day}` | |
| 250 | + }, | |
| 251 | + statusText(status) { | |
| 252 | + if (status === 'booked') return '已预约' | |
| 253 | + if (status === 'serving') return '服务中' | |
| 254 | + if (status === 'converted') return '已完成' | |
| 255 | + if (status === 'cancelled') return '已取消' | |
| 256 | + return '无' | |
| 257 | + }, | |
| 258 | + statusColor(status) { | |
| 259 | + if (status === 'booked') return '#409EFF' | |
| 260 | + if (status === 'serving') return '#67C23A' | |
| 261 | + if (status === 'converted') return '#909399' | |
| 262 | + if (status === 'cancelled') return '#F56C6C' | |
| 263 | + return '#909399' | |
| 264 | + }, | |
| 111 | 265 | calcHeight() { |
| 112 | 266 | this.$nextTick(() => { |
| 113 | 267 | const el = this.$el && this.$el.querySelector('.dialog-content') |
| ... | ... | @@ -116,44 +270,297 @@ export default { |
| 116 | 270 | } |
| 117 | 271 | }) |
| 118 | 272 | }, |
| 273 | + onDayRender(info) { | |
| 274 | + const dateStr = this.formatDate(info.date) | |
| 275 | + const frame = info.el && (info.el.querySelector('.fc-daygrid-day-frame') || info.el.querySelector('.fc-daygrid-day-events') || info.el) | |
| 276 | + if (!frame) return | |
| 277 | + let mount = frame.querySelector('.pc-booking-mount') | |
| 278 | + if (!mount) { | |
| 279 | + mount = document.createElement('div') | |
| 280 | + mount.className = 'pc-booking-mount' | |
| 281 | + frame.appendChild(mount) | |
| 282 | + } | |
| 283 | + mount.innerHTML = this.renderDayCell(dateStr) | |
| 284 | + }, | |
| 119 | 285 | datesRender(info) { |
| 120 | 286 | const view = info.view |
| 121 | 287 | this.startTime = view.activeStart |
| 122 | 288 | this.endTime = view.activeEnd |
| 289 | + this.refreshBookingDataSync() | |
| 123 | 290 | this.initData() |
| 124 | 291 | }, |
| 125 | - initData() { | |
| 126 | - this.loading = true | |
| 127 | - setTimeout(() => { | |
| 128 | - let filtered = [...this.mockData] | |
| 129 | - if (this.query.F_Status) filtered = filtered.filter(r => r.F_Status === this.query.F_Status) | |
| 130 | - if (this.startTime && this.endTime) { | |
| 131 | - filtered = filtered.filter(r => { | |
| 132 | - const t = new Date(r.yysj).getTime() | |
| 133 | - return t >= this.startTime.getTime() && t < this.endTime.getTime() | |
| 292 | + getCalendarRoot() { | |
| 293 | + // append-to-body 时日历在 document.body,优先从 document 查找 | |
| 294 | + const fromDoc = document.querySelector('.booking-calendar-dialog .store-calendar') | |
| 295 | + return fromDoc || (this.$el && this.$el.querySelector('.store-calendar')) || null | |
| 296 | + }, | |
| 297 | + getVisibleMonthDate() { | |
| 298 | + if (this.startTime && this.endTime) { | |
| 299 | + const mid = (this.startTime.getTime() + this.endTime.getTime()) / 2 | |
| 300 | + return new Date(mid) | |
| 301 | + } | |
| 302 | + return new Date() | |
| 303 | + }, | |
| 304 | + refreshBookingDataSync() { | |
| 305 | + const visibleMonth = this.getVisibleMonthDate() | |
| 306 | + let list = this.readStorage() | |
| 307 | + if (!list || list.length === 0) { | |
| 308 | + list = this.buildDemoDataForCalendar(visibleMonth) | |
| 309 | + this.writeStorage(list) | |
| 310 | + } | |
| 311 | + if (this.query.status) list = list.filter(r => r && r.status === this.query.status) | |
| 312 | + if (this.startTime && this.endTime) { | |
| 313 | + const start = this.startTime.getTime() | |
| 314 | + const end = this.endTime.getTime() | |
| 315 | + list = list.filter(r => { | |
| 316 | + const d = r && r.date ? new Date(r.date) : null | |
| 317 | + if (!d || Number.isNaN(d.getTime())) return false | |
| 318 | + const t = d.getTime() | |
| 319 | + return t >= start && t < end | |
| 320 | + }) | |
| 321 | + if (list.length === 0) { | |
| 322 | + const existing = this.readStorage() | |
| 323 | + const demo = this.buildDemoDataForCalendar(visibleMonth) | |
| 324 | + const y = visibleMonth.getFullYear() | |
| 325 | + const m = visibleMonth.getMonth() | |
| 326 | + const monthStart = new Date(y, m, 1).getTime() | |
| 327 | + const monthEnd = new Date(y, m + 1, 0, 23, 59, 59, 999).getTime() | |
| 328 | + const outside = (existing || []).filter(r => { | |
| 329 | + const t = r && r.date ? new Date(r.date).getTime() : NaN | |
| 330 | + return Number.isNaN(t) || t < monthStart || t > monthEnd | |
| 134 | 331 | }) |
| 332 | + this.writeStorage([...outside, ...demo]) | |
| 333 | + list = demo | |
| 334 | + if (this.query.status) list = list.filter(r => r && r.status === this.query.status) | |
| 135 | 335 | } |
| 136 | - this.calendarEvents = filtered.map(item => { | |
| 137 | - let color = '#409EFF' | |
| 138 | - if (item.F_Status === '已确认') color = '#67C23A' | |
| 139 | - else if (item.F_Status === '已取消') color = '#F56C6C' | |
| 140 | - let title = `${item.gkxm || '无'} - ${item.yyrName || '无'}` | |
| 141 | - if (item.yyjksName) title += ` (${item.yyjksName})` | |
| 142 | - return { | |
| 143 | - id: item.id, | |
| 144 | - title, | |
| 145 | - start: item.yysj ? new Date(item.yysj).toISOString() : new Date().toISOString(), | |
| 146 | - end: item.yyjs ? new Date(item.yyjs).toISOString() : new Date().toISOString(), | |
| 147 | - color, | |
| 148 | - editable: false, | |
| 149 | - allDay: false | |
| 150 | - } | |
| 336 | + } | |
| 337 | + this.bookingList = list | |
| 338 | + const map = {} | |
| 339 | + list.forEach(r => { | |
| 340 | + const key = r && r.date ? r.date : '' | |
| 341 | + if (!key) return | |
| 342 | + if (!map[key]) map[key] = [] | |
| 343 | + map[key].push(r) | |
| 344 | + }) | |
| 345 | + Object.keys(map).forEach(k => { | |
| 346 | + map[k] = map[k].slice().sort((a, b) => { | |
| 347 | + const at = (a && a.startTime) || '' | |
| 348 | + const bt = (b && b.startTime) || '' | |
| 349 | + return String(at).localeCompare(String(bt)) | |
| 151 | 350 | }) |
| 351 | + }) | |
| 352 | + this.bookingByDate = map | |
| 353 | + this.calendarEvents = list.map(item => { | |
| 354 | + const dateStr = item.date || this.formatDate(new Date()) | |
| 355 | + const start = item.startTime ? `${dateStr}T${item.startTime}:00` : `${dateStr}T00:00:00` | |
| 356 | + const end = item.endTime ? `${dateStr}T${item.endTime}:00` : start | |
| 357 | + return { | |
| 358 | + id: item.id, | |
| 359 | + title: '', | |
| 360 | + start, | |
| 361 | + end, | |
| 362 | + color: 'transparent', | |
| 363 | + textColor: 'transparent', | |
| 364 | + classNames: ['pc-booking-hidden-event'], | |
| 365 | + editable: false, | |
| 366 | + allDay: false | |
| 367 | + } | |
| 368 | + }) | |
| 369 | + }, | |
| 370 | + _clearFirstOpenTimers() { | |
| 371 | + if (this._firstOpenPatchTimers && this._firstOpenPatchTimers.length) { | |
| 372 | + this._firstOpenPatchTimers.forEach(t => clearTimeout(t)) | |
| 373 | + this._firstOpenPatchTimers = [] | |
| 374 | + } | |
| 375 | + }, | |
| 376 | + setCurrentMonthRange() { | |
| 377 | + const now = new Date() | |
| 378 | + const y = now.getFullYear() | |
| 379 | + const m = now.getMonth() | |
| 380 | + this.startTime = new Date(y, m, 1, 0, 0, 0, 0) | |
| 381 | + this.endTime = new Date(y, m + 1, 0, 23, 59, 59, 999) | |
| 382 | + }, | |
| 383 | + initData() { | |
| 384 | + this.loading = true | |
| 385 | + const run = () => { | |
| 386 | + this.refreshBookingDataSync() | |
| 152 | 387 | this.loading = false |
| 153 | - }, 300) | |
| 388 | + this.$nextTick(() => { | |
| 389 | + this.installDayCellRenderer() | |
| 390 | + this.patchAllCells() | |
| 391 | + const api = this.$refs.fullCalendar && this.$refs.fullCalendar.getApi && this.$refs.fullCalendar.getApi() | |
| 392 | + api && api.rerenderDates && api.rerenderDates() | |
| 393 | + this.$nextTick(() => { this.patchAllCells() }) | |
| 394 | + setTimeout(() => { this.patchAllCells() }, 150) | |
| 395 | + }) | |
| 396 | + } | |
| 397 | + // append-to-body 时 FullCalendar DOM 稍晚就绪,用 250ms 保证 getCalendarRoot/patchAllCells 能拿到日格 | |
| 398 | + const delay = this.startTime && this.endTime ? 250 : 400 | |
| 399 | + setTimeout(run, delay) | |
| 400 | + }, | |
| 401 | + _installBookingItemClickGuard() { | |
| 402 | + this._removeBookingItemClickGuard() | |
| 403 | + if (this._pcBookingGuardInstalled) return | |
| 404 | + const handler = (e) => { | |
| 405 | + if (!this.visibleProxy) return | |
| 406 | + const target = e.target | |
| 407 | + if (!target || typeof target.closest !== 'function') return | |
| 408 | + const itemEl = target.closest('.pc-booking-item') | |
| 409 | + if (!itemEl) return | |
| 410 | + const dialog = target.closest('.booking-calendar-dialog') | |
| 411 | + if (!dialog) return | |
| 412 | + e.preventDefault() | |
| 413 | + e.stopPropagation() | |
| 414 | + const id = itemEl.getAttribute('data-id') | |
| 415 | + const rec = (this.bookingList || []).find(x => x && x.id === id) | |
| 416 | + if (rec) { | |
| 417 | + this.selectedBooking = { ...rec } | |
| 418 | + this.detailVisible = true | |
| 419 | + } | |
| 420 | + } | |
| 421 | + document.addEventListener('click', handler, true) | |
| 422 | + this._pcBookingGuardHandler = handler | |
| 423 | + this._pcBookingGuardTarget = document | |
| 424 | + this._pcBookingGuardInstalled = true | |
| 425 | + }, | |
| 426 | + _removeBookingItemClickGuard() { | |
| 427 | + if (this._pcBookingGuardTarget && this._pcBookingGuardHandler) { | |
| 428 | + this._pcBookingGuardTarget.removeEventListener('click', this._pcBookingGuardHandler, true) | |
| 429 | + this._pcBookingGuardTarget = null | |
| 430 | + this._pcBookingGuardHandler = null | |
| 431 | + } | |
| 432 | + this._pcBookingGuardInstalled = false | |
| 433 | + }, | |
| 434 | + installDayCellRenderer() { | |
| 435 | + const root = this.getCalendarRoot() | |
| 436 | + if (!root) return | |
| 437 | + if (this._pcBookingDelegationInstalled) return | |
| 438 | + this._pcBookingDelegationInstalled = true | |
| 439 | + | |
| 440 | + // 监听 FullCalendar 月视图切换导致的 DOM 变动,及时重绘日格摘要 | |
| 441 | + this._pcBookingMutation && this._pcBookingMutation.disconnect && this._pcBookingMutation.disconnect() | |
| 442 | + this._pcBookingMutation = new MutationObserver(() => { | |
| 443 | + this.patchAllCells() | |
| 444 | + }) | |
| 445 | + this._pcBookingMutation.observe(root, { childList: true, subtree: true }) | |
| 446 | + }, | |
| 447 | + renderDayCell(dateStr) { | |
| 448 | + const list = (this.bookingByDate && this.bookingByDate[dateStr]) ? this.bookingByDate[dateStr] : [] | |
| 449 | + const total = list.length | |
| 450 | + if (!total) return '' | |
| 451 | + | |
| 452 | + const maxShow = 2 | |
| 453 | + const showList = list.slice(0, maxShow) | |
| 454 | + const rest = total - showList.length | |
| 455 | + const itemsHtml = showList.map(r => { | |
| 456 | + const time = `${this.esc(r.startTime || '')}-${this.esc(r.endTime || '')}`.replace(/^-|-$/g, '') | |
| 457 | + const member = this.esc(r.memberName || '无') | |
| 458 | + const room = this.esc(r.roomName || '无') | |
| 459 | + const stText = this.esc(this.statusText(r.status)) | |
| 460 | + const stColor = this.statusColor(r.status) | |
| 461 | + const brief = [time, member, room, stText].filter(Boolean).join(' ') | |
| 462 | + return `<div class="pc-booking-item" data-id="${this.esc(r.id)}"> | |
| 463 | + <span class="pc-booking-dot" style="background:${stColor}"></span> | |
| 464 | + <span class="pc-booking-brief" title="${brief}">${brief}</span> | |
| 465 | + </div>` | |
| 466 | + }).join('') | |
| 467 | + | |
| 468 | + const moreHtml = rest > 0 ? `<div class="pc-booking-more">+${rest}</div>` : '' | |
| 469 | + return `<div class="pc-booking-wrap"> | |
| 470 | + <div class="pc-booking-badge">${total}</div> | |
| 471 | + <div class="pc-booking-list">${itemsHtml}${moreHtml}</div> | |
| 472 | + </div>` | |
| 473 | + }, | |
| 474 | + patchAllCells() { | |
| 475 | + const root = this.getCalendarRoot() | |
| 476 | + if (!root) return | |
| 477 | + const cells = root.querySelectorAll('.fc-daygrid-day') | |
| 478 | + cells.forEach(cell => { | |
| 479 | + const dateStr = cell.getAttribute('data-date') | |
| 480 | + if (!dateStr) return | |
| 481 | + let mount = cell.querySelector('.pc-booking-mount') | |
| 482 | + if (!mount) { | |
| 483 | + // 不能挂在 day-top(高度小/可能 overflow),挂到 day-frame/事件容器里更稳定 | |
| 484 | + const top = cell.querySelector('.fc-daygrid-day-frame') || | |
| 485 | + cell.querySelector('.fc-daygrid-day-events') || | |
| 486 | + cell | |
| 487 | + mount = document.createElement('div') | |
| 488 | + mount.className = 'pc-booking-mount' | |
| 489 | + top.appendChild(mount) | |
| 490 | + } | |
| 491 | + mount.innerHTML = this.renderDayCell(dateStr) | |
| 492 | + }) | |
| 493 | + }, | |
| 494 | + handleDateClick(arg) { | |
| 495 | + // 若点击的是预约条目,不打开新建预约,由 capture 层只打开详情 | |
| 496 | + if (arg && arg.jsEvent && arg.jsEvent.target && arg.jsEvent.target.closest && arg.jsEvent.target.closest('.pc-booking-item')) { | |
| 497 | + return | |
| 498 | + } | |
| 499 | + // 点击空白日期格:打开新建预约,预填点击的日期 | |
| 500 | + const dateStr = arg && arg.date ? this.formatDate(arg.date) : this.formatDate(new Date()) | |
| 501 | + this.consumePrefill = { date: dateStr } | |
| 502 | + this.consumeVisible = true | |
| 503 | + }, | |
| 504 | + handleEventClick(arg) { | |
| 505 | + // 点击日历上已有预约事件(透明占位):打开预约详情 | |
| 506 | + const id = arg && arg.event ? arg.event.id : '' | |
| 507 | + if (!id) return | |
| 508 | + const rec = (this.bookingList || []).find(x => x && x.id === id) | |
| 509 | + if (!rec) return | |
| 510 | + this.selectedBooking = { ...rec } | |
| 511 | + this.detailVisible = true | |
| 512 | + }, | |
| 513 | + handleConsumeSaved() { | |
| 514 | + this.consumeVisible = false | |
| 515 | + this.initData() | |
| 516 | + }, | |
| 517 | + handleDetailCancel(b) { | |
| 518 | + if (!b) return | |
| 519 | + this.$confirm(`确定取消「${b.memberName || '该会员'}」在 ${b.date} ${b.startTime || ''}-${b.endTime || ''} 的预约吗?`, '取消预约确认', { | |
| 520 | + confirmButtonText: '确定', | |
| 521 | + cancelButtonText: '再想想', | |
| 522 | + type: 'warning' | |
| 523 | + }).then(() => { | |
| 524 | + this.updateBookingInStorage(b.id, { ...b, status: 'cancelled' }) | |
| 525 | + this.detailVisible = false | |
| 526 | + this.selectedBooking = null | |
| 527 | + this.initData() | |
| 528 | + this.$message.success('已取消预约') | |
| 529 | + }).catch(() => {}) | |
| 530 | + }, | |
| 531 | + handleDetailEdit(b) { | |
| 532 | + if (!b) return | |
| 533 | + this.consumePrefill = { ...b } | |
| 534 | + this.detailVisible = false | |
| 535 | + this.$nextTick(() => { | |
| 536 | + this.consumeVisible = true | |
| 537 | + }) | |
| 538 | + }, | |
| 539 | + handleDetailStart(b) { | |
| 540 | + if (!b) return | |
| 541 | + this.updateBookingInStorage(b.id, { ...b, status: 'serving' }) | |
| 542 | + this.selectedBooking = { ...this.selectedBooking, status: 'serving' } | |
| 543 | + this.initData() | |
| 544 | + this.$message.success('已开始服务') | |
| 545 | + }, | |
| 546 | + handleDetailConvert(b) { | |
| 547 | + if (!b) return | |
| 548 | + this.updateBookingInStorage(b.id, { ...b, status: 'converted' }) | |
| 549 | + this.detailVisible = false | |
| 550 | + this.selectedBooking = null | |
| 551 | + this.initData() | |
| 552 | + this.$message.success('已转消耗开单') | |
| 553 | + }, | |
| 554 | + updateBookingInStorage(id, updated) { | |
| 555 | + const list = this.readStorage() | |
| 556 | + const idx = list.findIndex(x => x && x.id === id) | |
| 557 | + if (idx >= 0) { | |
| 558 | + list[idx] = { ...list[idx], ...updated } | |
| 559 | + this.writeStorage(list) | |
| 560 | + } | |
| 154 | 561 | }, |
| 155 | 562 | search() { this.initData() }, |
| 156 | - reset() { this.query.F_Status = undefined; this.initData() } | |
| 563 | + reset() { this.query.status = undefined; this.initData() } | |
| 157 | 564 | } |
| 158 | 565 | } |
| 159 | 566 | </script> |
| ... | ... | @@ -173,6 +580,63 @@ export default { |
| 173 | 580 | .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; } |
| 174 | 581 | .fc-unthemed th, .fc-unthemed td { border-color: #f1f5f9; } |
| 175 | 582 | } |
| 583 | + | |
| 584 | +.booking-calendar-dialog .store-calendar { | |
| 585 | + .pc-booking-hidden-event { display: none !important; } | |
| 586 | + .pc-booking-mount { | |
| 587 | + position: relative; | |
| 588 | + margin: 4px 6px 0 4px; | |
| 589 | + pointer-events: auto; | |
| 590 | + z-index: 2; | |
| 591 | + } | |
| 592 | + .pc-booking-wrap { position: relative; } | |
| 593 | + .pc-booking-badge { | |
| 594 | + position: absolute; | |
| 595 | + top: -2px; | |
| 596 | + right: 6px; | |
| 597 | + min-width: 16px; | |
| 598 | + height: 16px; | |
| 599 | + line-height: 16px; | |
| 600 | + padding: 0 5px; | |
| 601 | + border-radius: 999px; | |
| 602 | + background: rgba(37, 99, 235, 0.12); | |
| 603 | + color: #2563eb; | |
| 604 | + font-size: 11px; | |
| 605 | + font-weight: 700; | |
| 606 | + text-align: center; | |
| 607 | + } | |
| 608 | + .pc-booking-list { margin-top: 18px; } | |
| 609 | + .pc-booking-item { | |
| 610 | + display: flex; | |
| 611 | + align-items: center; | |
| 612 | + gap: 6px; | |
| 613 | + margin: 2px 0; | |
| 614 | + padding: 2px 6px; | |
| 615 | + border-radius: 8px; | |
| 616 | + background: rgba(241, 245, 249, 0.55); | |
| 617 | + cursor: pointer; | |
| 618 | + user-select: none; | |
| 619 | + transition: background 0.15s; | |
| 620 | + &:hover { background: rgba(219, 234, 254, 0.8); } | |
| 621 | + } | |
| 622 | + .pc-booking-dot { width: 6px; height: 6px; border-radius: 999px; flex-shrink: 0; } | |
| 623 | + .pc-booking-brief { | |
| 624 | + font-size: 11px; | |
| 625 | + color: #334155; | |
| 626 | + white-space: nowrap; | |
| 627 | + overflow: hidden; | |
| 628 | + text-overflow: ellipsis; | |
| 629 | + line-height: 16px; | |
| 630 | + max-width: 140px; | |
| 631 | + } | |
| 632 | + .pc-booking-more { | |
| 633 | + font-size: 11px; | |
| 634 | + color: #64748b; | |
| 635 | + padding: 0 6px; | |
| 636 | + margin-top: 2px; | |
| 637 | + white-space: nowrap; | |
| 638 | + } | |
| 639 | +} | |
| 176 | 640 | </style> |
| 177 | 641 | |
| 178 | 642 | <style lang="scss" scoped> | ... | ... |
store-pc/src/components/CompanyCalendarDialog.vue
| ... | ... | @@ -39,6 +39,73 @@ |
| 39 | 39 | /> |
| 40 | 40 | </div> |
| 41 | 41 | </div> |
| 42 | + | |
| 43 | + <el-dialog | |
| 44 | + :visible.sync="detailVisible" | |
| 45 | + :show-close="false" | |
| 46 | + width="520px" | |
| 47 | + custom-class="company-calendar-detail-dialog" | |
| 48 | + append-to-body | |
| 49 | + > | |
| 50 | + <div v-if="selectedEvent" class="detail-inner"> | |
| 51 | + <div class="header"> | |
| 52 | + <div class="title">日程详情</div> | |
| 53 | + <span class="close" @click="detailVisible = false"><i class="el-icon-close"></i></span> | |
| 54 | + </div> | |
| 55 | + | |
| 56 | + <div class="body"> | |
| 57 | + <div class="row"><span class="label">主题</span><span class="value">{{ selectedEvent.title || '无' }}</span></div> | |
| 58 | + <div class="row"> | |
| 59 | + <span class="label">类型</span> | |
| 60 | + <span class="value"> | |
| 61 | + <span class="type-dot" :style="{ background: typeColorMap[selectedEvent.type] || '#3b82f6' }"></span> | |
| 62 | + {{ selectedEventTypeLabel }} | |
| 63 | + </span> | |
| 64 | + </div> | |
| 65 | + <div class="row"><span class="label">日期</span><span class="value">{{ selectedEventDateStr }}</span></div> | |
| 66 | + <div class="row"><span class="label">时间</span><span class="value">{{ selectedEventTimeRange }}</span></div> | |
| 67 | + <div class="row" v-if="selectedEvent.desc"> | |
| 68 | + <span class="label">内容</span> | |
| 69 | + <span class="value value--multiline">{{ selectedEvent.desc }}</span> | |
| 70 | + </div> | |
| 71 | + <div class="row" v-if="selectedEvent.store"> | |
| 72 | + <span class="label">门店/地点</span> | |
| 73 | + <span class="value">{{ selectedEvent.store || '无' }}</span> | |
| 74 | + </div> | |
| 75 | + <div class="row row--remark"> | |
| 76 | + <span class="label">备注</span> | |
| 77 | + <span class="value value--multiline">{{ selectedEvent.remark || '无' }}</span> | |
| 78 | + </div> | |
| 79 | + <div class="row row--attachments"> | |
| 80 | + <span class="label">附件</span> | |
| 81 | + <span class="value value--block"> | |
| 82 | + <template v-if="selectedEventAttachments.length"> | |
| 83 | + <div | |
| 84 | + v-for="(att, idx) in selectedEventAttachments" | |
| 85 | + :key="idx" | |
| 86 | + class="attachment-item" | |
| 87 | + :class="'attachment-item--' + getAttachmentFileType(att)" | |
| 88 | + > | |
| 89 | + <span class="attachment-icon-wrap"> | |
| 90 | + <i :class="'el-icon-' + getAttachmentIcon(att)"></i> | |
| 91 | + </span> | |
| 92 | + <span class="attachment-name" :title="att.name || att.fileName">{{ att.name || att.fileName || '附件' }}</span> | |
| 93 | + <a v-if="att.url" :href="att.url" target="_blank" rel="noopener" class="attachment-link"> | |
| 94 | + <i class="el-icon-download"></i> 下载 | |
| 95 | + </a> | |
| 96 | + <span v-else class="attachment-link attachment-link--disabled">下载</span> | |
| 97 | + </div> | |
| 98 | + </template> | |
| 99 | + <span v-else class="attachment-empty">无</span> | |
| 100 | + </span> | |
| 101 | + </div> | |
| 102 | + </div> | |
| 103 | + | |
| 104 | + <div class="footer"> | |
| 105 | + <el-button size="small" @click="detailVisible = false">关 闭</el-button> | |
| 106 | + </div> | |
| 107 | + </div> | |
| 108 | + </el-dialog> | |
| 42 | 109 | </el-dialog> |
| 43 | 110 | </template> |
| 44 | 111 | |
| ... | ... | @@ -67,11 +134,11 @@ export default { |
| 67 | 134 | // 当天 7 条示例(操作会 + 大会 + 活动),方便预览密集情况 |
| 68 | 135 | { id: 'E001', date: todayStr, start: `${todayStr}T09:00:00`, end: `${todayStr}T10:00:00`, type: 'ops', title: '操作会 - 紫荆店' }, |
| 69 | 136 | { id: 'E002', date: todayStr, start: `${todayStr}T10:30:00`, end: `${todayStr}T11:30:00`, type: 'ops', title: '操作会 - 西沙店' }, |
| 70 | - { id: 'E003', date: todayStr, start: `${todayStr}T13:00:00`, end: `${todayStr}T14:00:00`, type: 'training', title: '培训会 - 新员工入职营' }, | |
| 137 | + { id: 'E003', date: todayStr, start: `${todayStr}T13:00:00`, end: `${todayStr}T14:00:00`, type: 'training', title: '培训会 - 新员工入职营', remark: '请提前准备入职材料', attachments: [{ name: '入职手册.pdf' }, { name: '培训PPT.pptx' }] }, | |
| 71 | 138 | { id: 'E004', date: todayStr, start: `${todayStr}T14:30:00`, end: `${todayStr}T15:30:00`, type: 'training', title: '培训会 - 科美新品讲解' }, |
| 72 | - { id: 'E005', date: todayStr, start: `${todayStr}T16:00:00`, end: `${todayStr}T17:00:00`, type: 'activity', title: '门店活动 - 紫荆店会员沙龙' }, | |
| 139 | + { id: 'E005', date: todayStr, start: `${todayStr}T16:00:00`, end: `${todayStr}T17:00:00`, type: 'activity', title: '门店活动 - 紫荆店会员沙龙', remark: '需准备茶点与签到礼' }, | |
| 73 | 140 | { id: 'E006', date: todayStr, start: `${todayStr}T18:00:00`, end: `${todayStr}T19:00:00`, type: 'activity', title: '门店活动 - 西沙店体验日' }, |
| 74 | - { id: 'E007', date: todayStr, start: `${todayStr}T19:30:00`, end: `${todayStr}T21:00:00`, type: 'meeting', title: '全体大会 - 本月复盘' }, | |
| 141 | + { id: 'E007', date: todayStr, start: `${todayStr}T19:30:00`, end: `${todayStr}T21:00:00`, type: 'meeting', title: '全体大会 - 本月复盘', remark: '各店长携带本月数据报表', attachments: [{ name: '本月经营数据.xlsx' }, { name: '会议议程.pdf' }] }, | |
| 75 | 142 | // 其它日期:尽量覆盖当月大部分天 |
| 76 | 143 | { id: 'E008', date: `${y}-${m}-01`, start: `${y}-${m}-01T10:00:00`, end: `${y}-${m}-01T12:00:00`, type: 'meeting', title: '月初经营例会' }, |
| 77 | 144 | { id: 'E009', date: `${y}-${m}-02`, start: `${y}-${m}-02T14:00:00`, end: `${y}-${m}-02T16:00:00`, type: 'ops', title: '操作会 - 静居寺店' }, |
| ... | ... | @@ -92,7 +159,7 @@ export default { |
| 92 | 159 | { id: 'E024', date: `${y}-${m}-17`, start: `${y}-${m}-17T15:00:00`, end: `${y}-${m}-17T17:00:00`, type: 'activity', title: '门店活动 - 会员答谢宴' }, |
| 93 | 160 | { id: 'E025', date: `${y}-${m}-18`, start: `${y}-${m}-18T14:00:00`, end: `${y}-${m}-18T15:30:00`, type: 'training', title: '培训会 - 绩效与辅导' }, |
| 94 | 161 | { id: 'E026', date: `${y}-${m}-19`, start: `${y}-${m}-19T10:00:00`, end: `${y}-${m}-19T11:30:00`, type: 'ops', title: '操作会 - 门店联动' }, |
| 95 | - { id: 'E027', date: `${y}-${m}-20`, start: `${y}-${m}-20T09:30:00`, end: `${y}-${m}-20T11:30:00`, type: 'meeting', title: '季度全体大会' }, | |
| 162 | + { id: 'E027', date: `${y}-${m}-20`, start: `${y}-${m}-20T09:30:00`, end: `${y}-${m}-20T11:30:00`, type: 'meeting', title: '季度全体大会', remark: '全体参加,线上同步', attachments: [{ name: '季度总结报告.pdf' }, { name: '下季目标分解.xlsx' }] }, | |
| 96 | 163 | { id: 'E028', date: `${y}-${m}-21`, start: `${y}-${m}-21T19:00:00`, end: `${y}-${m}-21T20:30:00`, type: 'activity', title: '门店活动 - 新品体验会' }, |
| 97 | 164 | { id: 'E029', date: `${y}-${m}-22`, start: `${y}-${m}-22T10:00:00`, end: `${y}-${m}-22T11:30:00`, type: 'training', title: '培训会 - 工具使用' }, |
| 98 | 165 | { id: 'E030', date: `${y}-${m}-23`, start: `${y}-${m}-23T14:00:00`, end: `${y}-${m}-23T15:30:00`, type: 'ops', title: '操作会 - 区域复盘' }, |
| ... | ... | @@ -110,6 +177,8 @@ export default { |
| 110 | 177 | startTime: null, |
| 111 | 178 | endTime: null, |
| 112 | 179 | calendarHeight: 720, |
| 180 | + detailVisible: false, | |
| 181 | + selectedEvent: null, | |
| 113 | 182 | typeColorMap: { |
| 114 | 183 | ops: '#6366f1', |
| 115 | 184 | training: '#22c55e', |
| ... | ... | @@ -134,6 +203,27 @@ export default { |
| 134 | 203 | { type: 'meeting', label: '全体大会', color: this.typeColorMap.meeting }, |
| 135 | 204 | { type: 'activity', label: '门店活动', color: this.typeColorMap.activity } |
| 136 | 205 | ] |
| 206 | + }, | |
| 207 | + selectedEventTypeLabel() { | |
| 208 | + if (!this.selectedEvent || !this.selectedEvent.type) return '—' | |
| 209 | + const item = this.legendList.find(x => x.type === this.selectedEvent.type) | |
| 210 | + return item ? item.label : this.selectedEvent.type | |
| 211 | + }, | |
| 212 | + selectedEventDateStr() { | |
| 213 | + if (!this.selectedEvent || !this.selectedEvent.start) return '—' | |
| 214 | + return this.formatDate(new Date(this.selectedEvent.start)) | |
| 215 | + }, | |
| 216 | + selectedEventTimeRange() { | |
| 217 | + if (!this.selectedEvent || !this.selectedEvent.start) return '—' | |
| 218 | + const start = new Date(this.selectedEvent.start) | |
| 219 | + const end = this.selectedEvent.end ? new Date(this.selectedEvent.end) : start | |
| 220 | + const fmt = d => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` | |
| 221 | + return `${fmt(start)} - ${fmt(end)}` | |
| 222 | + }, | |
| 223 | + selectedEventAttachments() { | |
| 224 | + if (!this.selectedEvent || !this.selectedEvent.attachments) return [] | |
| 225 | + const list = Array.isArray(this.selectedEvent.attachments) ? this.selectedEvent.attachments : [] | |
| 226 | + return list.filter(a => a && (a.name || a.fileName)) | |
| 137 | 227 | } |
| 138 | 228 | }, |
| 139 | 229 | watch: { |
| ... | ... | @@ -196,41 +286,43 @@ export default { |
| 196 | 286 | this.loading = false |
| 197 | 287 | }, 200) |
| 198 | 288 | }, |
| 289 | + formatDate(d) { | |
| 290 | + const dt = d instanceof Date ? d : new Date(d) | |
| 291 | + const y = dt.getFullYear() | |
| 292 | + const m = String(dt.getMonth() + 1).padStart(2, '0') | |
| 293 | + const day = String(dt.getDate()).padStart(2, '0') | |
| 294 | + return `${y}-${m}-${day}` | |
| 295 | + }, | |
| 296 | + getAttachmentFileType(att) { | |
| 297 | + const name = (att.name || att.fileName || '').toLowerCase() | |
| 298 | + if (name.endsWith('.pdf')) return 'pdf' | |
| 299 | + if (name.endsWith('.xlsx') || name.endsWith('.xls')) return 'xlsx' | |
| 300 | + if (name.endsWith('.pptx') || name.endsWith('.ppt')) return 'pptx' | |
| 301 | + if (name.endsWith('.docx') || name.endsWith('.doc')) return 'doc' | |
| 302 | + if (name.endsWith('.png') || name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'img' | |
| 303 | + return 'default' | |
| 304 | + }, | |
| 305 | + getAttachmentIcon(att) { | |
| 306 | + const type = this.getAttachmentFileType(att) | |
| 307 | + if (type === 'img') return 'picture-outline' | |
| 308 | + return 'document' | |
| 309 | + }, | |
| 199 | 310 | handleEventClick(info) { |
| 200 | 311 | const evt = info && info.event |
| 201 | 312 | if (!evt) return |
| 202 | 313 | const raw = this.companyEvents.find(e => e.id === evt.id) || {} |
| 203 | - const start = evt.start | |
| 204 | - const end = evt.end | |
| 205 | - const fmt = d => { | |
| 206 | - if (!d) return '' | |
| 207 | - const hh = String(d.getHours()).padStart(2, '0') | |
| 208 | - const mm = String(d.getMinutes()).padStart(2, '0') | |
| 209 | - return `${hh}:${mm}` | |
| 314 | + this.selectedEvent = { | |
| 315 | + id: raw.id, | |
| 316 | + title: raw.title || evt.title, | |
| 317 | + type: (evt.extendedProps && evt.extendedProps.type) || raw.type, | |
| 318 | + start: raw.start, | |
| 319 | + end: raw.end, | |
| 320 | + desc: raw.desc, | |
| 321 | + store: raw.store, | |
| 322 | + remark: raw.remark, | |
| 323 | + attachments: raw.attachments ? [...raw.attachments] : [] | |
| 210 | 324 | } |
| 211 | - const timeRange = start ? `${fmt(start)} - ${fmt(end || start)}` : '' | |
| 212 | - const type = (evt.extendedProps && evt.extendedProps.type) || '' | |
| 213 | - const typeText = this.legendList.find(x => x.type === type)?.label || '安排' | |
| 214 | - const dateStr = start ? this.formatDate(start) : '' | |
| 215 | - const subject = raw.title || evt.title || '—' | |
| 216 | - const content = raw.desc || '—' | |
| 217 | - const otherParts = [] | |
| 218 | - if (raw.store) otherParts.push(`门店/地点:${raw.store}`) | |
| 219 | - otherParts.push(`类型:${typeText}`) | |
| 220 | - const otherText = otherParts.join(';') | |
| 221 | - | |
| 222 | - const html = ` | |
| 223 | - <div style="line-height:1.7;font-size:13px;color:#4b5563;"> | |
| 224 | - <div><strong>主题:</strong>${subject}</div> | |
| 225 | - <div><strong>内容:</strong>${content}</div> | |
| 226 | - <div><strong>时间:</strong>${dateStr} ${timeRange}</div> | |
| 227 | - <div><strong>其他:</strong>${otherText}</div> | |
| 228 | - </div> | |
| 229 | - ` | |
| 230 | - this.$alert(html, '日程详情', { | |
| 231 | - confirmButtonText: '知道了', | |
| 232 | - dangerouslyUseHTMLString: true | |
| 233 | - }) | |
| 325 | + this.detailVisible = true | |
| 234 | 326 | } |
| 235 | 327 | } |
| 236 | 328 | } |
| ... | ... | @@ -395,4 +487,216 @@ export default { |
| 395 | 487 | } |
| 396 | 488 | </style> |
| 397 | 489 | |
| 490 | +<style lang="scss" scoped> | |
| 491 | +/* 日程详情弹窗:与 booking-consume-detail-dialog 统一的结构与样式 */ | |
| 492 | +.detail-inner { | |
| 493 | + padding: 18px 22px 14px; | |
| 494 | +} | |
| 495 | + | |
| 496 | +.detail-inner .header { | |
| 497 | + display: flex; | |
| 498 | + align-items: center; | |
| 499 | + justify-content: space-between; | |
| 500 | + gap: 10px; | |
| 501 | + margin-bottom: 12px; | |
| 502 | + padding: 10px 14px; | |
| 503 | + border-radius: 14px; | |
| 504 | + background: rgba(219, 234, 254, 0.96); | |
| 505 | +} | |
| 506 | + | |
| 507 | +.detail-inner .title { | |
| 508 | + font-size: 17px; | |
| 509 | + font-weight: 600; | |
| 510 | + color: #0f172a; | |
| 511 | +} | |
| 512 | + | |
| 513 | +.detail-inner .close { | |
| 514 | + cursor: pointer; | |
| 515 | + width: 28px; | |
| 516 | + height: 28px; | |
| 517 | + display: flex; | |
| 518 | + align-items: center; | |
| 519 | + justify-content: center; | |
| 520 | + border-radius: 999px; | |
| 521 | + color: #64748b; | |
| 522 | + transition: all 0.15s; | |
| 523 | +} | |
| 524 | +.detail-inner .close:hover { | |
| 525 | + background: rgba(0, 0, 0, 0.06); | |
| 526 | + color: #0f172a; | |
| 527 | +} | |
| 528 | + | |
| 529 | +.detail-inner .body { | |
| 530 | + padding: 4px 0 16px; | |
| 531 | +} | |
| 532 | + | |
| 533 | +.detail-inner .row { | |
| 534 | + display: flex; | |
| 535 | + padding: 10px 0; | |
| 536 | + border-bottom: 1px solid #f1f5f9; | |
| 537 | + font-size: 14px; | |
| 538 | +} | |
| 539 | + | |
| 540 | +.detail-inner .label { | |
| 541 | + width: 96px; | |
| 542 | + color: #64748b; | |
| 543 | + flex-shrink: 0; | |
| 544 | +} | |
| 545 | + | |
| 546 | +.detail-inner .value { | |
| 547 | + flex: 1; | |
| 548 | + min-width: 0; | |
| 549 | + color: #111827; | |
| 550 | + white-space: normal; | |
| 551 | + overflow: visible; | |
| 552 | + text-overflow: clip; | |
| 553 | + line-height: 1.5; | |
| 554 | + word-break: break-all; | |
| 555 | +} | |
| 556 | + | |
| 557 | +.detail-inner .value--multiline { | |
| 558 | + white-space: pre-wrap; | |
| 559 | + word-break: break-word; | |
| 560 | +} | |
| 561 | + | |
| 562 | +.detail-inner .row--remark .value { | |
| 563 | + white-space: pre-wrap; | |
| 564 | + word-break: break-word; | |
| 565 | +} | |
| 566 | + | |
| 567 | +.detail-inner .row--attachments .value--block { | |
| 568 | + display: flex; | |
| 569 | + flex-direction: column; | |
| 570 | + gap: 10px; | |
| 571 | +} | |
| 572 | +.detail-inner .attachment-empty { | |
| 573 | + color: #94a3b8; | |
| 574 | + font-size: 13px; | |
| 575 | +} | |
| 576 | +.detail-inner .attachment-item { | |
| 577 | + display: flex; | |
| 578 | + align-items: center; | |
| 579 | + gap: 12px; | |
| 580 | + padding: 10px 12px; | |
| 581 | + border-radius: 12px; | |
| 582 | + background: #f8fafc; | |
| 583 | + border: 1px solid #e2e8f0; | |
| 584 | + border-left: 3px solid #94a3b8; | |
| 585 | + font-size: 13px; | |
| 586 | + transition: all 0.2s ease; | |
| 587 | + &:hover { | |
| 588 | + background: #f1f5f9; | |
| 589 | + border-color: #cbd5e1; | |
| 590 | + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); | |
| 591 | + } | |
| 592 | +} | |
| 593 | +.detail-inner .attachment-item--pdf { border-left-color: #dc2626; } | |
| 594 | +.detail-inner .attachment-item--xlsx { border-left-color: #16a34a; } | |
| 595 | +.detail-inner .attachment-item--pptx { border-left-color: #ea580c; } | |
| 596 | +.detail-inner .attachment-item--doc { border-left-color: #2563eb; } | |
| 597 | +.detail-inner .attachment-item--img { border-left-color: #7c3aed; } | |
| 598 | +.detail-inner .attachment-icon-wrap { | |
| 599 | + display: inline-flex; | |
| 600 | + align-items: center; | |
| 601 | + justify-content: center; | |
| 602 | + width: 32px; | |
| 603 | + height: 32px; | |
| 604 | + border-radius: 8px; | |
| 605 | + flex-shrink: 0; | |
| 606 | + .el-icon-document, | |
| 607 | + .el-icon-picture-outline { font-size: 16px; color: #fff; } | |
| 608 | +} | |
| 609 | +.detail-inner .attachment-item--pdf .attachment-icon-wrap { background: linear-gradient(135deg, #dc2626, #b91c1c); } | |
| 610 | +.detail-inner .attachment-item--xlsx .attachment-icon-wrap { background: linear-gradient(135deg, #16a34a, #15803d); } | |
| 611 | +.detail-inner .attachment-item--pptx .attachment-icon-wrap { background: linear-gradient(135deg, #ea580c, #c2410c); } | |
| 612 | +.detail-inner .attachment-item--doc .attachment-icon-wrap { background: linear-gradient(135deg, #2563eb, #1d4ed8); } | |
| 613 | +.detail-inner .attachment-item--img .attachment-icon-wrap { background: linear-gradient(135deg, #7c3aed, #6d28d9); } | |
| 614 | +.detail-inner .attachment-item--default .attachment-icon-wrap { background: linear-gradient(135deg, #64748b, #475569); } | |
| 615 | +.detail-inner .attachment-name { | |
| 616 | + flex: 1; | |
| 617 | + min-width: 0; | |
| 618 | + color: #334155; | |
| 619 | + font-weight: 500; | |
| 620 | + white-space: nowrap; | |
| 621 | + overflow: hidden; | |
| 622 | + text-overflow: ellipsis; | |
| 623 | +} | |
| 624 | +.detail-inner .attachment-link { | |
| 625 | + display: inline-flex; | |
| 626 | + align-items: center; | |
| 627 | + gap: 4px; | |
| 628 | + padding: 4px 10px; | |
| 629 | + border-radius: 8px; | |
| 630 | + background: rgba(37, 99, 235, 0.1); | |
| 631 | + color: #2563eb; | |
| 632 | + text-decoration: none; | |
| 633 | + font-size: 12px; | |
| 634 | + font-weight: 500; | |
| 635 | + flex-shrink: 0; | |
| 636 | + transition: background 0.2s; | |
| 637 | + .el-icon-download { font-size: 12px; } | |
| 638 | + &:hover { | |
| 639 | + background: rgba(37, 99, 235, 0.18); | |
| 640 | + color: #1d4ed8; | |
| 641 | + } | |
| 642 | + &.attachment-link--disabled { | |
| 643 | + cursor: default; | |
| 644 | + color: #94a3b8; | |
| 645 | + background: rgba(148, 163, 184, 0.15); | |
| 646 | + pointer-events: none; | |
| 647 | + &:hover { background: rgba(148, 163, 184, 0.15); color: #94a3b8; } | |
| 648 | + } | |
| 649 | +} | |
| 650 | + | |
| 651 | +.detail-inner .type-dot { | |
| 652 | + display: inline-block; | |
| 653 | + width: 8px; | |
| 654 | + height: 8px; | |
| 655 | + border-radius: 999px; | |
| 656 | + margin-right: 6px; | |
| 657 | + vertical-align: middle; | |
| 658 | +} | |
| 659 | + | |
| 660 | +.detail-inner .footer { | |
| 661 | + display: flex; | |
| 662 | + justify-content: flex-end; | |
| 663 | + gap: 10px; | |
| 664 | + padding-top: 12px; | |
| 665 | + border-top: 1px solid #f1f5f9; | |
| 666 | +} | |
| 667 | +</style> | |
| 668 | + | |
| 669 | +<style lang="scss" scoped> | |
| 670 | +::v-deep .company-calendar-detail-dialog { | |
| 671 | + max-width: 520px; | |
| 672 | + margin-top: 10vh !important; | |
| 673 | + border-radius: 20px; | |
| 674 | + padding: 0; | |
| 675 | + background: radial-gradient(circle at 0 0, rgba(255,255,255,0.96) 0, rgba(248,250,252,0.98) 40%, rgba(241,245,249,0.98) 100%); | |
| 676 | + box-shadow: 0 24px 48px rgba(15,23,42,0.18), 0 0 0 1px rgba(255,255,255,0.9); | |
| 677 | + backdrop-filter: blur(22px); | |
| 678 | + -webkit-backdrop-filter: blur(22px); | |
| 679 | +} | |
| 680 | +::v-deep .company-calendar-detail-dialog .el-dialog__header { display: none; } | |
| 681 | +::v-deep .company-calendar-detail-dialog .el-dialog__body { padding: 0; } | |
| 682 | +::v-deep .company-calendar-detail-dialog .el-button--primary, | |
| 683 | +::v-deep .company-calendar-detail-dialog .el-button--default { | |
| 684 | + border-radius: 999px; | |
| 685 | + padding: 0 18px; | |
| 686 | + height: 30px; | |
| 687 | + line-height: 30px; | |
| 688 | + font-size: 12px; | |
| 689 | +} | |
| 690 | +::v-deep .company-calendar-detail-dialog .el-button--default { | |
| 691 | + background: rgba(239, 246, 255, 0.9); | |
| 692 | + color: #2563eb; | |
| 693 | + border-color: rgba(37, 99, 235, 0.18); | |
| 694 | +} | |
| 695 | +::v-deep .company-calendar-detail-dialog .el-button--primary { | |
| 696 | + background: #2563eb; | |
| 697 | + border-color: #2563eb; | |
| 698 | + box-shadow: 0 4px 10px rgba(37, 99, 235, 0.35); | |
| 699 | +} | |
| 700 | +</style> | |
| 701 | + | |
| 398 | 702 | ... | ... |
store-pc/src/components/EmployeeScheduleDialog.vue
| ... | ... | @@ -31,11 +31,19 @@ |
| 31 | 31 | >{{ d.weekday }} {{ d.dateStr }}</span> |
| 32 | 32 | </div> |
| 33 | 33 | <div class="schedule-legend"> |
| 34 | - <span class="legend-item"><i class="legend-dot legend-dot--booked"></i>已预约</span> | |
| 35 | - <span class="legend-item"><i class="legend-dot legend-dot--confirmed"></i>已确认</span> | |
| 36 | - <span class="legend-item"><i class="legend-dot legend-dot--leave"></i>请假</span> | |
| 37 | - <span class="legend-item"><i class="legend-dot legend-dot--vacation"></i>休假</span> | |
| 38 | - <span class="legend-item"><i class="legend-dot legend-dot--rest"></i>休息</span> | |
| 34 | + <span class="legend-group"> | |
| 35 | + <span class="legend-group__title">预约状态</span> | |
| 36 | + <span class="legend-item"><i class="legend-dot legend-dot--booked"></i>已预约</span> | |
| 37 | + <span class="legend-item"><i class="legend-dot legend-dot--serving"></i>服务中</span> | |
| 38 | + <span class="legend-item"><i class="legend-dot legend-dot--completed"></i>已完成</span> | |
| 39 | + </span> | |
| 40 | + <span class="legend-divider"></span> | |
| 41 | + <span class="legend-group"> | |
| 42 | + <span class="legend-group__title">人事状态</span> | |
| 43 | + <span class="legend-item"><i class="legend-dot legend-dot--leave"></i>请假</span> | |
| 44 | + <span class="legend-item"><i class="legend-dot legend-dot--vacation"></i>休假</span> | |
| 45 | + </span> | |
| 46 | + <span class="legend-divider"></span> | |
| 39 | 47 | <span class="legend-item"><i class="legend-dot legend-dot--slot"></i>30分钟/格</span> |
| 40 | 48 | </div> |
| 41 | 49 | </div> |
| ... | ... | @@ -103,34 +111,6 @@ |
| 103 | 111 | </div> |
| 104 | 112 | </div> |
| 105 | 113 | |
| 106 | - <!-- 预约详情弹窗:风格与主弹窗统一(旧预约 mock) --> | |
| 107 | - <el-dialog | |
| 108 | - :visible.sync="detailDialogVisible" | |
| 109 | - width="440px" | |
| 110 | - append-to-body | |
| 111 | - custom-class="schedule-detail-dialog" | |
| 112 | - :show-close="false" | |
| 113 | - :close-on-click-modal="false" | |
| 114 | - > | |
| 115 | - <div v-if="selectedBooking" class="booking-detail-inner"> | |
| 116 | - <div class="booking-detail-header"> | |
| 117 | - <div class="booking-detail-title">预约详情</div> | |
| 118 | - <span class="booking-detail-close" @click="detailDialogVisible = false"><i class="el-icon-close"></i></span> | |
| 119 | - </div> | |
| 120 | - <div class="booking-detail-body"> | |
| 121 | - <div class="detail-row"><span class="label">会员姓名</span><span>{{ selectedBooking.customerName }}</span></div> | |
| 122 | - <div class="detail-row"><span class="label">预约时间</span><span>{{ selectedBooking.timeRange }}</span></div> | |
| 123 | - <div class="detail-row"><span class="label">预约项目</span><span>{{ selectedBooking.project || '—' }}</span></div> | |
| 124 | - <div class="detail-row"><span class="label">状态</span><span :class="'status-' + (selectedBooking.status === '已确认' ? 'confirmed' : 'booked')">{{ selectedBooking.status }}</span></div> | |
| 125 | - <div class="detail-row" v-if="selectedBooking.remark"><span class="label">备注</span><span>{{ selectedBooking.remark }}</span></div> | |
| 126 | - </div> | |
| 127 | - <div class="booking-detail-footer"> | |
| 128 | - <el-button size="small" @click="detailDialogVisible = false">关 闭</el-button> | |
| 129 | - <el-button type="primary" size="small" @click="openEditBooking">编 辑</el-button> | |
| 130 | - </div> | |
| 131 | - </div> | |
| 132 | - </el-dialog> | |
| 133 | - | |
| 134 | 114 | <!-- 添加预约:复用新建预约弹窗,仅默认健康师、日期、时间 --> |
| 135 | 115 | <BookingDialog |
| 136 | 116 | :visible.sync="addBookingDialogVisible" |
| ... | ... | @@ -143,15 +123,16 @@ |
| 143 | 123 | :visible.sync="preConsumeDetailVisible" |
| 144 | 124 | :booking="selectedPreConsumeBooking" |
| 145 | 125 | @cancel="handlePreConsumeCancel" |
| 126 | + @edit="handlePreConsumeEdit" | |
| 146 | 127 | @start="handlePreConsumeStart" |
| 147 | 128 | @convert="handlePreConsumeConvert" |
| 148 | 129 | /> |
| 149 | 130 | |
| 150 | - <!-- 转消耗:复用现有 ConsumeDialog(仅 mock 预填) --> | |
| 151 | - <consume-dialog | |
| 152 | - :visible.sync="consumeDialogVisible" | |
| 153 | - :prefill="consumePrefill" | |
| 154 | - @submitted="handleConsumeSubmitted" | |
| 131 | + <!-- 修改预约:复用预约消耗单弹窗,编辑同一条记录 --> | |
| 132 | + <booking-consume-dialog | |
| 133 | + :visible.sync="bookingConsumeDialogVisible" | |
| 134 | + :prefill="bookingConsumePrefill" | |
| 135 | + @saved="handlePreConsumeBookingUpdated" | |
| 155 | 136 | /> |
| 156 | 137 | </el-dialog> |
| 157 | 138 | </template> |
| ... | ... | @@ -159,13 +140,14 @@ |
| 159 | 140 | <script> |
| 160 | 141 | import BookingDialog from '@/components/BookingDialog.vue' |
| 161 | 142 | import BookingConsumeDetailDialog from '@/components/booking-consume-detail-dialog.vue' |
| 162 | -import ConsumeDialog from '@/components/ConsumeDialog.vue' | |
| 143 | +import BookingConsumeDialog from '@/components/booking-consume-dialog.vue' | |
| 163 | 144 | |
| 164 | 145 | const PRE_CONSUME_STORAGE_KEY = 'store_pc_pre_consume_bookings' |
| 146 | +const CONSUME_PREFILL_ONCE_KEY = 'store_pc_consume_prefill_once' | |
| 165 | 147 | |
| 166 | 148 | export default { |
| 167 | 149 | name: 'EmployeeScheduleDialog', |
| 168 | - components: { BookingDialog, BookingConsumeDetailDialog, ConsumeDialog }, | |
| 150 | + components: { BookingDialog, BookingConsumeDetailDialog, BookingConsumeDialog }, | |
| 169 | 151 | props: { |
| 170 | 152 | visible: { type: Boolean, default: false }, |
| 171 | 153 | openMode: { type: String, default: 'view' } |
| ... | ... | @@ -174,17 +156,15 @@ export default { |
| 174 | 156 | return { |
| 175 | 157 | weekStart: null, |
| 176 | 158 | selectedDay: null, |
| 177 | - detailDialogVisible: false, | |
| 178 | 159 | addBookingDialogVisible: false, |
| 179 | - selectedBooking: null, | |
| 180 | 160 | addBookingPrefill: {}, |
| 181 | 161 | dragState: null, |
| 182 | 162 | preConsumeBookings: [], |
| 183 | 163 | activePreConsumeId: '', |
| 184 | 164 | preConsumeDetailVisible: false, |
| 185 | 165 | selectedPreConsumeBooking: null, |
| 186 | - consumeDialogVisible: false, | |
| 187 | - consumePrefill: {}, | |
| 166 | + bookingConsumeDialogVisible: false, | |
| 167 | + bookingConsumePrefill: {}, | |
| 188 | 168 | employeeList: [ |
| 189 | 169 | { id: 'E001', name: '董顺秀', role: '健康师' }, |
| 190 | 170 | { id: 'E002', name: '张丽', role: '健康师' }, |
| ... | ... | @@ -202,26 +182,8 @@ export default { |
| 202 | 182 | }, |
| 203 | 183 | // 员工当日状态:key = employeeId_dayOffset, value = { type: 'normal'|'leave'|'vacation'|'rest', text, slotStart?, slotEnd? } |
| 204 | 184 | // slotStart/slotEnd 为可选,表示半天请假/休假的时段(slot 索引 0-47) |
| 205 | - employeeDayStatus: { | |
| 206 | - 'E003_0': { type: 'leave', text: '事假' }, | |
| 207 | - 'E002_1': { type: 'vacation', text: '年假' }, | |
| 208 | - 'E004_1': { type: 'leave', text: '事假(半天)', slotStart: 18, slotEnd: 24 } | |
| 209 | - }, | |
| 210 | - bookings: [ | |
| 211 | - { id: 'B1', employeeId: 'E001', dayOffset: 0, slotStart: 18, slotEnd: 20, customerName: '刘泽蓉', customerPhone: '159****8353', project: '美拉-面部', status: '已确认', remark: '' }, | |
| 212 | - { id: 'B2', employeeId: 'E001', dayOffset: 0, slotStart: 26, slotEnd: 30, customerName: '林晓薇', customerPhone: '138****8801', project: '逆龄胶原-眼部', status: '已预约', remark: '' }, | |
| 213 | - { id: 'B3', employeeId: 'E001', dayOffset: 0, slotStart: 34, slotEnd: 36, customerName: '陈美玲', customerPhone: '139****2233', project: '生命之波', status: '已确认', remark: '' }, | |
| 214 | - { id: 'B4', employeeId: 'E001', dayOffset: 1, slotStart: 18, slotEnd: 20, customerName: '张芳芳', status: '已预约', project: '面部护理' }, | |
| 215 | - { id: 'B5', employeeId: 'E001', dayOffset: 1, slotStart: 22, slotEnd: 24, customerName: '王晓燕', status: '已预约', project: '眼周护理' }, | |
| 216 | - { id: 'B6', employeeId: 'E002', dayOffset: 0, slotStart: 20, slotEnd: 22, customerName: '李明珠', status: '已确认', project: '精雕' }, | |
| 217 | - { id: 'B7', employeeId: 'E002', dayOffset: 0, slotStart: 28, slotEnd: 32, customerName: '周丽', status: '已预约', project: '微雕-面部' }, | |
| 218 | - { id: 'B8', employeeId: 'E002', dayOffset: 2, slotStart: 18, slotEnd: 22, customerName: '赵美华', status: '已确认', project: '逆龄胶原' }, | |
| 219 | - { id: 'B9', employeeId: 'E003', dayOffset: 0, slotStart: 18, slotEnd: 19, customerName: '罗建琼', status: '已确认', project: '美拉-面部' }, | |
| 220 | - { id: 'B10', employeeId: 'E003', dayOffset: 0, slotStart: 30, slotEnd: 34, customerName: '范佳佳', status: '已预约', project: 'CELL神经' }, | |
| 221 | - { id: 'B11', employeeId: 'E003', dayOffset: 0, slotStart: 34, slotEnd: 36, customerName: '黄仕碧', status: '已预约', project: '生命之波' }, | |
| 222 | - { id: 'B12', employeeId: 'E004', dayOffset: 0, slotStart: 28, slotEnd: 30, customerName: '王英', status: '已预约', project: '面部护理' }, | |
| 223 | - { id: 'B13', employeeId: 'E004', dayOffset: 3, slotStart: 20, slotEnd: 24, customerName: '沈丽', status: '已确认', project: '眼周护理' } | |
| 224 | - ] | |
| 185 | + employeeDayStatus: {}, | |
| 186 | + bookings: [] | |
| 225 | 187 | } |
| 226 | 188 | }, |
| 227 | 189 | computed: { |
| ... | ... | @@ -326,8 +288,25 @@ export default { |
| 326 | 288 | mounted() { |
| 327 | 289 | this.initWeek() |
| 328 | 290 | this.loadPreConsumeBookings() |
| 291 | + this._preConsumeUpdatedHandler = () => { | |
| 292 | + // 同一页面内 localStorage 更新不会触发 storage 事件,用自定义事件做刷新 | |
| 293 | + if (this.visibleProxy) this.loadPreConsumeBookings() | |
| 294 | + } | |
| 295 | + try { | |
| 296 | + window.addEventListener('store_pc_pre_consume_bookings_updated', this._preConsumeUpdatedHandler) | |
| 297 | + } catch (e) {} | |
| 298 | + }, | |
| 299 | + beforeDestroy() { | |
| 300 | + try { | |
| 301 | + this._preConsumeUpdatedHandler && window.removeEventListener('store_pc_pre_consume_bookings_updated', this._preConsumeUpdatedHandler) | |
| 302 | + } catch (e) {} | |
| 329 | 303 | }, |
| 330 | 304 | methods: { |
| 305 | + bookingStatusClass(status) { | |
| 306 | + if (status === '服务中') return 'serving' | |
| 307 | + if (status === '已完成') return 'completed' | |
| 308 | + return 'booked' | |
| 309 | + }, | |
| 331 | 310 | readPreConsumeStorage() { |
| 332 | 311 | try { |
| 333 | 312 | const raw = localStorage.getItem(PRE_CONSUME_STORAGE_KEY) |
| ... | ... | @@ -343,7 +322,124 @@ export default { |
| 343 | 322 | }, |
| 344 | 323 | loadPreConsumeBookings() { |
| 345 | 324 | const list = this.readPreConsumeStorage() |
| 325 | + if (!list || list.length === 0) { | |
| 326 | + const seeded = this.buildSamplePreConsumeBookings() | |
| 327 | + this.writePreConsumeStorage(seeded) | |
| 328 | + this.preConsumeBookings = seeded | |
| 329 | + this.seedEmployeeDayStatus() | |
| 330 | + return | |
| 331 | + } | |
| 346 | 332 | this.preConsumeBookings = list |
| 333 | + this.seedEmployeeDayStatus() | |
| 334 | + }, | |
| 335 | + seedEmployeeDayStatus() { | |
| 336 | + // 仅作为示例数据:区分“人事状态(请假/休假)”与“预约状态(已预约/服务中/已完成)” | |
| 337 | + // key = employeeId_dayOffset | |
| 338 | + this.employeeDayStatus = { | |
| 339 | + // 3/8(本周 dayOffset=0)示例:E003 请假(整天) | |
| 340 | + 'E003_0': { type: 'leave', text: '请假' }, | |
| 341 | + // 3/9(dayOffset=1)示例:E002 休假(整天) | |
| 342 | + 'E002_1': { type: 'vacation', text: '休假' }, | |
| 343 | + // 3/8(dayOffset=0)示例:E004 半天请假(09:00-12:00) | |
| 344 | + 'E004_0': { type: 'leave', text: '请假(半天)', slotStart: 18, slotEnd: 24 } | |
| 345 | + } | |
| 346 | + }, | |
| 347 | + buildSamplePreConsumeBookings() { | |
| 348 | + // 按当前周(weekStart)生成示例预约消耗单,确保 3/8(dayOffset=0)有数据 | |
| 349 | + const fmtDate = (d) => { | |
| 350 | + const y = d.getFullYear() | |
| 351 | + const m = String(d.getMonth() + 1).padStart(2, '0') | |
| 352 | + const day = String(d.getDate()).padStart(2, '0') | |
| 353 | + return `${y}-${m}-${day}` | |
| 354 | + } | |
| 355 | + const day0 = new Date(this.weekStart) | |
| 356 | + const day1 = new Date(this.weekStart); day1.setDate(day1.getDate() + 1) | |
| 357 | + const day2 = new Date(this.weekStart); day2.setDate(day2.getDate() + 2) | |
| 358 | + const d0 = fmtDate(day0) | |
| 359 | + const d1 = fmtDate(day1) | |
| 360 | + const d2 = fmtDate(day2) | |
| 361 | + | |
| 362 | + const mk = (id, date, startTime, endTime, memberName, roomName, therapistIds, status, colorKey, items) => ({ | |
| 363 | + id, | |
| 364 | + memberId: 'cust_demo', | |
| 365 | + memberName, | |
| 366 | + date, | |
| 367 | + startTime, | |
| 368 | + endTime, | |
| 369 | + roomId: 'R001', | |
| 370 | + roomName, | |
| 371 | + therapistIds, | |
| 372 | + therapistNames: therapistIds.map(tid => this.employeeList.find(e => e.id === tid)?.name).filter(Boolean), | |
| 373 | + status, | |
| 374 | + colorKey, | |
| 375 | + remark: '', | |
| 376 | + itemLabels: (items || []).map(x => `${x.label}×${x.count}`), | |
| 377 | + items | |
| 378 | + }) | |
| 379 | + | |
| 380 | + return [ | |
| 381 | + // 3/8:已预约(多健康师) | |
| 382 | + mk( | |
| 383 | + 'PCB_DEMO_0308_01', | |
| 384 | + d0, | |
| 385 | + '09:00', | |
| 386 | + '10:30', | |
| 387 | + '林小纤', | |
| 388 | + '1号房', | |
| 389 | + ['E001', 'E002'], | |
| 390 | + 'booked', | |
| 391 | + 'blue', | |
| 392 | + [ | |
| 393 | + { projectId: 'item001', label: '面部深层护理(次卡)', count: 1, workers: [{ workerId: 'E001' }] }, | |
| 394 | + { projectId: 'item003', label: '眼周护理套餐', count: 1, workers: [{ workerId: 'E002' }] } | |
| 395 | + ] | |
| 396 | + ), | |
| 397 | + // 3/8:服务中 | |
| 398 | + mk( | |
| 399 | + 'PCB_DEMO_0308_02', | |
| 400 | + d0, | |
| 401 | + '14:00', | |
| 402 | + '15:30', | |
| 403 | + '王丽', | |
| 404 | + 'VIP房', | |
| 405 | + ['E001'], | |
| 406 | + 'serving', | |
| 407 | + 'orange', | |
| 408 | + [ | |
| 409 | + { projectId: 'item002', label: '肩颈调理(疗程)', count: 2, workers: [{ workerId: 'E001' }] } | |
| 410 | + ] | |
| 411 | + ), | |
| 412 | + // 3/9:已完成 | |
| 413 | + mk( | |
| 414 | + 'PCB_DEMO_0309_01', | |
| 415 | + d1, | |
| 416 | + '11:00', | |
| 417 | + '12:00', | |
| 418 | + '张敏', | |
| 419 | + '2号房', | |
| 420 | + ['E004'], | |
| 421 | + 'converted', | |
| 422 | + 'green', | |
| 423 | + [ | |
| 424 | + { projectId: 'item001', label: '面部深层护理(次卡)', count: 1, workers: [{ workerId: 'E004' }] } | |
| 425 | + ] | |
| 426 | + ), | |
| 427 | + // 3/10:已预约 | |
| 428 | + mk( | |
| 429 | + 'PCB_DEMO_0310_01', | |
| 430 | + d2, | |
| 431 | + '16:00', | |
| 432 | + '17:00', | |
| 433 | + '赵美华', | |
| 434 | + '3号房', | |
| 435 | + ['E002', 'E004'], | |
| 436 | + 'booked', | |
| 437 | + 'purple', | |
| 438 | + [ | |
| 439 | + { projectId: 'item003', label: '眼周护理套餐', count: 1, workers: [{ workerId: 'E002' }, { workerId: 'E004' }] } | |
| 440 | + ] | |
| 441 | + ) | |
| 442 | + ] | |
| 347 | 443 | }, |
| 348 | 444 | findPreConsumeForSlot(empId, date, slotIndex) { |
| 349 | 445 | const list = this.preConsumeBookingsWithDate |
| ... | ... | @@ -358,7 +454,7 @@ export default { |
| 358 | 454 | preConsumeStatusClass(status) { |
| 359 | 455 | const s = status || 'booked' |
| 360 | 456 | if (s === 'serving') return 'slot--preconsume-serving' |
| 361 | - if (s === 'converted') return 'slot--preconsume-converted' | |
| 457 | + if (s === 'converted') return 'slot--preconsume-converted' // 已完成(示例:转消耗后) | |
| 362 | 458 | if (s === 'cancelled') return 'slot--preconsume-cancelled' |
| 363 | 459 | return 'slot--preconsume-booked' |
| 364 | 460 | }, |
| ... | ... | @@ -441,11 +537,16 @@ export default { |
| 441 | 537 | const pcb = this.findPreConsumeForSlot(empId, date, slotIndex) |
| 442 | 538 | if (pcb) { |
| 443 | 539 | let c = `slot--preconsume ${this.preConsumeStatusClass(pcb.status)} slot--preconsume-color-${pcb.colorKey || 'blue'}` |
| 444 | - if (slotIndex === pcb.slotStart) c += ' slot--resize-start' | |
| 445 | - if (slotIndex === pcb.slotEnd - 1) c += ' slot--resize-end' | |
| 540 | + if (slotIndex === pcb.slotStart) c += ' slot--resize-start slot--preconsume-edge' | |
| 541 | + if (slotIndex === pcb.slotEnd - 1) c += ' slot--resize-end slot--preconsume-edge' | |
| 446 | 542 | if (this.activePreConsumeId) { |
| 447 | - if (pcb.id === this.activePreConsumeId) c += ' slot--same-active' | |
| 448 | - else c += ' slot--dimmed' | |
| 543 | + // 仅对“其他单子”做淡化;当前选中的同单保持正常显示 | |
| 544 | + if (pcb.id === this.activePreConsumeId) { | |
| 545 | + // 仅在块两端加高亮描边,避免整块出现密集分割线 | |
| 546 | + if (slotIndex === pcb.slotStart || slotIndex === pcb.slotEnd - 1) c += ' slot--same-active' | |
| 547 | + } else { | |
| 548 | + c += ' slot--dimmed' | |
| 549 | + } | |
| 449 | 550 | } |
| 450 | 551 | return c |
| 451 | 552 | } |
| ... | ... | @@ -456,7 +557,9 @@ export default { |
| 456 | 557 | slotIndex < x.slotEnd |
| 457 | 558 | ) |
| 458 | 559 | if (b) { |
| 459 | - let c = b.status === '已确认' ? 'slot--confirmed' : 'slot--booked' | |
| 560 | + let c = 'slot--booked' | |
| 561 | + if (b.status === '服务中') c = 'slot--serving' | |
| 562 | + else if (b.status === '已完成') c = 'slot--completed' | |
| 460 | 563 | if (slotIndex === b.slotStart) c += ' slot--resize-start' |
| 461 | 564 | if (slotIndex === b.slotEnd - 1) c += ' slot--resize-end' |
| 462 | 565 | return c |
| ... | ... | @@ -490,25 +593,61 @@ export default { |
| 490 | 593 | if (list.length === 0) return '当日无预约 · 点击日期查看详情' |
| 491 | 594 | return list.map(x => `${x.customerName} ${x.timeRange}`).join('\n') + '\n点击日期查看详情' |
| 492 | 595 | }, |
| 493 | - openEditBooking() { | |
| 494 | - if (!this.selectedBooking) return | |
| 495 | - const b = this.selectedBooking | |
| 496 | - const [startStr] = (b.timeRange || '').split('-') | |
| 497 | - this.addBookingPrefill = { | |
| 498 | - yyjks: b.employeeId, | |
| 499 | - yyrq: new Date(b.date + 'T12:00:00'), | |
| 500 | - yysj: startStr ? startStr.trim() : '', | |
| 501 | - yysjEnd: (b.timeRange || '').split('-')[1]?.trim() || '', | |
| 502 | - editBookingId: b.id, | |
| 503 | - gkxm: b.customerName, | |
| 504 | - yytyxm: b.project | |
| 505 | - } | |
| 506 | - this.detailDialogVisible = false | |
| 507 | - this.addBookingDialogVisible = true | |
| 596 | + toSlotIndex(timeStr) { | |
| 597 | + if (!timeStr) return null | |
| 598 | + const [h, m] = timeStr.split(':').map(Number) | |
| 599 | + if (Number.isNaN(h) || Number.isNaN(m)) return null | |
| 600 | + return h * 2 + (m >= 30 ? 1 : 0) | |
| 508 | 601 | }, |
| 509 | 602 | handleSlotMouseDown(e, emp, date, slotIndex) { |
| 510 | 603 | const pcb = this.findPreConsumeForSlot(emp.id, date, slotIndex) |
| 511 | 604 | if (pcb) { |
| 605 | + // 仅允许从两端拖动调整时长;中间点击仅查看详情 | |
| 606 | + const cls = this.slotClass(emp.id, date, slotIndex) | |
| 607 | + const isResizeStart = cls.includes('slot--resize-start') | |
| 608 | + const isResizeEnd = cls.includes('slot--resize-end') | |
| 609 | + if (this.selectedDay && (isResizeStart || isResizeEnd)) { | |
| 610 | + const edge = isResizeStart ? 'start' : 'end' | |
| 611 | + this.dragState = { type: 'preconsume_resize', preId: pcb.id, edge, employeeId: emp.id, date } | |
| 612 | + const originalStart = pcb.startTime | |
| 613 | + const originalEnd = pcb.endTime | |
| 614 | + const onMove = (ev) => { | |
| 615 | + const slotEl = document.elementFromPoint(ev.clientX, ev.clientY)?.closest('.slot') | |
| 616 | + if (!slotEl) return | |
| 617 | + const row = slotEl.closest('.schedule-row') | |
| 618 | + const empName = row?.querySelector('.employee-name')?.textContent | |
| 619 | + const empObj = this.employeeList.find(e => e.name === empName) | |
| 620 | + if (!empObj || empObj.id !== this.dragState.employeeId) return | |
| 621 | + const idx = Array.from(slotEl.parentElement?.children || []).indexOf(slotEl) | |
| 622 | + if (idx < 0 || idx >= 48) return | |
| 623 | + | |
| 624 | + const current = (this.preConsumeBookingsWithDate || []).find(x => x.id === this.dragState.preId) | |
| 625 | + if (!current) return | |
| 626 | + const startSlot = this.toSlotIndex(current.startTime) ?? current.slotStart | |
| 627 | + const endSlot = this.toSlotIndex(current.endTime) ?? current.slotEnd | |
| 628 | + if (this.dragState.edge === 'start') { | |
| 629 | + const newStart = Math.min(idx, (endSlot || 1) - 1) | |
| 630 | + const startTime = this.formatSlotTime(Math.max(newStart, 0)) | |
| 631 | + this.updatePreConsumeRecord({ id: current.id, startTime }) | |
| 632 | + } else { | |
| 633 | + const newEnd = Math.max(idx + 1, (startSlot || 0) + 1) | |
| 634 | + const endTime = this.formatSlotTime(Math.min(newEnd, 48)) | |
| 635 | + this.updatePreConsumeRecord({ id: current.id, endTime }) | |
| 636 | + } | |
| 637 | + } | |
| 638 | + const onUp = () => { | |
| 639 | + document.removeEventListener('mousemove', onMove) | |
| 640 | + document.removeEventListener('mouseup', onUp) | |
| 641 | + this.dragState = null | |
| 642 | + // 若未发生变化,保持不弹窗 | |
| 643 | + const cur = (this.preConsumeBookings || []).find(x => x.id === pcb.id) | |
| 644 | + if (!cur || (cur.startTime === originalStart && cur.endTime === originalEnd)) return | |
| 645 | + } | |
| 646 | + document.addEventListener('mousemove', onMove) | |
| 647 | + document.addEventListener('mouseup', onUp) | |
| 648 | + return | |
| 649 | + } | |
| 650 | + | |
| 512 | 651 | this.activePreConsumeId = pcb.id |
| 513 | 652 | this.selectedPreConsumeBooking = pcb |
| 514 | 653 | this.preConsumeDetailVisible = true |
| ... | ... | @@ -522,10 +661,6 @@ export default { |
| 522 | 661 | ) |
| 523 | 662 | // 周视图下允许点击块直接查看详情,不支持拖动修改/新建 |
| 524 | 663 | if (!this.selectedDay) { |
| 525 | - if (b) { | |
| 526 | - this.selectedBooking = b | |
| 527 | - this.detailDialogVisible = true | |
| 528 | - } | |
| 529 | 664 | return |
| 530 | 665 | } |
| 531 | 666 | if (b) { |
| ... | ... | @@ -677,6 +812,17 @@ export default { |
| 677 | 812 | this.preConsumeBookings = list |
| 678 | 813 | return updated |
| 679 | 814 | }, |
| 815 | + updatePreConsumeRecord(record) { | |
| 816 | + if (!record || !record.id) return null | |
| 817 | + const list = this.readPreConsumeStorage() | |
| 818 | + const idx = list.findIndex(x => x && x.id === record.id) | |
| 819 | + if (idx < 0) return null | |
| 820 | + const updated = { ...list[idx], ...record } | |
| 821 | + list.splice(idx, 1, updated) | |
| 822 | + this.writePreConsumeStorage(list) | |
| 823 | + this.preConsumeBookings = list | |
| 824 | + return updated | |
| 825 | + }, | |
| 680 | 826 | handlePreConsumeCancel(b) { |
| 681 | 827 | if (!b) return |
| 682 | 828 | this.$confirm(`确定取消「${b.memberName || '该会员'}」在 ${b.date} ${b.startTime}-${b.endTime} 的预约吗?`, '取消预约确认', { |
| ... | ... | @@ -695,30 +841,41 @@ export default { |
| 695 | 841 | if (updated) this.selectedPreConsumeBooking = updated |
| 696 | 842 | this.$message.success('已开始服务') |
| 697 | 843 | }, |
| 844 | + handlePreConsumeEdit(b) { | |
| 845 | + if (!b) return | |
| 846 | + this.bookingConsumePrefill = { ...b } | |
| 847 | + this.preConsumeDetailVisible = false | |
| 848 | + this.$nextTick(() => { | |
| 849 | + this.bookingConsumeDialogVisible = true | |
| 850 | + }) | |
| 851 | + }, | |
| 852 | + handlePreConsumeBookingUpdated(record) { | |
| 853 | + const updated = this.updatePreConsumeRecord(record) || record | |
| 854 | + this.selectedPreConsumeBooking = updated | |
| 855 | + this.bookingConsumeDialogVisible = false | |
| 856 | + this.$message.success('预约消耗单已更新') | |
| 857 | + }, | |
| 698 | 858 | handlePreConsumeConvert(b) { |
| 699 | 859 | if (!b) return |
| 700 | 860 | const updated = this.updatePreConsumeStatus(b.id, 'converted') || b |
| 701 | 861 | this.selectedPreConsumeBooking = updated |
| 702 | 862 | this.preConsumeDetailVisible = false |
| 703 | - | |
| 704 | - this.consumePrefill = { | |
| 863 | + const prefillOnce = { | |
| 705 | 864 | memberId: updated.memberId, |
| 706 | 865 | name: updated.memberName, |
| 707 | 866 | consumeDate: updated.date, |
| 708 | 867 | remark: `转自预约消耗单:${updated.id}${updated.remark ? ' · ' + updated.remark : ''}`, |
| 709 | - therapistIds: updated.therapistIds, | |
| 868 | + therapistIds: Array.from(new Set([].concat(updated.therapistIds || []))).filter(Boolean), | |
| 710 | 869 | items: (updated.items || []).map(x => ({ |
| 711 | 870 | projectId: x.projectId, |
| 712 | 871 | label: x.label, |
| 713 | 872 | count: (x.count != null ? x.count : x.qty) || 1 |
| 714 | 873 | })) |
| 715 | 874 | } |
| 716 | - this.consumeDialogVisible = true | |
| 717 | - }, | |
| 718 | - handleConsumeSubmitted() { | |
| 719 | - // mock:提交后保持 converted 状态即可 | |
| 720 | - this.consumeDialogVisible = false | |
| 721 | - this.$message.success('已完成转消耗(示例)') | |
| 875 | + try { | |
| 876 | + localStorage.setItem(CONSUME_PREFILL_ONCE_KEY, JSON.stringify(prefillOnce)) | |
| 877 | + } catch (e) {} | |
| 878 | + this.$router.push({ path: '/dashboard', query: { openConsume: '1' } }) | |
| 722 | 879 | } |
| 723 | 880 | } |
| 724 | 881 | } |
| ... | ... | @@ -862,9 +1019,33 @@ export default { |
| 862 | 1019 | .schedule-legend { |
| 863 | 1020 | display: flex; |
| 864 | 1021 | align-items: center; |
| 865 | - gap: 16px; | |
| 1022 | + gap: 14px; | |
| 866 | 1023 | font-size: 12px; |
| 867 | 1024 | color: #64748b; |
| 1025 | + flex-wrap: wrap; | |
| 1026 | +} | |
| 1027 | + | |
| 1028 | +.legend-group { | |
| 1029 | + display: inline-flex; | |
| 1030 | + align-items: center; | |
| 1031 | + gap: 12px; | |
| 1032 | + padding: 6px 10px; | |
| 1033 | + border-radius: 10px; | |
| 1034 | + background: rgba(241, 245, 249, 0.75); | |
| 1035 | +} | |
| 1036 | + | |
| 1037 | +.legend-group__title { | |
| 1038 | + font-size: 12px; | |
| 1039 | + font-weight: 600; | |
| 1040 | + color: #475569; | |
| 1041 | + margin-right: 2px; | |
| 1042 | + white-space: nowrap; | |
| 1043 | +} | |
| 1044 | + | |
| 1045 | +.legend-divider { | |
| 1046 | + width: 1px; | |
| 1047 | + height: 18px; | |
| 1048 | + background: rgba(226, 232, 240, 0.9); | |
| 868 | 1049 | } |
| 869 | 1050 | |
| 870 | 1051 | .legend-item { |
| ... | ... | @@ -881,10 +1062,10 @@ export default { |
| 881 | 1062 | } |
| 882 | 1063 | |
| 883 | 1064 | .legend-dot--booked { background: #3b82f6; } |
| 884 | -.legend-dot--confirmed { background: #22c55e; } | |
| 885 | -.legend-dot--leave { background: #f97316; } | |
| 1065 | +.legend-dot--serving { background: #f97316; } | |
| 1066 | +.legend-dot--completed { background: #22c55e; } | |
| 1067 | +.legend-dot--leave { background: #94a3b8; } | |
| 886 | 1068 | .legend-dot--vacation { background: #8b5cf6; } |
| 887 | -.legend-dot--rest { background: #94a3b8; } | |
| 888 | 1069 | .legend-dot--slot { background: #e2e8f0; } |
| 889 | 1070 | |
| 890 | 1071 | .schedule-wrap { |
| ... | ... | @@ -1029,9 +1210,8 @@ export default { |
| 1029 | 1210 | border-radius: 4px; |
| 1030 | 1211 | display: inline-block; |
| 1031 | 1212 | &--normal { background: #f1f5f9; color: #64748b; } |
| 1032 | - &--leave { background: rgba(249, 115, 22, 0.15); color: #ea580c; } | |
| 1213 | + &--leave { background: rgba(148, 163, 184, 0.2); color: #64748b; } | |
| 1033 | 1214 | &--vacation { background: rgba(139, 92, 246, 0.15); color: #7c3aed; } |
| 1034 | - &--rest { background: rgba(148, 163, 184, 0.2); color: #64748b; } | |
| 1035 | 1215 | } |
| 1036 | 1216 | |
| 1037 | 1217 | .day-slots { |
| ... | ... | @@ -1044,7 +1224,7 @@ export default { |
| 1044 | 1224 | display: flex; |
| 1045 | 1225 | align-items: center; |
| 1046 | 1226 | justify-content: center; |
| 1047 | - background: rgba(249, 115, 22, 0.08); | |
| 1227 | + background: rgba(148, 163, 184, 0.16); | |
| 1048 | 1228 | } |
| 1049 | 1229 | &--vacation { |
| 1050 | 1230 | background: rgba(139, 92, 246, 0.08); |
| ... | ... | @@ -1054,13 +1234,15 @@ export default { |
| 1054 | 1234 | |
| 1055 | 1235 | .leave-cover { |
| 1056 | 1236 | font-size: 12px; |
| 1057 | - color: #ea580c; | |
| 1237 | + color: #475569; | |
| 1058 | 1238 | font-weight: 500; |
| 1059 | 1239 | } |
| 1060 | 1240 | |
| 1061 | 1241 | .slot { |
| 1062 | 1242 | min-height: 20px; |
| 1063 | 1243 | background: #f8fafc; |
| 1244 | + /* 默认格子边线(预占用块会覆盖为透明以减少分割线) */ | |
| 1245 | + border: 1px solid rgba(241, 245, 249, 0.95); | |
| 1064 | 1246 | |
| 1065 | 1247 | &--clickable { |
| 1066 | 1248 | cursor: pointer; |
| ... | ... | @@ -1077,7 +1259,16 @@ export default { |
| 1077 | 1259 | &:not(.slot--resize-start):not(.slot--resize-end) { border-radius: 0; } |
| 1078 | 1260 | } |
| 1079 | 1261 | |
| 1080 | -.slot--confirmed { | |
| 1262 | +.slot--serving { | |
| 1263 | + background: rgba(249, 115, 22, 0.5); | |
| 1264 | + &:hover { background: rgba(249, 115, 22, 0.7); } | |
| 1265 | + &.slot--resize-start.slot--resize-end { border-radius: 2px; } | |
| 1266 | + &.slot--resize-start:not(.slot--resize-end) { border-radius: 2px 0 0 2px; } | |
| 1267 | + &.slot--resize-end:not(.slot--resize-start) { border-radius: 0 2px 2px 0; } | |
| 1268 | + &:not(.slot--resize-start):not(.slot--resize-end) { border-radius: 0; } | |
| 1269 | +} | |
| 1270 | + | |
| 1271 | +.slot--completed { | |
| 1081 | 1272 | background: rgba(34, 197, 94, 0.5); |
| 1082 | 1273 | &:hover { background: rgba(34, 197, 94, 0.7); } |
| 1083 | 1274 | &.slot--resize-start.slot--resize-end { border-radius: 2px; } |
| ... | ... | @@ -1087,7 +1278,7 @@ export default { |
| 1087 | 1278 | } |
| 1088 | 1279 | |
| 1089 | 1280 | .slot--leave { |
| 1090 | - background: rgba(249, 115, 22, 0.35); | |
| 1281 | + background: rgba(148, 163, 184, 0.35); | |
| 1091 | 1282 | } |
| 1092 | 1283 | |
| 1093 | 1284 | .slot--in-drag { |
| ... | ... | @@ -1102,6 +1293,10 @@ export default { |
| 1102 | 1293 | position: relative; |
| 1103 | 1294 | background: rgba(37, 99, 235, 0.42); |
| 1104 | 1295 | &:hover { background: rgba(37, 99, 235, 0.6); } |
| 1296 | + /* 让块内部看起来是“整块”,压掉密集分割线 */ | |
| 1297 | + border-color: transparent !important; | |
| 1298 | + border-width: 0 !important; | |
| 1299 | + box-shadow: none; | |
| 1105 | 1300 | } |
| 1106 | 1301 | .slot--preconsume-booked { } |
| 1107 | 1302 | .slot--preconsume-serving { |
| ... | ... | @@ -1117,20 +1312,39 @@ export default { |
| 1117 | 1312 | &:hover { background: rgba(239, 68, 68, 0.6); } |
| 1118 | 1313 | } |
| 1119 | 1314 | |
| 1120 | -.slot--preconsume-color-blue { box-shadow: inset 0 -2px 0 rgba(37, 99, 235, 0.65); } | |
| 1121 | -.slot--preconsume-color-green { box-shadow: inset 0 -2px 0 rgba(34, 197, 94, 0.7); } | |
| 1122 | -.slot--preconsume-color-orange { box-shadow: inset 0 -2px 0 rgba(249, 115, 22, 0.75); } | |
| 1123 | -.slot--preconsume-color-purple { box-shadow: inset 0 -2px 0 rgba(139, 92, 246, 0.75); } | |
| 1124 | -.slot--preconsume-color-gray { box-shadow: inset 0 -2px 0 rgba(148, 163, 184, 0.8); } | |
| 1315 | +/* 颜色识别不要贯穿整段底边,避免中间出现“下边框” */ | |
| 1316 | +.slot--preconsume-color-blue { box-shadow: none; } | |
| 1317 | +.slot--preconsume-color-green { box-shadow: none; } | |
| 1318 | +.slot--preconsume-color-orange { box-shadow: none; } | |
| 1319 | +.slot--preconsume-color-purple { box-shadow: none; } | |
| 1320 | +.slot--preconsume-color-gray { box-shadow: none; } | |
| 1321 | + | |
| 1322 | +/* 仅在块两端做轻微色条提示 */ | |
| 1323 | +.slot--preconsume-edge.slot--preconsume-color-blue { box-shadow: inset 0 -2px 0 rgba(37, 99, 235, 0.65); } | |
| 1324 | +.slot--preconsume-edge.slot--preconsume-color-green { box-shadow: inset 0 -2px 0 rgba(34, 197, 94, 0.7); } | |
| 1325 | +.slot--preconsume-edge.slot--preconsume-color-orange { box-shadow: inset 0 -2px 0 rgba(249, 115, 22, 0.75); } | |
| 1326 | +.slot--preconsume-edge.slot--preconsume-color-purple { box-shadow: inset 0 -2px 0 rgba(139, 92, 246, 0.75); } | |
| 1327 | +.slot--preconsume-edge.slot--preconsume-color-gray { box-shadow: inset 0 -2px 0 rgba(148, 163, 184, 0.8); } | |
| 1125 | 1328 | |
| 1126 | 1329 | .slot--same-active { |
| 1127 | - outline: 2px solid rgba(17, 24, 39, 0.25); | |
| 1330 | + /* 用内描边提示边缘,不改变格子视觉尺寸 */ | |
| 1331 | + box-shadow: inset 0 0 0 2px rgba(17, 24, 39, 0.22); | |
| 1128 | 1332 | z-index: 1; |
| 1129 | 1333 | } |
| 1130 | 1334 | .slot--dimmed { |
| 1131 | 1335 | opacity: 0.35; |
| 1132 | 1336 | } |
| 1133 | 1337 | |
| 1338 | +.slot--preconsume.slot--resize-start { | |
| 1339 | + /* 用内阴影提示左边缘,避免“变大” */ | |
| 1340 | + border-left: 0 !important; | |
| 1341 | + box-shadow: inset 2px 0 0 rgba(15, 23, 42, 0.14); | |
| 1342 | +} | |
| 1343 | +.slot--preconsume.slot--resize-end { | |
| 1344 | + border-right: 0 !important; | |
| 1345 | + box-shadow: inset -2px 0 0 rgba(15, 23, 42, 0.14); | |
| 1346 | +} | |
| 1347 | + | |
| 1134 | 1348 | /* 预约详情弹窗 - 与主弹窗风格统一 */ |
| 1135 | 1349 | ::v-deep .schedule-detail-dialog { |
| 1136 | 1350 | border-radius: 20px; |
| ... | ... | @@ -1187,8 +1401,9 @@ export default { |
| 1187 | 1401 | border-bottom: 1px solid #f1f5f9; |
| 1188 | 1402 | font-size: 14px; |
| 1189 | 1403 | .label { width: 90px; color: #64748b; flex-shrink: 0; } |
| 1190 | - .status-confirmed { color: #22c55e; font-weight: 600; } | |
| 1191 | 1404 | .status-booked { color: #3b82f6; font-weight: 600; } |
| 1405 | + .status-serving { color: #f97316; font-weight: 600; } | |
| 1406 | + .status-completed { color: #22c55e; font-weight: 600; } | |
| 1192 | 1407 | } |
| 1193 | 1408 | |
| 1194 | 1409 | .booking-detail-footer { | ... | ... |
store-pc/src/components/booking-consume-detail-dialog.vue
| ... | ... | @@ -2,7 +2,7 @@ |
| 2 | 2 | <el-dialog |
| 3 | 3 | :visible.sync="visibleProxy" |
| 4 | 4 | :show-close="false" |
| 5 | - width="520px" | |
| 5 | + width="700px" | |
| 6 | 6 | :close-on-click-modal="false" |
| 7 | 7 | custom-class="booking-consume-detail-dialog" |
| 8 | 8 | append-to-body |
| ... | ... | @@ -15,7 +15,8 @@ |
| 15 | 15 | |
| 16 | 16 | <div class="body"> |
| 17 | 17 | <div class="row"><span class="label">会员</span><span class="value">{{ booking.memberName || '无' }}</span></div> |
| 18 | - <div class="row"><span class="label">预约时间</span><span class="value">{{ booking.date }} {{ booking.startTime }}-{{ booking.endTime }}</span></div> | |
| 18 | + <div class="row"><span class="label">预约日期</span><span class="value">{{ booking.date || '无' }}</span></div> | |
| 19 | + <div class="row"><span class="label">开始/结束</span><span class="value">{{ timeRangeText }}</span></div> | |
| 19 | 20 | <div class="row"><span class="label">房间</span><span class="value">{{ booking.roomName || '无' }}</span></div> |
| 20 | 21 | <div class="row"> |
| 21 | 22 | <span class="label">健康师</span> |
| ... | ... | @@ -24,19 +25,31 @@ |
| 24 | 25 | <span v-else>无</span> |
| 25 | 26 | </span> |
| 26 | 27 | </div> |
| 27 | - <div class="row"> | |
| 28 | - <span class="label">项目/品项</span> | |
| 29 | - <span class="value"> | |
| 30 | - <span v-if="itemLabels.length">{{ itemLabels.join('、') }}</span> | |
| 31 | - <span v-else>无</span> | |
| 32 | - </span> | |
| 28 | + <div class="row row--items"> | |
| 29 | + <span class="label">品项明细</span> | |
| 30 | + <div class="value value--block"> | |
| 31 | + <div v-if="displayItems.length" class="items-list"> | |
| 32 | + <div v-for="(it, idx) in displayItems" :key="idx" class="item-line"> | |
| 33 | + <div class="item-main"> | |
| 34 | + <span class="item-name">{{ it.label || '无' }}</span> | |
| 35 | + <span class="item-count">×{{ it.count }}</span> | |
| 36 | + </div> | |
| 37 | + <div class="item-sub"> | |
| 38 | + <span class="sub-label">服务健康师:</span> | |
| 39 | + <span class="sub-value">{{ it.workerNamesText }}</span> | |
| 40 | + </div> | |
| 41 | + </div> | |
| 42 | + </div> | |
| 43 | + <div v-else class="empty-text">无</div> | |
| 44 | + </div> | |
| 33 | 45 | </div> |
| 34 | 46 | <div class="row"> |
| 35 | 47 | <span class="label">状态</span> |
| 36 | 48 | <span :class="['value', 'status', 'status--' + (booking.status || 'booked')]">{{ statusText }}</span> |
| 37 | 49 | </div> |
| 38 | - <div class="row" v-if="booking.remark"> | |
| 39 | - <span class="label">备注</span><span class="value">{{ booking.remark }}</span> | |
| 50 | + <div class="row row--remark"> | |
| 51 | + <span class="label">备注</span> | |
| 52 | + <span class="value value--multiline">{{ booking.remark || '无' }}</span> | |
| 40 | 53 | </div> |
| 41 | 54 | </div> |
| 42 | 55 | |
| ... | ... | @@ -51,6 +64,15 @@ |
| 51 | 64 | 取消预约 |
| 52 | 65 | </el-button> |
| 53 | 66 | <el-button |
| 67 | + v-if="booking.status !== 'cancelled' && booking.status !== 'converted'" | |
| 68 | + type="primary" | |
| 69 | + plain | |
| 70 | + size="small" | |
| 71 | + @click="$emit('edit', booking)" | |
| 72 | + > | |
| 73 | + 修改预约 | |
| 74 | + </el-button> | |
| 75 | + <el-button | |
| 54 | 76 | v-if="booking.status === 'booked'" |
| 55 | 77 | type="primary" |
| 56 | 78 | size="small" |
| ... | ... | @@ -65,7 +87,7 @@ |
| 65 | 87 | size="small" |
| 66 | 88 | @click="$emit('convert', booking)" |
| 67 | 89 | > |
| 68 | - 转消耗 | |
| 90 | + 转消耗开单 | |
| 69 | 91 | </el-button> |
| 70 | 92 | </div> |
| 71 | 93 | </div> |
| ... | ... | @@ -84,23 +106,44 @@ export default { |
| 84 | 106 | get() { return this.visible }, |
| 85 | 107 | set(v) { this.$emit('update:visible', v) } |
| 86 | 108 | }, |
| 109 | + timeRangeText() { | |
| 110 | + const b = this.booking || {} | |
| 111 | + const st = b.startTime || '无' | |
| 112 | + const et = b.endTime || '无' | |
| 113 | + if (st === '无' && et === '无') return '无' | |
| 114 | + return `${st}-${et}` | |
| 115 | + }, | |
| 87 | 116 | therapistNames() { |
| 88 | 117 | const b = this.booking || {} |
| 89 | 118 | if (Array.isArray(b.therapistNames) && b.therapistNames.length) return b.therapistNames |
| 90 | 119 | return [] |
| 91 | 120 | }, |
| 92 | - itemLabels() { | |
| 121 | + therapistNameMap() { | |
| 93 | 122 | const b = this.booking || {} |
| 94 | - if (Array.isArray(b.itemLabels) && b.itemLabels.length) return b.itemLabels | |
| 95 | - if (Array.isArray(b.items) && b.items.length) { | |
| 96 | - return b.items.map(x => { | |
| 97 | - const base = x.label || '' | |
| 98 | - const cnt = (x.count != null ? x.count : x.qty) | |
| 99 | - const suffix = cnt != null ? `×${cnt}` : '' | |
| 100 | - return `${base}${suffix}`.trim() | |
| 101 | - }).filter(Boolean) | |
| 102 | - } | |
| 103 | - return [] | |
| 123 | + const ids = Array.isArray(b.therapistIds) ? b.therapistIds : [] | |
| 124 | + const names = Array.isArray(b.therapistNames) ? b.therapistNames : [] | |
| 125 | + const map = new Map() | |
| 126 | + ids.forEach((id, idx) => { | |
| 127 | + if (!id) return | |
| 128 | + map.set(id, names[idx] || id) | |
| 129 | + }) | |
| 130 | + return map | |
| 131 | + }, | |
| 132 | + displayItems() { | |
| 133 | + const b = this.booking || {} | |
| 134 | + const list = Array.isArray(b.items) ? b.items : [] | |
| 135 | + const map = this.therapistNameMap | |
| 136 | + return list.map(x => { | |
| 137 | + const count = (x && (x.count != null ? x.count : x.qty)) || 1 | |
| 138 | + const workers = Array.isArray(x && x.workers) ? x.workers : [] | |
| 139 | + const workerIds = workers.map(w => w && w.workerId).filter(Boolean) | |
| 140 | + const workerNames = workerIds.map(id => map.get(id) || id) | |
| 141 | + return { | |
| 142 | + label: (x && x.label) || '', | |
| 143 | + count, | |
| 144 | + workerNamesText: workerNames.length ? workerNames.join('、') : '无' | |
| 145 | + } | |
| 146 | + }) | |
| 104 | 147 | }, |
| 105 | 148 | statusText() { |
| 106 | 149 | const s = (this.booking && this.booking.status) || 'booked' |
| ... | ... | @@ -162,6 +205,10 @@ export default { |
| 162 | 205 | font-size: 14px; |
| 163 | 206 | } |
| 164 | 207 | |
| 208 | +.row--items { | |
| 209 | + align-items: flex-start; | |
| 210 | +} | |
| 211 | + | |
| 165 | 212 | .label { |
| 166 | 213 | width: 96px; |
| 167 | 214 | color: #64748b; |
| ... | ... | @@ -172,9 +219,87 @@ export default { |
| 172 | 219 | flex: 1; |
| 173 | 220 | min-width: 0; |
| 174 | 221 | color: #111827; |
| 175 | - white-space: nowrap; | |
| 176 | - overflow: hidden; | |
| 177 | - text-overflow: ellipsis; | |
| 222 | + white-space: normal; | |
| 223 | + overflow: visible; | |
| 224 | + text-overflow: clip; | |
| 225 | + line-height: 1.5; | |
| 226 | + word-break: break-all; | |
| 227 | +} | |
| 228 | + | |
| 229 | +.value--block { | |
| 230 | + white-space: normal; | |
| 231 | + overflow: visible; | |
| 232 | +} | |
| 233 | + | |
| 234 | +.value--multiline { | |
| 235 | + white-space: normal; | |
| 236 | + overflow: visible; | |
| 237 | + text-overflow: clip; | |
| 238 | + line-height: 1.5; | |
| 239 | + word-break: break-all; | |
| 240 | +} | |
| 241 | + | |
| 242 | +.items-list { | |
| 243 | + display: flex; | |
| 244 | + flex-direction: column; | |
| 245 | + gap: 10px; | |
| 246 | +} | |
| 247 | + | |
| 248 | +.item-line { | |
| 249 | + padding: 10px 12px; | |
| 250 | + border: 1px solid rgba(226, 232, 240, 0.9); | |
| 251 | + border-radius: 12px; | |
| 252 | + background: rgba(248, 250, 252, 0.75); | |
| 253 | +} | |
| 254 | + | |
| 255 | +.item-main { | |
| 256 | + display: flex; | |
| 257 | + align-items: center; | |
| 258 | + justify-content: space-between; | |
| 259 | + gap: 10px; | |
| 260 | +} | |
| 261 | + | |
| 262 | +.item-name { | |
| 263 | + font-weight: 600; | |
| 264 | + color: #0f172a; | |
| 265 | + flex: 1; | |
| 266 | + min-width: 0; | |
| 267 | + white-space: normal; | |
| 268 | + overflow: visible; | |
| 269 | + text-overflow: clip; | |
| 270 | + line-height: 1.4; | |
| 271 | + word-break: break-all; | |
| 272 | +} | |
| 273 | + | |
| 274 | +.item-count { | |
| 275 | + flex-shrink: 0; | |
| 276 | + font-size: 12px; | |
| 277 | + color: #2563eb; | |
| 278 | + font-weight: 700; | |
| 279 | +} | |
| 280 | + | |
| 281 | +.item-sub { | |
| 282 | + margin-top: 6px; | |
| 283 | + font-size: 12px; | |
| 284 | + color: #64748b; | |
| 285 | + display: flex; | |
| 286 | + gap: 6px; | |
| 287 | + line-height: 1.4; | |
| 288 | +} | |
| 289 | + | |
| 290 | +.sub-label { | |
| 291 | + flex-shrink: 0; | |
| 292 | +} | |
| 293 | + | |
| 294 | +.sub-value { | |
| 295 | + flex: 1; | |
| 296 | + min-width: 0; | |
| 297 | + word-break: break-all; | |
| 298 | +} | |
| 299 | + | |
| 300 | +.empty-text { | |
| 301 | + color: #94a3b8; | |
| 302 | + font-size: 13px; | |
| 178 | 303 | } |
| 179 | 304 | |
| 180 | 305 | .status { |
| ... | ... | @@ -201,7 +326,7 @@ export default { |
| 201 | 326 | } |
| 202 | 327 | |
| 203 | 328 | ::v-deep .booking-consume-detail-dialog { |
| 204 | - max-width: 520px; | |
| 329 | + max-width: 700px; | |
| 205 | 330 | margin-top: 10vh !important; |
| 206 | 331 | border-radius: 20px; |
| 207 | 332 | padding: 0; | ... | ... |
store-pc/src/components/booking-consume-dialog.vue
| ... | ... | @@ -247,13 +247,16 @@ export default { |
| 247 | 247 | name: 'BookingConsumeDialog', |
| 248 | 248 | components: { RoomUsageDialog }, |
| 249 | 249 | props: { |
| 250 | - visible: { type: Boolean, default: false } | |
| 250 | + visible: { type: Boolean, default: false }, | |
| 251 | + prefill: { type: Object, default: () => ({}) } | |
| 251 | 252 | }, |
| 252 | 253 | data() { |
| 253 | 254 | return { |
| 254 | 255 | submitting: false, |
| 255 | 256 | roomUsageVisible: false, |
| 256 | 257 | form: this.createEmptyForm(), |
| 258 | + editId: '', | |
| 259 | + editMeta: null, | |
| 257 | 260 | memberOptions: [ |
| 258 | 261 | { value: 'cust001', label: '林小纤', phone: '13800138000' }, |
| 259 | 262 | { value: 'cust002', label: '王丽', phone: '13800138001' }, |
| ... | ... | @@ -351,6 +354,12 @@ export default { |
| 351 | 354 | watch: { |
| 352 | 355 | visible(val) { |
| 353 | 356 | if (val) this.resetFormForOpen() |
| 357 | + }, | |
| 358 | + prefill: { | |
| 359 | + deep: true, | |
| 360 | + handler() { | |
| 361 | + if (this.visible) this.resetFormForOpen() | |
| 362 | + } | |
| 354 | 363 | } |
| 355 | 364 | }, |
| 356 | 365 | methods: { |
| ... | ... | @@ -390,13 +399,67 @@ export default { |
| 390 | 399 | }, |
| 391 | 400 | resetFormForOpen() { |
| 392 | 401 | this.form = this.createEmptyForm() |
| 402 | + this.editId = '' | |
| 403 | + this.editMeta = null | |
| 393 | 404 | this.roomUsageVisible = false |
| 394 | 405 | this.submitting = false |
| 395 | 406 | this.availableItems = [] |
| 407 | + this.applyPrefill() | |
| 396 | 408 | this.$nextTick(() => { |
| 397 | 409 | this.$refs.form && this.$refs.form.clearValidate() |
| 398 | 410 | }) |
| 399 | 411 | }, |
| 412 | + applyPrefill() { | |
| 413 | + const p = this.prefill || {} | |
| 414 | + if (!p || typeof p !== 'object') return | |
| 415 | + | |
| 416 | + if (p.id) { | |
| 417 | + this.editId = p.id | |
| 418 | + this.editMeta = { colorKey: p.colorKey, createdAt: p.createdAt } | |
| 419 | + } | |
| 420 | + | |
| 421 | + if (p.memberId) { | |
| 422 | + this.form.memberId = p.memberId | |
| 423 | + this.form.memberName = p.memberName || '' | |
| 424 | + this.loadMemberItems(p.memberId) | |
| 425 | + } | |
| 426 | + if (p.date) { | |
| 427 | + const d = p.date instanceof Date ? p.date : new Date(p.date) | |
| 428 | + if (!Number.isNaN(d.getTime())) this.form.date = d | |
| 429 | + } | |
| 430 | + if (p.startTime) this.form.startTime = p.startTime | |
| 431 | + if (p.endTime) this.form.endTime = p.endTime | |
| 432 | + if (p.roomId) { | |
| 433 | + this.form.roomId = p.roomId | |
| 434 | + const r = this.roomOptions.find(x => x.id === p.roomId) | |
| 435 | + this.form.roomName = (p.roomName || (r ? r.name : '')) || '' | |
| 436 | + } else if (p.roomName) { | |
| 437 | + this.form.roomName = p.roomName | |
| 438 | + } | |
| 439 | + if (p.remark != null) this.form.remark = p.remark || '' | |
| 440 | + | |
| 441 | + // items | |
| 442 | + if (Array.isArray(p.items) && p.items.length) { | |
| 443 | + this.form.items = p.items.map(pi => { | |
| 444 | + const it = this.createEmptyItem() | |
| 445 | + it.projectId = pi.projectId || '' | |
| 446 | + it.label = pi.label || '' | |
| 447 | + it.count = (pi.count != null ? pi.count : 1) || 1 | |
| 448 | + it.workers = Array.isArray(pi.workers) && pi.workers.length ? pi.workers.map(w => ({ | |
| 449 | + workerId: (w && (w.workerId || w.id)) || '', | |
| 450 | + performance: '', | |
| 451 | + laborCost: '', | |
| 452 | + count: '' | |
| 453 | + })) : [{ workerId: '' }] | |
| 454 | + return it | |
| 455 | + }) | |
| 456 | + this.$nextTick(() => { | |
| 457 | + this.form.items.forEach((_, idx) => { | |
| 458 | + if (this.form.items[idx].projectId) this.onProjectChange(idx) | |
| 459 | + }) | |
| 460 | + }) | |
| 461 | + } | |
| 462 | + }, | |
| 400 | 463 | onMemberChange(val) { |
| 401 | 464 | const m = this.memberOptions.find(x => x.value === val) |
| 402 | 465 | this.form.memberName = m ? m.label : '' |
| ... | ... | @@ -448,7 +511,7 @@ export default { |
| 448 | 511 | if (!valid) return |
| 449 | 512 | this.submitting = true |
| 450 | 513 | setTimeout(() => { |
| 451 | - const id = `PCB_${Date.now()}_${Math.floor(Math.random() * 1000)}` | |
| 514 | + const id = this.editId || `PCB_${Date.now()}_${Math.floor(Math.random() * 1000)}` | |
| 452 | 515 | const dateStr = this.formatDate(this.form.date) |
| 453 | 516 | const therapistIds = this.selectedTherapistIds |
| 454 | 517 | const therapistNames = this.selectedTherapistNames |
| ... | ... | @@ -479,12 +542,17 @@ export default { |
| 479 | 542 | itemLabels, |
| 480 | 543 | remark: this.form.remark || '', |
| 481 | 544 | status: 'booked', |
| 482 | - colorKey: this.buildColorKey(id), | |
| 483 | - createdAt: new Date().toISOString() | |
| 545 | + colorKey: (this.editMeta && this.editMeta.colorKey) ? this.editMeta.colorKey : this.buildColorKey(id), | |
| 546 | + createdAt: (this.editMeta && this.editMeta.createdAt) ? this.editMeta.createdAt : new Date().toISOString() | |
| 484 | 547 | } |
| 485 | 548 | const list = this.readStorage() |
| 486 | - list.unshift(record) | |
| 549 | + const idx = list.findIndex(x => x && x.id === id) | |
| 550 | + if (idx >= 0) list.splice(idx, 1, record) | |
| 551 | + else list.unshift(record) | |
| 487 | 552 | this.writeStorage(list) |
| 553 | + try { | |
| 554 | + window.dispatchEvent(new CustomEvent('store_pc_pre_consume_bookings_updated', { detail: { id: record.id } })) | |
| 555 | + } catch (e) {} | |
| 488 | 556 | this.submitting = false |
| 489 | 557 | this.$emit('saved', record) |
| 490 | 558 | }, 500) | ... | ... |
store-pc/src/views/dashboard/index.vue
| ... | ... | @@ -377,7 +377,7 @@ |
| 377 | 377 | /> |
| 378 | 378 | <booking-dialog :visible.sync="bookingDialogVisible" :prefill="bookingPrefill" /> |
| 379 | 379 | <billing-dialog :visible.sync="billingDialogVisible" :prefill="billingPrefill" /> |
| 380 | - <consume-dialog :visible.sync="consumeDialogVisible" /> | |
| 380 | + <consume-dialog :visible.sync="consumeDialogVisible" :prefill="consumePrefill" /> | |
| 381 | 381 | <refund-dialog :visible.sync="refundDialogVisible" /> |
| 382 | 382 | <tuoke-list-dialog :visible.sync="tuokeListVisible" /> |
| 383 | 383 | <booking-calendar-dialog :visible.sync="bookingCalendarVisible" /> |
| ... | ... | @@ -807,6 +807,7 @@ export default { |
| 807 | 807 | billingDialogVisible: false, |
| 808 | 808 | billingPrefill: {}, |
| 809 | 809 | consumeDialogVisible: false, |
| 810 | + consumePrefill: {}, | |
| 810 | 811 | refundDialogVisible: false, |
| 811 | 812 | tuokeListVisible: false, |
| 812 | 813 | bookingCalendarVisible: false, |
| ... | ... | @@ -942,6 +943,8 @@ export default { |
| 942 | 943 | fmt() |
| 943 | 944 | this.clockTimer = setInterval(fmt, 1000) |
| 944 | 945 | |
| 946 | + this.tryOpenConsumeFromPrefillOnce() | |
| 947 | + | |
| 945 | 948 | // 提醒中心默认展开/收起与上次状态(可配置) |
| 946 | 949 | const savedOpen = localStorage.getItem('store_reminder_open') |
| 947 | 950 | const savedType = localStorage.getItem('store_reminder_type') |
| ... | ... | @@ -965,6 +968,28 @@ export default { |
| 965 | 968 | clearInterval(this.clockTimer) |
| 966 | 969 | }, |
| 967 | 970 | methods: { |
| 971 | + tryOpenConsumeFromPrefillOnce() { | |
| 972 | + const key = 'store_pc_consume_prefill_once' | |
| 973 | + let raw = null | |
| 974 | + try { | |
| 975 | + raw = localStorage.getItem(key) | |
| 976 | + } catch (e) { | |
| 977 | + raw = null | |
| 978 | + } | |
| 979 | + if (!raw) return | |
| 980 | + try { | |
| 981 | + const obj = JSON.parse(raw) | |
| 982 | + if (!obj || typeof obj !== 'object') return | |
| 983 | + this.consumePrefill = obj | |
| 984 | + this.$nextTick(() => { | |
| 985 | + this.consumeDialogVisible = true | |
| 986 | + }) | |
| 987 | + } catch (e) { | |
| 988 | + // ignore | |
| 989 | + } finally { | |
| 990 | + try { localStorage.removeItem(key) } catch (e) {} | |
| 991 | + } | |
| 992 | + }, | |
| 968 | 993 | toggleFullscreen() { |
| 969 | 994 | if (!document.fullscreenElement) { |
| 970 | 995 | document.documentElement.requestFullscreen().catch(() => { }) | ... | ... |