Commit f4739ed53b544678b0eed29bfdf1108361f7f39a

Authored by “wangming”
1 parent cb9a18fe

完成了基本的门店PC页面的设计

store-pc/src/components/BookingCalendarDialog.vue
@@ -16,10 +16,11 @@ @@ -16,10 +16,11 @@
16 <div class="dialog-search"> 16 <div class="dialog-search">
17 <el-form @submit.native.prevent :inline="true" size="small"> 17 <el-form @submit.native.prevent :inline="true" size="small">
18 <el-form-item label="预约状态"> 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 </el-select> 24 </el-select>
24 </el-form-item> 25 </el-form-item>
25 <el-form-item> 26 <el-form-item>
@@ -44,10 +45,27 @@ @@ -44,10 +45,27 @@
44 :eventLimit="true" 45 :eventLimit="true"
45 allDayText="全天" 46 allDayText="全天"
46 :editable="false" 47 :editable="false"
  48 + :dayRender="onDayRender"
47 @datesRender="datesRender" 49 @datesRender="datesRender"
  50 + @dateClick="handleDateClick"
  51 + @eventClick="handleEventClick"
48 /> 52 />
49 </div> 53 </div>
50 </div> 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 </el-dialog> 69 </el-dialog>
52 </template> 70 </template>
53 71
@@ -56,36 +74,32 @@ import FullCalendar from &#39;@fullcalendar/vue&#39; @@ -56,36 +74,32 @@ import FullCalendar from &#39;@fullcalendar/vue&#39;
56 import dayGridPlugin from '@fullcalendar/daygrid' 74 import dayGridPlugin from '@fullcalendar/daygrid'
57 import timeGridPlugin from '@fullcalendar/timegrid' 75 import timeGridPlugin from '@fullcalendar/timegrid'
58 import interactionPlugin from '@fullcalendar/interaction' 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 export default { 82 export default {
74 name: 'BookingCalendarDialog', 83 name: 'BookingCalendarDialog',
75 - components: { FullCalendar }, 84 + components: { FullCalendar, BookingConsumeDialog, BookingConsumeDetailDialog },
76 props: { visible: { type: Boolean, default: false } }, 85 props: { visible: { type: Boolean, default: false } },
77 data() { 86 data() {
78 return { 87 return {
79 loading: false, 88 loading: false,
80 - mockData: MOCK_BOOKING,  
81 - query: { F_Status: undefined }, 89 + query: { status: undefined },
82 calendarPlugins: [dayGridPlugin, timeGridPlugin, interactionPlugin], 90 calendarPlugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
83 calendarEvents: [], 91 calendarEvents: [],
84 calendarHeader: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' }, 92 calendarHeader: { left: 'prev,next today', center: 'title', right: 'dayGridMonth,timeGridWeek,timeGridDay' },
85 buttonText: { today: '今日', month: '月', week: '周', day: '日' }, 93 buttonText: { today: '今日', month: '月', week: '周', day: '日' },
86 startTime: null, 94 startTime: null,
87 endTime: null, 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 computed: { 105 computed: {
@@ -97,7 +111,27 @@ export default { @@ -97,7 +111,27 @@ export default {
97 watch: { 111 watch: {
98 visible(v) { 112 visible(v) {
99 if (v) { 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,8 +140,128 @@ export default {
106 }, 140 },
107 beforeDestroy() { 141 beforeDestroy() {
108 window.removeEventListener('resize', this.calcHeight) 142 window.removeEventListener('resize', this.calcHeight)
  143 + this._clearFirstOpenTimers()
  144 + this._removeBookingItemClickGuard()
109 }, 145 },
110 methods: { 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, '&amp;')
  239 + .replace(/</g, '&lt;')
  240 + .replace(/>/g, '&gt;')
  241 + .replace(/"/g, '&quot;')
  242 + .replace(/'/g, '&#39;')
  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 calcHeight() { 265 calcHeight() {
112 this.$nextTick(() => { 266 this.$nextTick(() => {
113 const el = this.$el && this.$el.querySelector('.dialog-content') 267 const el = this.$el && this.$el.querySelector('.dialog-content')
@@ -116,44 +270,297 @@ export default { @@ -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 datesRender(info) { 285 datesRender(info) {
120 const view = info.view 286 const view = info.view
121 this.startTime = view.activeStart 287 this.startTime = view.activeStart
122 this.endTime = view.activeEnd 288 this.endTime = view.activeEnd
  289 + this.refreshBookingDataSync()
123 this.initData() 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 this.loading = false 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 search() { this.initData() }, 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 </script> 566 </script>
@@ -173,6 +580,63 @@ export default { @@ -173,6 +580,63 @@ export default {
173 .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; } 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 .fc-unthemed th, .fc-unthemed td { border-color: #f1f5f9; } 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 </style> 640 </style>
177 641
178 <style lang="scss" scoped> 642 <style lang="scss" scoped>
store-pc/src/components/CompanyCalendarDialog.vue
@@ -39,6 +39,73 @@ @@ -39,6 +39,73 @@
39 /> 39 />
40 </div> 40 </div>
41 </div> 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 </el-dialog> 109 </el-dialog>
43 </template> 110 </template>
44 111
@@ -67,11 +134,11 @@ export default { @@ -67,11 +134,11 @@ export default {
67 // 当天 7 条示例(操作会 + 大会 + 活动),方便预览密集情况 134 // 当天 7 条示例(操作会 + 大会 + 活动),方便预览密集情况
68 { id: 'E001', date: todayStr, start: `${todayStr}T09:00:00`, end: `${todayStr}T10:00:00`, type: 'ops', title: '操作会 - 紫荆店' }, 135 { id: 'E001', date: todayStr, start: `${todayStr}T09:00:00`, end: `${todayStr}T10:00:00`, type: 'ops', title: '操作会 - 紫荆店' },
69 { id: 'E002', date: todayStr, start: `${todayStr}T10:30:00`, end: `${todayStr}T11:30:00`, type: 'ops', title: '操作会 - 西沙店' }, 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 { id: 'E004', date: todayStr, start: `${todayStr}T14:30:00`, end: `${todayStr}T15:30:00`, type: 'training', title: '培训会 - 科美新品讲解' }, 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 { id: 'E006', date: todayStr, start: `${todayStr}T18:00:00`, end: `${todayStr}T19:00:00`, type: 'activity', title: '门店活动 - 西沙店体验日' }, 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 { id: 'E008', date: `${y}-${m}-01`, start: `${y}-${m}-01T10:00:00`, end: `${y}-${m}-01T12:00:00`, type: 'meeting', title: '月初经营例会' }, 143 { id: 'E008', date: `${y}-${m}-01`, start: `${y}-${m}-01T10:00:00`, end: `${y}-${m}-01T12:00:00`, type: 'meeting', title: '月初经营例会' },
77 { id: 'E009', date: `${y}-${m}-02`, start: `${y}-${m}-02T14:00:00`, end: `${y}-${m}-02T16:00:00`, type: 'ops', title: '操作会 - 静居寺店' }, 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,7 +159,7 @@ export default {
92 { id: 'E024', date: `${y}-${m}-17`, start: `${y}-${m}-17T15:00:00`, end: `${y}-${m}-17T17:00:00`, type: 'activity', title: '门店活动 - 会员答谢宴' }, 159 { id: 'E024', date: `${y}-${m}-17`, start: `${y}-${m}-17T15:00:00`, end: `${y}-${m}-17T17:00:00`, type: 'activity', title: '门店活动 - 会员答谢宴' },
93 { id: 'E025', date: `${y}-${m}-18`, start: `${y}-${m}-18T14:00:00`, end: `${y}-${m}-18T15:30:00`, type: 'training', title: '培训会 - 绩效与辅导' }, 160 { id: 'E025', date: `${y}-${m}-18`, start: `${y}-${m}-18T14:00:00`, end: `${y}-${m}-18T15:30:00`, type: 'training', title: '培训会 - 绩效与辅导' },
94 { id: 'E026', date: `${y}-${m}-19`, start: `${y}-${m}-19T10:00:00`, end: `${y}-${m}-19T11:30:00`, type: 'ops', title: '操作会 - 门店联动' }, 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 { id: 'E028', date: `${y}-${m}-21`, start: `${y}-${m}-21T19:00:00`, end: `${y}-${m}-21T20:30:00`, type: 'activity', title: '门店活动 - 新品体验会' }, 163 { id: 'E028', date: `${y}-${m}-21`, start: `${y}-${m}-21T19:00:00`, end: `${y}-${m}-21T20:30:00`, type: 'activity', title: '门店活动 - 新品体验会' },
97 { id: 'E029', date: `${y}-${m}-22`, start: `${y}-${m}-22T10:00:00`, end: `${y}-${m}-22T11:30:00`, type: 'training', title: '培训会 - 工具使用' }, 164 { id: 'E029', date: `${y}-${m}-22`, start: `${y}-${m}-22T10:00:00`, end: `${y}-${m}-22T11:30:00`, type: 'training', title: '培训会 - 工具使用' },
98 { id: 'E030', date: `${y}-${m}-23`, start: `${y}-${m}-23T14:00:00`, end: `${y}-${m}-23T15:30:00`, type: 'ops', title: '操作会 - 区域复盘' }, 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,6 +177,8 @@ export default {
110 startTime: null, 177 startTime: null,
111 endTime: null, 178 endTime: null,
112 calendarHeight: 720, 179 calendarHeight: 720,
  180 + detailVisible: false,
  181 + selectedEvent: null,
113 typeColorMap: { 182 typeColorMap: {
114 ops: '#6366f1', 183 ops: '#6366f1',
115 training: '#22c55e', 184 training: '#22c55e',
@@ -134,6 +203,27 @@ export default { @@ -134,6 +203,27 @@ export default {
134 { type: 'meeting', label: '全体大会', color: this.typeColorMap.meeting }, 203 { type: 'meeting', label: '全体大会', color: this.typeColorMap.meeting },
135 { type: 'activity', label: '门店活动', color: this.typeColorMap.activity } 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 watch: { 229 watch: {
@@ -196,41 +286,43 @@ export default { @@ -196,41 +286,43 @@ export default {
196 this.loading = false 286 this.loading = false
197 }, 200) 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 handleEventClick(info) { 310 handleEventClick(info) {
200 const evt = info && info.event 311 const evt = info && info.event
201 if (!evt) return 312 if (!evt) return
202 const raw = this.companyEvents.find(e => e.id === evt.id) || {} 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,4 +487,216 @@ export default {
395 } 487 }
396 </style> 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,11 +31,19 @@
31 >{{ d.weekday }} {{ d.dateStr }}</span> 31 >{{ d.weekday }} {{ d.dateStr }}</span>
32 </div> 32 </div>
33 <div class="schedule-legend"> 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 <span class="legend-item"><i class="legend-dot legend-dot--slot"></i>30分钟/格</span> 47 <span class="legend-item"><i class="legend-dot legend-dot--slot"></i>30分钟/格</span>
40 </div> 48 </div>
41 </div> 49 </div>
@@ -103,34 +111,6 @@ @@ -103,34 +111,6 @@
103 </div> 111 </div>
104 </div> 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 <BookingDialog 115 <BookingDialog
136 :visible.sync="addBookingDialogVisible" 116 :visible.sync="addBookingDialogVisible"
@@ -143,15 +123,16 @@ @@ -143,15 +123,16 @@
143 :visible.sync="preConsumeDetailVisible" 123 :visible.sync="preConsumeDetailVisible"
144 :booking="selectedPreConsumeBooking" 124 :booking="selectedPreConsumeBooking"
145 @cancel="handlePreConsumeCancel" 125 @cancel="handlePreConsumeCancel"
  126 + @edit="handlePreConsumeEdit"
146 @start="handlePreConsumeStart" 127 @start="handlePreConsumeStart"
147 @convert="handlePreConsumeConvert" 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 </el-dialog> 137 </el-dialog>
157 </template> 138 </template>
@@ -159,13 +140,14 @@ @@ -159,13 +140,14 @@
159 <script> 140 <script>
160 import BookingDialog from '@/components/BookingDialog.vue' 141 import BookingDialog from '@/components/BookingDialog.vue'
161 import BookingConsumeDetailDialog from '@/components/booking-consume-detail-dialog.vue' 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 const PRE_CONSUME_STORAGE_KEY = 'store_pc_pre_consume_bookings' 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 export default { 148 export default {
167 name: 'EmployeeScheduleDialog', 149 name: 'EmployeeScheduleDialog',
168 - components: { BookingDialog, BookingConsumeDetailDialog, ConsumeDialog }, 150 + components: { BookingDialog, BookingConsumeDetailDialog, BookingConsumeDialog },
169 props: { 151 props: {
170 visible: { type: Boolean, default: false }, 152 visible: { type: Boolean, default: false },
171 openMode: { type: String, default: 'view' } 153 openMode: { type: String, default: 'view' }
@@ -174,17 +156,15 @@ export default { @@ -174,17 +156,15 @@ export default {
174 return { 156 return {
175 weekStart: null, 157 weekStart: null,
176 selectedDay: null, 158 selectedDay: null,
177 - detailDialogVisible: false,  
178 addBookingDialogVisible: false, 159 addBookingDialogVisible: false,
179 - selectedBooking: null,  
180 addBookingPrefill: {}, 160 addBookingPrefill: {},
181 dragState: null, 161 dragState: null,
182 preConsumeBookings: [], 162 preConsumeBookings: [],
183 activePreConsumeId: '', 163 activePreConsumeId: '',
184 preConsumeDetailVisible: false, 164 preConsumeDetailVisible: false,
185 selectedPreConsumeBooking: null, 165 selectedPreConsumeBooking: null,
186 - consumeDialogVisible: false,  
187 - consumePrefill: {}, 166 + bookingConsumeDialogVisible: false,
  167 + bookingConsumePrefill: {},
188 employeeList: [ 168 employeeList: [
189 { id: 'E001', name: '董顺秀', role: '健康师' }, 169 { id: 'E001', name: '董顺秀', role: '健康师' },
190 { id: 'E002', name: '张丽', role: '健康师' }, 170 { id: 'E002', name: '张丽', role: '健康师' },
@@ -202,26 +182,8 @@ export default { @@ -202,26 +182,8 @@ export default {
202 }, 182 },
203 // 员工当日状态:key = employeeId_dayOffset, value = { type: 'normal'|'leave'|'vacation'|'rest', text, slotStart?, slotEnd? } 183 // 员工当日状态:key = employeeId_dayOffset, value = { type: 'normal'|'leave'|'vacation'|'rest', text, slotStart?, slotEnd? }
204 // slotStart/slotEnd 为可选,表示半天请假/休假的时段(slot 索引 0-47) 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 computed: { 189 computed: {
@@ -326,8 +288,25 @@ export default { @@ -326,8 +288,25 @@ export default {
326 mounted() { 288 mounted() {
327 this.initWeek() 289 this.initWeek()
328 this.loadPreConsumeBookings() 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 methods: { 304 methods: {
  305 + bookingStatusClass(status) {
  306 + if (status === '服务中') return 'serving'
  307 + if (status === '已完成') return 'completed'
  308 + return 'booked'
  309 + },
331 readPreConsumeStorage() { 310 readPreConsumeStorage() {
332 try { 311 try {
333 const raw = localStorage.getItem(PRE_CONSUME_STORAGE_KEY) 312 const raw = localStorage.getItem(PRE_CONSUME_STORAGE_KEY)
@@ -343,7 +322,124 @@ export default { @@ -343,7 +322,124 @@ export default {
343 }, 322 },
344 loadPreConsumeBookings() { 323 loadPreConsumeBookings() {
345 const list = this.readPreConsumeStorage() 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 this.preConsumeBookings = list 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 findPreConsumeForSlot(empId, date, slotIndex) { 444 findPreConsumeForSlot(empId, date, slotIndex) {
349 const list = this.preConsumeBookingsWithDate 445 const list = this.preConsumeBookingsWithDate
@@ -358,7 +454,7 @@ export default { @@ -358,7 +454,7 @@ export default {
358 preConsumeStatusClass(status) { 454 preConsumeStatusClass(status) {
359 const s = status || 'booked' 455 const s = status || 'booked'
360 if (s === 'serving') return 'slot--preconsume-serving' 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 if (s === 'cancelled') return 'slot--preconsume-cancelled' 458 if (s === 'cancelled') return 'slot--preconsume-cancelled'
363 return 'slot--preconsume-booked' 459 return 'slot--preconsume-booked'
364 }, 460 },
@@ -441,11 +537,16 @@ export default { @@ -441,11 +537,16 @@ export default {
441 const pcb = this.findPreConsumeForSlot(empId, date, slotIndex) 537 const pcb = this.findPreConsumeForSlot(empId, date, slotIndex)
442 if (pcb) { 538 if (pcb) {
443 let c = `slot--preconsume ${this.preConsumeStatusClass(pcb.status)} slot--preconsume-color-${pcb.colorKey || 'blue'}` 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 if (this.activePreConsumeId) { 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 return c 551 return c
451 } 552 }
@@ -456,7 +557,9 @@ export default { @@ -456,7 +557,9 @@ export default {
456 slotIndex < x.slotEnd 557 slotIndex < x.slotEnd
457 ) 558 )
458 if (b) { 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 if (slotIndex === b.slotStart) c += ' slot--resize-start' 563 if (slotIndex === b.slotStart) c += ' slot--resize-start'
461 if (slotIndex === b.slotEnd - 1) c += ' slot--resize-end' 564 if (slotIndex === b.slotEnd - 1) c += ' slot--resize-end'
462 return c 565 return c
@@ -490,25 +593,61 @@ export default { @@ -490,25 +593,61 @@ export default {
490 if (list.length === 0) return '当日无预约 · 点击日期查看详情' 593 if (list.length === 0) return '当日无预约 · 点击日期查看详情'
491 return list.map(x => `${x.customerName} ${x.timeRange}`).join('\n') + '\n点击日期查看详情' 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 handleSlotMouseDown(e, emp, date, slotIndex) { 602 handleSlotMouseDown(e, emp, date, slotIndex) {
510 const pcb = this.findPreConsumeForSlot(emp.id, date, slotIndex) 603 const pcb = this.findPreConsumeForSlot(emp.id, date, slotIndex)
511 if (pcb) { 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 this.activePreConsumeId = pcb.id 651 this.activePreConsumeId = pcb.id
513 this.selectedPreConsumeBooking = pcb 652 this.selectedPreConsumeBooking = pcb
514 this.preConsumeDetailVisible = true 653 this.preConsumeDetailVisible = true
@@ -522,10 +661,6 @@ export default { @@ -522,10 +661,6 @@ export default {
522 ) 661 )
523 // 周视图下允许点击块直接查看详情,不支持拖动修改/新建 662 // 周视图下允许点击块直接查看详情,不支持拖动修改/新建
524 if (!this.selectedDay) { 663 if (!this.selectedDay) {
525 - if (b) {  
526 - this.selectedBooking = b  
527 - this.detailDialogVisible = true  
528 - }  
529 return 664 return
530 } 665 }
531 if (b) { 666 if (b) {
@@ -677,6 +812,17 @@ export default { @@ -677,6 +812,17 @@ export default {
677 this.preConsumeBookings = list 812 this.preConsumeBookings = list
678 return updated 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 handlePreConsumeCancel(b) { 826 handlePreConsumeCancel(b) {
681 if (!b) return 827 if (!b) return
682 this.$confirm(`确定取消「${b.memberName || '该会员'}」在 ${b.date} ${b.startTime}-${b.endTime} 的预约吗?`, '取消预约确认', { 828 this.$confirm(`确定取消「${b.memberName || '该会员'}」在 ${b.date} ${b.startTime}-${b.endTime} 的预约吗?`, '取消预约确认', {
@@ -695,30 +841,41 @@ export default { @@ -695,30 +841,41 @@ export default {
695 if (updated) this.selectedPreConsumeBooking = updated 841 if (updated) this.selectedPreConsumeBooking = updated
696 this.$message.success('已开始服务') 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 handlePreConsumeConvert(b) { 858 handlePreConsumeConvert(b) {
699 if (!b) return 859 if (!b) return
700 const updated = this.updatePreConsumeStatus(b.id, 'converted') || b 860 const updated = this.updatePreConsumeStatus(b.id, 'converted') || b
701 this.selectedPreConsumeBooking = updated 861 this.selectedPreConsumeBooking = updated
702 this.preConsumeDetailVisible = false 862 this.preConsumeDetailVisible = false
703 -  
704 - this.consumePrefill = { 863 + const prefillOnce = {
705 memberId: updated.memberId, 864 memberId: updated.memberId,
706 name: updated.memberName, 865 name: updated.memberName,
707 consumeDate: updated.date, 866 consumeDate: updated.date,
708 remark: `转自预约消耗单:${updated.id}${updated.remark ? ' · ' + updated.remark : ''}`, 867 remark: `转自预约消耗单:${updated.id}${updated.remark ? ' · ' + updated.remark : ''}`,
709 - therapistIds: updated.therapistIds, 868 + therapistIds: Array.from(new Set([].concat(updated.therapistIds || []))).filter(Boolean),
710 items: (updated.items || []).map(x => ({ 869 items: (updated.items || []).map(x => ({
711 projectId: x.projectId, 870 projectId: x.projectId,
712 label: x.label, 871 label: x.label,
713 count: (x.count != null ? x.count : x.qty) || 1 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,9 +1019,33 @@ export default {
862 .schedule-legend { 1019 .schedule-legend {
863 display: flex; 1020 display: flex;
864 align-items: center; 1021 align-items: center;
865 - gap: 16px; 1022 + gap: 14px;
866 font-size: 12px; 1023 font-size: 12px;
867 color: #64748b; 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 .legend-item { 1051 .legend-item {
@@ -881,10 +1062,10 @@ export default { @@ -881,10 +1062,10 @@ export default {
881 } 1062 }
882 1063
883 .legend-dot--booked { background: #3b82f6; } 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 .legend-dot--vacation { background: #8b5cf6; } 1068 .legend-dot--vacation { background: #8b5cf6; }
887 -.legend-dot--rest { background: #94a3b8; }  
888 .legend-dot--slot { background: #e2e8f0; } 1069 .legend-dot--slot { background: #e2e8f0; }
889 1070
890 .schedule-wrap { 1071 .schedule-wrap {
@@ -1029,9 +1210,8 @@ export default { @@ -1029,9 +1210,8 @@ export default {
1029 border-radius: 4px; 1210 border-radius: 4px;
1030 display: inline-block; 1211 display: inline-block;
1031 &--normal { background: #f1f5f9; color: #64748b; } 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 &--vacation { background: rgba(139, 92, 246, 0.15); color: #7c3aed; } 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 .day-slots { 1217 .day-slots {
@@ -1044,7 +1224,7 @@ export default { @@ -1044,7 +1224,7 @@ export default {
1044 display: flex; 1224 display: flex;
1045 align-items: center; 1225 align-items: center;
1046 justify-content: center; 1226 justify-content: center;
1047 - background: rgba(249, 115, 22, 0.08); 1227 + background: rgba(148, 163, 184, 0.16);
1048 } 1228 }
1049 &--vacation { 1229 &--vacation {
1050 background: rgba(139, 92, 246, 0.08); 1230 background: rgba(139, 92, 246, 0.08);
@@ -1054,13 +1234,15 @@ export default { @@ -1054,13 +1234,15 @@ export default {
1054 1234
1055 .leave-cover { 1235 .leave-cover {
1056 font-size: 12px; 1236 font-size: 12px;
1057 - color: #ea580c; 1237 + color: #475569;
1058 font-weight: 500; 1238 font-weight: 500;
1059 } 1239 }
1060 1240
1061 .slot { 1241 .slot {
1062 min-height: 20px; 1242 min-height: 20px;
1063 background: #f8fafc; 1243 background: #f8fafc;
  1244 + /* 默认格子边线(预占用块会覆盖为透明以减少分割线) */
  1245 + border: 1px solid rgba(241, 245, 249, 0.95);
1064 1246
1065 &--clickable { 1247 &--clickable {
1066 cursor: pointer; 1248 cursor: pointer;
@@ -1077,7 +1259,16 @@ export default { @@ -1077,7 +1259,16 @@ export default {
1077 &:not(.slot--resize-start):not(.slot--resize-end) { border-radius: 0; } 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 background: rgba(34, 197, 94, 0.5); 1272 background: rgba(34, 197, 94, 0.5);
1082 &:hover { background: rgba(34, 197, 94, 0.7); } 1273 &:hover { background: rgba(34, 197, 94, 0.7); }
1083 &.slot--resize-start.slot--resize-end { border-radius: 2px; } 1274 &.slot--resize-start.slot--resize-end { border-radius: 2px; }
@@ -1087,7 +1278,7 @@ export default { @@ -1087,7 +1278,7 @@ export default {
1087 } 1278 }
1088 1279
1089 .slot--leave { 1280 .slot--leave {
1090 - background: rgba(249, 115, 22, 0.35); 1281 + background: rgba(148, 163, 184, 0.35);
1091 } 1282 }
1092 1283
1093 .slot--in-drag { 1284 .slot--in-drag {
@@ -1102,6 +1293,10 @@ export default { @@ -1102,6 +1293,10 @@ export default {
1102 position: relative; 1293 position: relative;
1103 background: rgba(37, 99, 235, 0.42); 1294 background: rgba(37, 99, 235, 0.42);
1104 &:hover { background: rgba(37, 99, 235, 0.6); } 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 .slot--preconsume-booked { } 1301 .slot--preconsume-booked { }
1107 .slot--preconsume-serving { 1302 .slot--preconsume-serving {
@@ -1117,20 +1312,39 @@ export default { @@ -1117,20 +1312,39 @@ export default {
1117 &:hover { background: rgba(239, 68, 68, 0.6); } 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 .slot--same-active { 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 z-index: 1; 1332 z-index: 1;
1129 } 1333 }
1130 .slot--dimmed { 1334 .slot--dimmed {
1131 opacity: 0.35; 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 ::v-deep .schedule-detail-dialog { 1349 ::v-deep .schedule-detail-dialog {
1136 border-radius: 20px; 1350 border-radius: 20px;
@@ -1187,8 +1401,9 @@ export default { @@ -1187,8 +1401,9 @@ export default {
1187 border-bottom: 1px solid #f1f5f9; 1401 border-bottom: 1px solid #f1f5f9;
1188 font-size: 14px; 1402 font-size: 14px;
1189 .label { width: 90px; color: #64748b; flex-shrink: 0; } 1403 .label { width: 90px; color: #64748b; flex-shrink: 0; }
1190 - .status-confirmed { color: #22c55e; font-weight: 600; }  
1191 .status-booked { color: #3b82f6; font-weight: 600; } 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 .booking-detail-footer { 1409 .booking-detail-footer {
store-pc/src/components/booking-consume-detail-dialog.vue
@@ -2,7 +2,7 @@ @@ -2,7 +2,7 @@
2 <el-dialog 2 <el-dialog
3 :visible.sync="visibleProxy" 3 :visible.sync="visibleProxy"
4 :show-close="false" 4 :show-close="false"
5 - width="520px" 5 + width="700px"
6 :close-on-click-modal="false" 6 :close-on-click-modal="false"
7 custom-class="booking-consume-detail-dialog" 7 custom-class="booking-consume-detail-dialog"
8 append-to-body 8 append-to-body
@@ -15,7 +15,8 @@ @@ -15,7 +15,8 @@
15 15
16 <div class="body"> 16 <div class="body">
17 <div class="row"><span class="label">会员</span><span class="value">{{ booking.memberName || '无' }}</span></div> 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 <div class="row"><span class="label">房间</span><span class="value">{{ booking.roomName || '无' }}</span></div> 20 <div class="row"><span class="label">房间</span><span class="value">{{ booking.roomName || '无' }}</span></div>
20 <div class="row"> 21 <div class="row">
21 <span class="label">健康师</span> 22 <span class="label">健康师</span>
@@ -24,19 +25,31 @@ @@ -24,19 +25,31 @@
24 <span v-else>无</span> 25 <span v-else>无</span>
25 </span> 26 </span>
26 </div> 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 </div> 45 </div>
34 <div class="row"> 46 <div class="row">
35 <span class="label">状态</span> 47 <span class="label">状态</span>
36 <span :class="['value', 'status', 'status--' + (booking.status || 'booked')]">{{ statusText }}</span> 48 <span :class="['value', 'status', 'status--' + (booking.status || 'booked')]">{{ statusText }}</span>
37 </div> 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 </div> 53 </div>
41 </div> 54 </div>
42 55
@@ -51,6 +64,15 @@ @@ -51,6 +64,15 @@
51 取消预约 64 取消预约
52 </el-button> 65 </el-button>
53 <el-button 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 v-if="booking.status === 'booked'" 76 v-if="booking.status === 'booked'"
55 type="primary" 77 type="primary"
56 size="small" 78 size="small"
@@ -65,7 +87,7 @@ @@ -65,7 +87,7 @@
65 size="small" 87 size="small"
66 @click="$emit('convert', booking)" 88 @click="$emit('convert', booking)"
67 > 89 >
68 - 转消耗 90 + 转消耗开单
69 </el-button> 91 </el-button>
70 </div> 92 </div>
71 </div> 93 </div>
@@ -84,23 +106,44 @@ export default { @@ -84,23 +106,44 @@ export default {
84 get() { return this.visible }, 106 get() { return this.visible },
85 set(v) { this.$emit('update:visible', v) } 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 therapistNames() { 116 therapistNames() {
88 const b = this.booking || {} 117 const b = this.booking || {}
89 if (Array.isArray(b.therapistNames) && b.therapistNames.length) return b.therapistNames 118 if (Array.isArray(b.therapistNames) && b.therapistNames.length) return b.therapistNames
90 return [] 119 return []
91 }, 120 },
92 - itemLabels() { 121 + therapistNameMap() {
93 const b = this.booking || {} 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 statusText() { 148 statusText() {
106 const s = (this.booking && this.booking.status) || 'booked' 149 const s = (this.booking && this.booking.status) || 'booked'
@@ -162,6 +205,10 @@ export default { @@ -162,6 +205,10 @@ export default {
162 font-size: 14px; 205 font-size: 14px;
163 } 206 }
164 207
  208 +.row--items {
  209 + align-items: flex-start;
  210 +}
  211 +
165 .label { 212 .label {
166 width: 96px; 213 width: 96px;
167 color: #64748b; 214 color: #64748b;
@@ -172,9 +219,87 @@ export default { @@ -172,9 +219,87 @@ export default {
172 flex: 1; 219 flex: 1;
173 min-width: 0; 220 min-width: 0;
174 color: #111827; 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 .status { 305 .status {
@@ -201,7 +326,7 @@ export default { @@ -201,7 +326,7 @@ export default {
201 } 326 }
202 327
203 ::v-deep .booking-consume-detail-dialog { 328 ::v-deep .booking-consume-detail-dialog {
204 - max-width: 520px; 329 + max-width: 700px;
205 margin-top: 10vh !important; 330 margin-top: 10vh !important;
206 border-radius: 20px; 331 border-radius: 20px;
207 padding: 0; 332 padding: 0;
store-pc/src/components/booking-consume-dialog.vue
@@ -247,13 +247,16 @@ export default { @@ -247,13 +247,16 @@ export default {
247 name: 'BookingConsumeDialog', 247 name: 'BookingConsumeDialog',
248 components: { RoomUsageDialog }, 248 components: { RoomUsageDialog },
249 props: { 249 props: {
250 - visible: { type: Boolean, default: false } 250 + visible: { type: Boolean, default: false },
  251 + prefill: { type: Object, default: () => ({}) }
251 }, 252 },
252 data() { 253 data() {
253 return { 254 return {
254 submitting: false, 255 submitting: false,
255 roomUsageVisible: false, 256 roomUsageVisible: false,
256 form: this.createEmptyForm(), 257 form: this.createEmptyForm(),
  258 + editId: '',
  259 + editMeta: null,
257 memberOptions: [ 260 memberOptions: [
258 { value: 'cust001', label: '林小纤', phone: '13800138000' }, 261 { value: 'cust001', label: '林小纤', phone: '13800138000' },
259 { value: 'cust002', label: '王丽', phone: '13800138001' }, 262 { value: 'cust002', label: '王丽', phone: '13800138001' },
@@ -351,6 +354,12 @@ export default { @@ -351,6 +354,12 @@ export default {
351 watch: { 354 watch: {
352 visible(val) { 355 visible(val) {
353 if (val) this.resetFormForOpen() 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 methods: { 365 methods: {
@@ -390,13 +399,67 @@ export default { @@ -390,13 +399,67 @@ export default {
390 }, 399 },
391 resetFormForOpen() { 400 resetFormForOpen() {
392 this.form = this.createEmptyForm() 401 this.form = this.createEmptyForm()
  402 + this.editId = ''
  403 + this.editMeta = null
393 this.roomUsageVisible = false 404 this.roomUsageVisible = false
394 this.submitting = false 405 this.submitting = false
395 this.availableItems = [] 406 this.availableItems = []
  407 + this.applyPrefill()
396 this.$nextTick(() => { 408 this.$nextTick(() => {
397 this.$refs.form && this.$refs.form.clearValidate() 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 onMemberChange(val) { 463 onMemberChange(val) {
401 const m = this.memberOptions.find(x => x.value === val) 464 const m = this.memberOptions.find(x => x.value === val)
402 this.form.memberName = m ? m.label : '' 465 this.form.memberName = m ? m.label : ''
@@ -448,7 +511,7 @@ export default { @@ -448,7 +511,7 @@ export default {
448 if (!valid) return 511 if (!valid) return
449 this.submitting = true 512 this.submitting = true
450 setTimeout(() => { 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 const dateStr = this.formatDate(this.form.date) 515 const dateStr = this.formatDate(this.form.date)
453 const therapistIds = this.selectedTherapistIds 516 const therapistIds = this.selectedTherapistIds
454 const therapistNames = this.selectedTherapistNames 517 const therapistNames = this.selectedTherapistNames
@@ -479,12 +542,17 @@ export default { @@ -479,12 +542,17 @@ export default {
479 itemLabels, 542 itemLabels,
480 remark: this.form.remark || '', 543 remark: this.form.remark || '',
481 status: 'booked', 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 const list = this.readStorage() 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 this.writeStorage(list) 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 this.submitting = false 556 this.submitting = false
489 this.$emit('saved', record) 557 this.$emit('saved', record)
490 }, 500) 558 }, 500)
store-pc/src/views/dashboard/index.vue
@@ -377,7 +377,7 @@ @@ -377,7 +377,7 @@
377 /> 377 />
378 <booking-dialog :visible.sync="bookingDialogVisible" :prefill="bookingPrefill" /> 378 <booking-dialog :visible.sync="bookingDialogVisible" :prefill="bookingPrefill" />
379 <billing-dialog :visible.sync="billingDialogVisible" :prefill="billingPrefill" /> 379 <billing-dialog :visible.sync="billingDialogVisible" :prefill="billingPrefill" />
380 - <consume-dialog :visible.sync="consumeDialogVisible" /> 380 + <consume-dialog :visible.sync="consumeDialogVisible" :prefill="consumePrefill" />
381 <refund-dialog :visible.sync="refundDialogVisible" /> 381 <refund-dialog :visible.sync="refundDialogVisible" />
382 <tuoke-list-dialog :visible.sync="tuokeListVisible" /> 382 <tuoke-list-dialog :visible.sync="tuokeListVisible" />
383 <booking-calendar-dialog :visible.sync="bookingCalendarVisible" /> 383 <booking-calendar-dialog :visible.sync="bookingCalendarVisible" />
@@ -807,6 +807,7 @@ export default { @@ -807,6 +807,7 @@ export default {
807 billingDialogVisible: false, 807 billingDialogVisible: false,
808 billingPrefill: {}, 808 billingPrefill: {},
809 consumeDialogVisible: false, 809 consumeDialogVisible: false,
  810 + consumePrefill: {},
810 refundDialogVisible: false, 811 refundDialogVisible: false,
811 tuokeListVisible: false, 812 tuokeListVisible: false,
812 bookingCalendarVisible: false, 813 bookingCalendarVisible: false,
@@ -942,6 +943,8 @@ export default { @@ -942,6 +943,8 @@ export default {
942 fmt() 943 fmt()
943 this.clockTimer = setInterval(fmt, 1000) 944 this.clockTimer = setInterval(fmt, 1000)
944 945
  946 + this.tryOpenConsumeFromPrefillOnce()
  947 +
945 // 提醒中心默认展开/收起与上次状态(可配置) 948 // 提醒中心默认展开/收起与上次状态(可配置)
946 const savedOpen = localStorage.getItem('store_reminder_open') 949 const savedOpen = localStorage.getItem('store_reminder_open')
947 const savedType = localStorage.getItem('store_reminder_type') 950 const savedType = localStorage.getItem('store_reminder_type')
@@ -965,6 +968,28 @@ export default { @@ -965,6 +968,28 @@ export default {
965 clearInterval(this.clockTimer) 968 clearInterval(this.clockTimer)
966 }, 969 },
967 methods: { 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 toggleFullscreen() { 993 toggleFullscreen() {
969 if (!document.fullscreenElement) { 994 if (!document.fullscreenElement) {
970 document.documentElement.requestFullscreen().catch(() => { }) 995 document.documentElement.requestFullscreen().catch(() => { })