Commit c8f0ff5f17a68c17bd3bb428e57a84fcc8a48f65

Authored by “wangming”
1 parent 06da27f0

Refactor LqCooperationCostService to streamline export functionality, ensuring a…

…ll data is exported without pagination. Enhance error handling for total amount validation to support negative values and various formats. Add a new method for parsing decimal values, accommodating accounting formats. Update documentation for clarity on export parameters and import requirements.
netcore/src/Modularity/Extend/NCC.Extend/LqCooperationCostService.cs
... ... @@ -25,6 +25,7 @@ using NCC.ClayObject;
25 25 using NCC.Common.Const;
26 26 using NCC.Extend.Entitys.Enum;
27 27 using Microsoft.AspNetCore.Http;
  28 +using System.Globalization;
28 29 using System.IO;
29 30 using System.Data;
30 31  
... ... @@ -288,22 +289,17 @@ namespace NCC.Extend.LqCooperationCost
288 289 /// <summary>
289 290 /// 导出合作成本表
290 291 /// </summary>
  292 + /// <remarks>
  293 + /// 未传入任何筛选参数时,默认导出全部数据;传入 storeId、storeName、year、month 时按条件筛选导出。
  294 + /// </remarks>
291 295 /// <param name="input">请求参数</param>
292 296 /// <returns></returns>
293 297 [HttpGet("Actions/Export")]
294 298 public async Task<dynamic> Export([FromQuery] LqCooperationCostListQueryInput input)
295 299 {
296 300 var userInfo = await _userManager.GetUserInfo();
297   - var exportData = new List<LqCooperationCostListOutput>();
298   - if (input.dataType == 0)
299   - {
300   - var data = Clay.Object(await this.GetList(input));
301   - exportData = data.Solidify<PageResult<LqCooperationCostListOutput>>().list;
302   - }
303   - else
304   - {
305   - exportData = await this.GetNoPagingList(input);
306   - }
  301 + // 导出时统一使用无分页列表:未传参数则导出全部,有参数则按条件筛选
  302 + var exportData = await this.GetNoPagingList(input);
307 303 List<ParamsModel> paramList = "[{\"value\":\"门店名称\",\"field\":\"storeName\"},{\"value\":\"年份\",\"field\":\"year\"},{\"value\":\"月份\",\"field\":\"month\"},{\"value\":\"合计金额\",\"field\":\"totalAmount\"},{\"value\":\"成本类型\",\"field\":\"costType\"},{\"value\":\"备注说明\",\"field\":\"remarks\"},{\"value\":\"创建人\",\"field\":\"createUser\"},{\"value\":\"创建时间\",\"field\":\"createTime\"},]".ToList<ParamsModel>();
308 304 ExcelConfig excelconfig = new ExcelConfig();
309 305 excelconfig.FileName = "合作成本表.xls";
... ... @@ -402,6 +398,9 @@ namespace NCC.Extend.LqCooperationCost
402 398 /// 第一行为标题行:门店名称、年份、月份、合计金额、成本类型、备注说明
403 399 /// 从第二行开始为数据行
404 400 ///
  401 + /// 合计金额支持正数和负数,负数可用于冲减、退款等场景;
  402 + /// 支持格式:-100、-100.50、会计格式(100)等
  403 + ///
405 404 /// 注意:导入时通过门店名称查找门店ID,不需要填写门店ID
406 405 ///
407 406 /// 示例请求:
... ... @@ -511,10 +510,17 @@ namespace NCC.Extend.LqCooperationCost
511 510 continue;
512 511 }
513 512  
514   - // 验证合计金额
515   - if (string.IsNullOrEmpty(totalAmountText) || !decimal.TryParse(totalAmountText, out decimal totalAmount))
  513 + // 验证合计金额(支持正数和负数,如冲减、退款等场景)
  514 + if (string.IsNullOrEmpty(totalAmountText))
  515 + {
  516 + failMessages.Add($"第{i + 1}行:合计金额不能为空");
  517 + failCount++;
  518 + continue;
  519 + }
  520 +
  521 + if (!TryParseDecimalAllowNegative(totalAmountText, out decimal totalAmount))
516 522 {
517   - failMessages.Add($"第{i + 1}行:合计金额格式错误(应为数字)");
  523 + failMessages.Add($"第{i + 1}行:合计金额格式错误(应为数字,支持负数如-100或会计格式(100))");
518 524 failCount++;
519 525 continue;
520 526 }
... ... @@ -616,6 +622,41 @@ namespace NCC.Extend.LqCooperationCost
616 622 throw NCCException.Oh($"导入失败:{ex.Message}");
617 623 }
618 624 }
  625 +
  626 + /// <summary>
  627 + /// 解析合计金额字符串,支持正数和负数
  628 + /// </summary>
  629 + /// <param name="text">待解析的字符串</param>
  630 + /// <param name="result">解析结果</param>
  631 + /// <returns>是否解析成功</returns>
  632 + /// <remarks>
  633 + /// 支持格式:-100、-100.50、会计格式(100)或(100)、千分位1,234.56等
  634 + /// </remarks>
  635 + private static bool TryParseDecimalAllowNegative(string text, out decimal result)
  636 + {
  637 + result = 0;
  638 + if (string.IsNullOrWhiteSpace(text)) return false;
  639 +
  640 + var cleaned = text.Trim()
  641 + .Replace(",", "")
  642 + .Replace(",", "")
  643 + .Replace("¥", "")
  644 + .Replace("$", "")
  645 + .Replace("元", "")
  646 + .Replace(" ", "");
  647 +
  648 + // 会计格式:(100) 或 (100)表示负数
  649 + if (cleaned.StartsWith("(") && cleaned.EndsWith(")"))
  650 + {
  651 + cleaned = "-" + cleaned.Substring(1, cleaned.Length - 2).Trim();
  652 + }
  653 + else if (cleaned.StartsWith("(") && cleaned.EndsWith(")"))
  654 + {
  655 + cleaned = "-" + cleaned.Substring(1, cleaned.Length - 2).Trim();
  656 + }
  657 +
  658 + return decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out result);
  659 + }
619 660 }
620 661 }
621 662  
... ...
netcore/src/Modularity/Message/NCC.Message.Entitys/NCC.Message.Entitys.csproj
1   -<Project Sdk="Microsoft.NET.Sdk">
  1 +<Project Sdk="Microsoft.NET.Sdk">
2 2  
3 3 <PropertyGroup>
4 4 <TargetFramework>net6.0</TargetFramework>
5 5 </PropertyGroup>
6 6  
  7 + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
  8 + <DocumentationFile>bin\Debug\$(AssemblyName).xml</DocumentationFile>
  9 + </PropertyGroup>
7 10 <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
8   - <DocumentationFile>D:\wesley\project\git\antis-food-alliance\netcore\src\Modularity\Message\NCC.Message.Entitys\NCC.Message.Entitys.xml</DocumentationFile>
  11 + <DocumentationFile>bin\Release\$(AssemblyName).xml</DocumentationFile>
9 12 </PropertyGroup>
10 13  
11 14 <ItemGroup>
... ...
store-pc/src/components/ConsumeDialog.vue
... ... @@ -372,7 +372,37 @@ export default {
372 372 this.form.memberName = this.prefill.name || ''
373 373 this.loadMemberItems(this.prefill.memberId)
374 374 }
  375 + if (this.prefill && this.prefill.consumeDate) {
  376 + const d = this.prefill.consumeDate instanceof Date ? this.prefill.consumeDate : new Date(this.prefill.consumeDate)
  377 + if (!Number.isNaN(d.getTime())) this.form.consumeDate = d
  378 + }
  379 + if (this.prefill && this.prefill.remark) {
  380 + this.form.remark = this.prefill.remark
  381 + }
375 382 this.$nextTick(() => {
  383 + // 预填品项(用于“预约消耗单 -> 转消耗”闭环,mock 预填即可)
  384 + const preItems = (this.prefill && Array.isArray(this.prefill.items)) ? this.prefill.items : null
  385 + if (preItems && preItems.length) {
  386 + this.form.items = preItems.map(pi => {
  387 + const it = this.createEmptyItem()
  388 + it.projectId = pi.projectId || ''
  389 + it.count = pi.count || 1
  390 + return it
  391 + })
  392 + // 触发品项信息填充与业绩分配
  393 + this.form.items.forEach((_, idx) => {
  394 + if (this.form.items[idx].projectId) this.onProjectChange(idx)
  395 + })
  396 + }
  397 +
  398 + // 预填健康师(把 therapistIds 映射到每个品项的 workers)
  399 + const tids = (this.prefill && Array.isArray(this.prefill.therapistIds)) ? this.prefill.therapistIds : null
  400 + if (tids && tids.length) {
  401 + this.form.items.forEach((item, idx) => {
  402 + item.workers = tids.map(tid => ({ workerId: tid, performance: '', laborCost: '', count: '' }))
  403 + this.redistributeWorkers(idx)
  404 + })
  405 + }
376 406 this.$refs.form && this.$refs.form.clearValidate()
377 407 })
378 408 },
... ...
store-pc/src/components/EmployeeScheduleDialog.vue
... ... @@ -103,7 +103,7 @@
103 103 </div>
104 104 </div>
105 105  
106   - <!-- 预约详情弹窗:风格与主弹窗统一 -->
  106 + <!-- 预约详情弹窗:风格与主弹窗统一(旧预约 mock) -->
107 107 <el-dialog
108 108 :visible.sync="detailDialogVisible"
109 109 width="440px"
... ... @@ -137,15 +137,35 @@
137 137 :prefill="addBookingPrefill"
138 138 :health-worker-options="healthWorkerOptionsFromEmployees"
139 139 />
  140 +
  141 + <!-- 预约消耗单详情(新:支持取消/开始服务/转消耗) -->
  142 + <booking-consume-detail-dialog
  143 + :visible.sync="preConsumeDetailVisible"
  144 + :booking="selectedPreConsumeBooking"
  145 + @cancel="handlePreConsumeCancel"
  146 + @start="handlePreConsumeStart"
  147 + @convert="handlePreConsumeConvert"
  148 + />
  149 +
  150 + <!-- 转消耗:复用现有 ConsumeDialog(仅 mock 预填) -->
  151 + <consume-dialog
  152 + :visible.sync="consumeDialogVisible"
  153 + :prefill="consumePrefill"
  154 + @submitted="handleConsumeSubmitted"
  155 + />
140 156 </el-dialog>
141 157 </template>
142 158  
143 159 <script>
144 160 import BookingDialog from '@/components/BookingDialog.vue'
  161 +import BookingConsumeDetailDialog from '@/components/booking-consume-detail-dialog.vue'
  162 +import ConsumeDialog from '@/components/ConsumeDialog.vue'
  163 +
  164 +const PRE_CONSUME_STORAGE_KEY = 'store_pc_pre_consume_bookings'
145 165  
146 166 export default {
147 167 name: 'EmployeeScheduleDialog',
148   - components: { BookingDialog },
  168 + components: { BookingDialog, BookingConsumeDetailDialog, ConsumeDialog },
149 169 props: {
150 170 visible: { type: Boolean, default: false },
151 171 openMode: { type: String, default: 'view' }
... ... @@ -159,6 +179,12 @@ export default {
159 179 selectedBooking: null,
160 180 addBookingPrefill: {},
161 181 dragState: null,
  182 + preConsumeBookings: [],
  183 + activePreConsumeId: '',
  184 + preConsumeDetailVisible: false,
  185 + selectedPreConsumeBooking: null,
  186 + consumeDialogVisible: false,
  187 + consumePrefill: {},
162 188 employeeList: [
163 189 { id: 'E001', name: '董顺秀', role: '健康师' },
164 190 { id: 'E002', name: '张丽', role: '健康师' },
... ... @@ -260,24 +286,82 @@ export default {
260 286 const timeEnd = `${String(Math.floor(b.slotEnd / 2)).padStart(2, '0')}:${(b.slotEnd % 2) ? '30' : '00'}`
261 287 return { ...b, date: `${y}-${m}-${day}`, timeRange: `${timeStart}-${timeEnd}` }
262 288 })
  289 + },
  290 + preConsumeBookingsWithDate() {
  291 + if (!this.weekStart) return []
  292 + const toSlot = (timeStr) => {
  293 + if (!timeStr) return null
  294 + const [h, m] = timeStr.split(':').map(Number)
  295 + if (Number.isNaN(h) || Number.isNaN(m)) return null
  296 + return h * 2 + (m >= 30 ? 1 : 0)
  297 + }
  298 + return (this.preConsumeBookings || []).map(b => {
  299 + const slotStart = toSlot(b.startTime)
  300 + const slotEnd = toSlot(b.endTime)
  301 + return {
  302 + ...b,
  303 + slotStart: slotStart == null ? 0 : slotStart,
  304 + slotEnd: slotEnd == null ? 0 : slotEnd
  305 + }
  306 + })
263 307 }
264 308 },
265 309 watch: {
266 310 visible(v) {
267 311 if (v && !this.weekStart) this.initWeek()
  312 + if (v) this.loadPreConsumeBookings()
268 313 if (v && this.openMode === 'set') {
269 314 this.$nextTick(() => {
270 315 this.gotoNextWeek()
271 316 this.$store.commit('SET_SCHEDULE_DIALOG_MODE', 'view')
272 317 })
273 318 }
274   - if (!v) this.selectedDay = null
  319 + if (!v) {
  320 + this.selectedDay = null
  321 + this.activePreConsumeId = ''
  322 + this.preConsumeDetailVisible = false
  323 + }
275 324 }
276 325 },
277 326 mounted() {
278 327 this.initWeek()
  328 + this.loadPreConsumeBookings()
279 329 },
280 330 methods: {
  331 + readPreConsumeStorage() {
  332 + try {
  333 + const raw = localStorage.getItem(PRE_CONSUME_STORAGE_KEY)
  334 + if (!raw) return []
  335 + const arr = JSON.parse(raw)
  336 + return Array.isArray(arr) ? arr : []
  337 + } catch (e) {
  338 + return []
  339 + }
  340 + },
  341 + writePreConsumeStorage(list) {
  342 + localStorage.setItem(PRE_CONSUME_STORAGE_KEY, JSON.stringify(list || []))
  343 + },
  344 + loadPreConsumeBookings() {
  345 + const list = this.readPreConsumeStorage()
  346 + this.preConsumeBookings = list
  347 + },
  348 + findPreConsumeForSlot(empId, date, slotIndex) {
  349 + const list = this.preConsumeBookingsWithDate
  350 + return list.find(x =>
  351 + Array.isArray(x.therapistIds) &&
  352 + x.therapistIds.includes(empId) &&
  353 + x.date === date &&
  354 + slotIndex >= x.slotStart &&
  355 + slotIndex < x.slotEnd
  356 + )
  357 + },
  358 + preConsumeStatusClass(status) {
  359 + const s = status || 'booked'
  360 + if (s === 'serving') return 'slot--preconsume-serving'
  361 + if (s === 'converted') return 'slot--preconsume-converted'
  362 + if (s === 'cancelled') return 'slot--preconsume-cancelled'
  363 + return 'slot--preconsume-booked'
  364 + },
281 365 initWeek() {
282 366 const now = new Date()
283 367 const dow = now.getDay()
... ... @@ -354,6 +438,17 @@ export default {
354 438 return slotIndex >= lo && slotIndex <= hi
355 439 },
356 440 slotClass(empId, date, slotIndex) {
  441 + const pcb = this.findPreConsumeForSlot(empId, date, slotIndex)
  442 + if (pcb) {
  443 + 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'
  446 + if (this.activePreConsumeId) {
  447 + if (pcb.id === this.activePreConsumeId) c += ' slot--same-active'
  448 + else c += ' slot--dimmed'
  449 + }
  450 + return c
  451 + }
357 452 const b = this.bookingsWithDate.find(x =>
358 453 x.employeeId === empId &&
359 454 x.date === date &&
... ... @@ -370,6 +465,13 @@ export default {
370 465 return ''
371 466 },
372 467 getSlotTooltip(empId, date, slotIndex) {
  468 + const pcb = this.findPreConsumeForSlot(empId, date, slotIndex)
  469 + if (pcb) {
  470 + const items = Array.isArray(pcb.itemLabels) && pcb.itemLabels.length ? pcb.itemLabels.join('、') : ''
  471 + const statusMap = { booked: '已预约', serving: '服务中', converted: '已转消耗', cancelled: '已取消' }
  472 + const st = statusMap[pcb.status] || '已预约'
  473 + return `${pcb.startTime}-${pcb.endTime} ${pcb.memberName || '无'}(${st})${items ? ' · ' + items : ''}`
  474 + }
373 475 const b = this.bookingsWithDate.find(x =>
374 476 x.employeeId === empId &&
375 477 x.date === date &&
... ... @@ -405,6 +507,13 @@ export default {
405 507 this.addBookingDialogVisible = true
406 508 },
407 509 handleSlotMouseDown(e, emp, date, slotIndex) {
  510 + const pcb = this.findPreConsumeForSlot(emp.id, date, slotIndex)
  511 + if (pcb) {
  512 + this.activePreConsumeId = pcb.id
  513 + this.selectedPreConsumeBooking = pcb
  514 + this.preConsumeDetailVisible = true
  515 + return
  516 + }
408 517 const b = this.bookingsWithDate.find(x =>
409 518 x.employeeId === emp.id &&
410 519 x.date === date &&
... ... @@ -558,6 +667,59 @@ export default {
558 667 if (!d || this.dragState.dayOffset !== d.dayOffset) return
559 668 this.dragState.endSlot = slotIndex
560 669 },
  670 + updatePreConsumeStatus(id, status) {
  671 + const list = this.readPreConsumeStorage()
  672 + const idx = list.findIndex(x => x && x.id === id)
  673 + if (idx < 0) return null
  674 + const updated = { ...list[idx], status }
  675 + list.splice(idx, 1, updated)
  676 + this.writePreConsumeStorage(list)
  677 + this.preConsumeBookings = list
  678 + return updated
  679 + },
  680 + handlePreConsumeCancel(b) {
  681 + if (!b) return
  682 + this.$confirm(`确定取消「${b.memberName || '该会员'}」在 ${b.date} ${b.startTime}-${b.endTime} 的预约吗?`, '取消预约确认', {
  683 + confirmButtonText: '确定',
  684 + cancelButtonText: '再想想',
  685 + type: 'warning'
  686 + }).then(() => {
  687 + const updated = this.updatePreConsumeStatus(b.id, 'cancelled')
  688 + if (updated) this.selectedPreConsumeBooking = updated
  689 + this.$message.success('已取消预约')
  690 + }).catch(() => {})
  691 + },
  692 + handlePreConsumeStart(b) {
  693 + if (!b) return
  694 + const updated = this.updatePreConsumeStatus(b.id, 'serving')
  695 + if (updated) this.selectedPreConsumeBooking = updated
  696 + this.$message.success('已开始服务')
  697 + },
  698 + handlePreConsumeConvert(b) {
  699 + if (!b) return
  700 + const updated = this.updatePreConsumeStatus(b.id, 'converted') || b
  701 + this.selectedPreConsumeBooking = updated
  702 + this.preConsumeDetailVisible = false
  703 +
  704 + this.consumePrefill = {
  705 + memberId: updated.memberId,
  706 + name: updated.memberName,
  707 + consumeDate: updated.date,
  708 + remark: `转自预约消耗单:${updated.id}${updated.remark ? ' · ' + updated.remark : ''}`,
  709 + therapistIds: updated.therapistIds,
  710 + items: (updated.items || []).map(x => ({
  711 + projectId: x.projectId,
  712 + label: x.label,
  713 + count: (x.count != null ? x.count : x.qty) || 1
  714 + }))
  715 + }
  716 + this.consumeDialogVisible = true
  717 + },
  718 + handleConsumeSubmitted() {
  719 + // mock:提交后保持 converted 状态即可
  720 + this.consumeDialogVisible = false
  721 + this.$message.success('已完成转消耗(示例)')
  722 + }
561 723 }
562 724 }
563 725 </script>
... ... @@ -935,6 +1097,40 @@ export default {
935 1097 .slot--resize-start { cursor: w-resize; }
936 1098 .slot--resize-end { cursor: e-resize; }
937 1099  
  1100 +/* 预约消耗单块:不同状态 + 同单高亮 */
  1101 +.slot--preconsume {
  1102 + position: relative;
  1103 + background: rgba(37, 99, 235, 0.42);
  1104 + &:hover { background: rgba(37, 99, 235, 0.6); }
  1105 +}
  1106 +.slot--preconsume-booked { }
  1107 +.slot--preconsume-serving {
  1108 + background: rgba(249, 115, 22, 0.55);
  1109 + &:hover { background: rgba(249, 115, 22, 0.7); }
  1110 +}
  1111 +.slot--preconsume-converted {
  1112 + background: rgba(34, 197, 94, 0.5);
  1113 + &:hover { background: rgba(34, 197, 94, 0.7); }
  1114 +}
  1115 +.slot--preconsume-cancelled {
  1116 + background: rgba(239, 68, 68, 0.45);
  1117 + &:hover { background: rgba(239, 68, 68, 0.6); }
  1118 +}
  1119 +
  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); }
  1125 +
  1126 +.slot--same-active {
  1127 + outline: 2px solid rgba(17, 24, 39, 0.25);
  1128 + z-index: 1;
  1129 +}
  1130 +.slot--dimmed {
  1131 + opacity: 0.35;
  1132 +}
  1133 +
938 1134 /* 预约详情弹窗 - 与主弹窗风格统一 */
939 1135 ::v-deep .schedule-detail-dialog {
940 1136 border-radius: 20px;
... ...
store-pc/src/components/booking-consume-detail-dialog.vue 0 → 100644
  1 +<template>
  2 + <el-dialog
  3 + :visible.sync="visibleProxy"
  4 + :show-close="false"
  5 + width="520px"
  6 + :close-on-click-modal="false"
  7 + custom-class="booking-consume-detail-dialog"
  8 + append-to-body
  9 + >
  10 + <div v-if="booking" class="inner">
  11 + <div class="header">
  12 + <div class="title">预约消耗单详情</div>
  13 + <span class="close" @click="visibleProxy = false"><i class="el-icon-close"></i></span>
  14 + </div>
  15 +
  16 + <div class="body">
  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>
  19 + <div class="row"><span class="label">房间</span><span class="value">{{ booking.roomName || '无' }}</span></div>
  20 + <div class="row">
  21 + <span class="label">健康师</span>
  22 + <span class="value">
  23 + <span v-if="therapistNames.length">{{ therapistNames.join('、') }}</span>
  24 + <span v-else>无</span>
  25 + </span>
  26 + </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>
  33 + </div>
  34 + <div class="row">
  35 + <span class="label">状态</span>
  36 + <span :class="['value', 'status', 'status--' + (booking.status || 'booked')]">{{ statusText }}</span>
  37 + </div>
  38 + <div class="row" v-if="booking.remark">
  39 + <span class="label">备注</span><span class="value">{{ booking.remark }}</span>
  40 + </div>
  41 + </div>
  42 +
  43 + <div class="footer">
  44 + <el-button size="small" @click="visibleProxy = false">关 闭</el-button>
  45 + <el-button
  46 + v-if="booking.status !== 'cancelled' && booking.status !== 'converted'"
  47 + size="small"
  48 + class="danger-btn"
  49 + @click="$emit('cancel', booking)"
  50 + >
  51 + 取消预约
  52 + </el-button>
  53 + <el-button
  54 + v-if="booking.status === 'booked'"
  55 + type="primary"
  56 + size="small"
  57 + @click="$emit('start', booking)"
  58 + >
  59 + 开始服务
  60 + </el-button>
  61 + <el-button
  62 + v-if="booking.status !== 'converted'"
  63 + type="primary"
  64 + plain
  65 + size="small"
  66 + @click="$emit('convert', booking)"
  67 + >
  68 + 转消耗
  69 + </el-button>
  70 + </div>
  71 + </div>
  72 + </el-dialog>
  73 +</template>
  74 +
  75 +<script>
  76 +export default {
  77 + name: 'BookingConsumeDetailDialog',
  78 + props: {
  79 + visible: { type: Boolean, default: false },
  80 + booking: { type: Object, default: null }
  81 + },
  82 + computed: {
  83 + visibleProxy: {
  84 + get() { return this.visible },
  85 + set(v) { this.$emit('update:visible', v) }
  86 + },
  87 + therapistNames() {
  88 + const b = this.booking || {}
  89 + if (Array.isArray(b.therapistNames) && b.therapistNames.length) return b.therapistNames
  90 + return []
  91 + },
  92 + itemLabels() {
  93 + 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 []
  104 + },
  105 + statusText() {
  106 + const s = (this.booking && this.booking.status) || 'booked'
  107 + const map = {
  108 + booked: '已预约',
  109 + serving: '服务中',
  110 + converted: '已转消耗',
  111 + cancelled: '已取消'
  112 + }
  113 + return map[s] || '已预约'
  114 + }
  115 + }
  116 +}
  117 +</script>
  118 +
  119 +<style lang="scss" scoped>
  120 +.inner {
  121 + padding: 18px 22px 14px;
  122 +}
  123 +
  124 +.header {
  125 + display: flex;
  126 + align-items: center;
  127 + justify-content: space-between;
  128 + gap: 10px;
  129 + margin-bottom: 12px;
  130 + padding: 10px 14px;
  131 + border-radius: 14px;
  132 + background: rgba(219, 234, 254, 0.96);
  133 +}
  134 +
  135 +.title {
  136 + font-size: 17px;
  137 + font-weight: 600;
  138 + color: #0f172a;
  139 +}
  140 +
  141 +.close {
  142 + cursor: pointer;
  143 + width: 28px;
  144 + height: 28px;
  145 + display: flex;
  146 + align-items: center;
  147 + justify-content: center;
  148 + border-radius: 999px;
  149 + color: #64748b;
  150 + transition: all 0.15s;
  151 + &:hover { background: rgba(0, 0, 0, 0.06); color: #0f172a; }
  152 +}
  153 +
  154 +.body {
  155 + padding: 4px 0 16px;
  156 +}
  157 +
  158 +.row {
  159 + display: flex;
  160 + padding: 10px 0;
  161 + border-bottom: 1px solid #f1f5f9;
  162 + font-size: 14px;
  163 +}
  164 +
  165 +.label {
  166 + width: 96px;
  167 + color: #64748b;
  168 + flex-shrink: 0;
  169 +}
  170 +
  171 +.value {
  172 + flex: 1;
  173 + min-width: 0;
  174 + color: #111827;
  175 + white-space: nowrap;
  176 + overflow: hidden;
  177 + text-overflow: ellipsis;
  178 +}
  179 +
  180 +.status {
  181 + font-weight: 600;
  182 +}
  183 +.status--booked { color: #2563eb; }
  184 +.status--serving { color: #f97316; }
  185 +.status--converted { color: #16a34a; }
  186 +.status--cancelled { color: #ef4444; }
  187 +
  188 +.footer {
  189 + display: flex;
  190 + justify-content: flex-end;
  191 + gap: 10px;
  192 + padding-top: 12px;
  193 + border-top: 1px solid #f1f5f9;
  194 +}
  195 +
  196 +.danger-btn {
  197 + border-radius: 999px !important;
  198 + background: rgba(254, 226, 226, 0.8) !important;
  199 + color: #b91c1c !important;
  200 + border-color: rgba(239, 68, 68, 0.25) !important;
  201 +}
  202 +
  203 +::v-deep .booking-consume-detail-dialog {
  204 + max-width: 520px;
  205 + margin-top: 10vh !important;
  206 + border-radius: 20px;
  207 + padding: 0;
  208 + 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%);
  209 + box-shadow: 0 24px 48px rgba(15,23,42,0.18), 0 0 0 1px rgba(255,255,255,0.9);
  210 + backdrop-filter: blur(22px);
  211 + -webkit-backdrop-filter: blur(22px);
  212 +}
  213 +::v-deep .booking-consume-detail-dialog .el-dialog__header { display: none; }
  214 +::v-deep .booking-consume-detail-dialog .el-dialog__body { padding: 0; }
  215 +::v-deep .booking-consume-detail-dialog .el-button--primary {
  216 + border-radius: 999px;
  217 + padding: 0 18px;
  218 + height: 30px;
  219 + line-height: 30px;
  220 + background: #2563eb;
  221 + border-color: #2563eb;
  222 + box-shadow: 0 4px 10px rgba(37, 99, 235, 0.35);
  223 + font-size: 12px;
  224 +}
  225 +::v-deep .booking-consume-detail-dialog .el-button--default {
  226 + border-radius: 999px;
  227 + padding: 0 18px;
  228 + height: 30px;
  229 + line-height: 30px;
  230 + background: rgba(239, 246, 255, 0.9);
  231 + color: #2563eb;
  232 + border-color: rgba(37, 99, 235, 0.18);
  233 + font-size: 12px;
  234 +}
  235 +</style>
  236 +
... ...
store-pc/src/components/booking-consume-dialog.vue 0 → 100644
  1 +<template>
  2 + <el-dialog
  3 + :visible.sync="visibleProxy"
  4 + :show-close="false"
  5 + width="1200px"
  6 + :close-on-click-modal="false"
  7 + custom-class="booking-consume-dialog"
  8 + append-to-body
  9 + >
  10 + <div class="consume-dialog-inner">
  11 + <div class="consume-header">
  12 + <div class="consume-title-wrap">
  13 + <div class="consume-title">预约消耗单</div>
  14 + <div class="consume-subtitle" v-if="form.memberName">{{ form.memberName }}</div>
  15 + </div>
  16 + <div class="consume-header-actions">
  17 + <el-button size="mini" class="ghost-btn" @click="roomUsageVisible = true">
  18 + <i class="el-icon-office-building"></i> 查看房态
  19 + </el-button>
  20 + <span class="consume-close" @click="handleCancel">
  21 + <i class="el-icon-close"></i>
  22 + </span>
  23 + </div>
  24 + </div>
  25 +
  26 + <div class="consume-content">
  27 + <el-form
  28 + ref="form"
  29 + :model="form"
  30 + :rules="rules"
  31 + label-width="96px"
  32 + size="small"
  33 + class="consume-form"
  34 + >
  35 + <div class="consume-left">
  36 + <div class="section-title"><i class="el-icon-document"></i> 基础信息</div>
  37 +
  38 + <el-form-item label="会员" prop="memberId">
  39 + <el-select v-model="form.memberId" placeholder="搜索会员" filterable clearable @change="onMemberChange">
  40 + <el-option v-for="m in memberOptions" :key="m.value" :label="`${m.label}(${m.phone})`" :value="m.value" />
  41 + </el-select>
  42 + </el-form-item>
  43 +
  44 + <el-form-item label="预约日期" prop="date">
  45 + <el-date-picker v-model="form.date" type="date" placeholder="选择日期" style="width:100%" />
  46 + </el-form-item>
  47 +
  48 + <el-form-item label="开始时间" prop="startTime">
  49 + <el-time-select v-model="form.startTime" :picker-options="timeOptions" placeholder="开始时间" style="width:100%" />
  50 + </el-form-item>
  51 + <el-form-item label="结束时间" prop="endTime">
  52 + <el-time-select v-model="form.endTime" :picker-options="timeOptionsEnd" placeholder="结束时间" style="width:100%" />
  53 + </el-form-item>
  54 +
  55 + <el-form-item label="房间" prop="roomId">
  56 + <el-select v-model="form.roomId" placeholder="选择房间" filterable clearable @change="onRoomChange">
  57 + <el-option v-for="r in roomOptions" :key="r.id" :label="r.name" :value="r.id" />
  58 + </el-select>
  59 + </el-form-item>
  60 +
  61 + <el-form-item label="服务健康师">
  62 + <div class="readonly-workers">
  63 + <span v-if="selectedTherapistNames.length" class="readonly-workers-text">{{ selectedTherapistNames.join('、') }}</span>
  64 + <span v-else class="readonly-workers-empty">请在右侧品项中选择服务健康师</span>
  65 + </div>
  66 + </el-form-item>
  67 +
  68 + <el-form-item label="备注">
  69 + <el-input v-model="form.remark" type="textarea" :rows="2" placeholder="请输入备注信息" />
  70 + </el-form-item>
  71 + </div>
  72 +
  73 + <div class="consume-right">
  74 + <div class="section-title"><i class="el-icon-goods"></i> 品项明细</div>
  75 +
  76 + <div v-for="(item, idx) in form.items" :key="'item-' + idx" class="item-card">
  77 + <div class="item-card-head">
  78 + <span class="item-card-no">品项 {{ idx + 1 }}</span>
  79 + <el-button
  80 + v-if="form.items.length > 1"
  81 + type="text"
  82 + class="item-remove-btn"
  83 + @click="removeItem(idx)"
  84 + >
  85 + <i class="el-icon-delete"></i> 删除
  86 + </el-button>
  87 + </div>
  88 +
  89 + <el-form-item
  90 + label="品项"
  91 + :prop="'items.' + idx + '.projectId'"
  92 + :rules="[{ required: true, message: '请选择品项', trigger: 'change' }]"
  93 + label-width="56px"
  94 + >
  95 + <el-select
  96 + v-model="item.projectId"
  97 + placeholder="搜索品项"
  98 + filterable
  99 + clearable
  100 + @change="onProjectChange(idx)"
  101 + >
  102 + <el-option v-for="p in availableItems" :key="p.value" :label="p.label" :value="p.value" />
  103 + </el-select>
  104 + </el-form-item>
  105 +
  106 + <div v-if="item.projectId" class="px-info-panel">
  107 + <el-row :gutter="8">
  108 + <el-col :span="8"><span class="px-tag">单价</span> <b>¥{{ item.price }}</b></el-col>
  109 + <el-col :span="8"><span class="px-tag">总购买</span> <b>{{ item.totalPurchased }}</b></el-col>
  110 + <el-col :span="8"><span class="px-tag">已消费</span> <b>{{ item.consumed }}</b></el-col>
  111 + </el-row>
  112 + <el-row :gutter="8" style="margin-top:4px">
  113 + <el-col :span="8"><span class="px-tag">剩余</span> <b class="remaining">{{ item.remaining }}</b></el-col>
  114 + <el-col :span="8"><span class="px-tag">来源</span> <b>{{ item.sourceType || '无' }}</b></el-col>
  115 + <el-col :span="8"><span class="px-tag">健康师手工费</span> <b>{{ item.healthCoachLaborCost }}</b></el-col>
  116 + </el-row>
  117 + <el-row :gutter="8" style="margin-top:4px" v-if="item.qt2 === '科美'">
  118 + <el-col :span="8"><span class="px-tag">科美手工费</span> <b>{{ item.techBeautyLaborCost }}</b></el-col>
  119 + </el-row>
  120 + </div>
  121 +
  122 + <el-row :gutter="12" style="margin-top:8px">
  123 + <el-col :span="16">
  124 + <el-form-item label="次数" label-width="56px">
  125 + <el-input-number
  126 + v-model="item.count"
  127 + :min="1"
  128 + :max="item.remaining || 999"
  129 + controls-position="right"
  130 + style="width:100%"
  131 + @change="onCountChange(idx)"
  132 + />
  133 + </el-form-item>
  134 + </el-col>
  135 + </el-row>
  136 +
  137 + <div class="worker-section">
  138 + <div class="worker-label">
  139 + <span>服务健康师</span>
  140 + <el-button type="text" size="mini" @click="addWorker(idx)">
  141 + <i class="el-icon-plus"></i> 添加健康师
  142 + </el-button>
  143 + </div>
  144 + <div v-for="(w, wi) in item.workers" :key="'w-' + wi" class="worker-row">
  145 + <el-select v-model="w.workerId" placeholder="选择健康师" filterable size="mini" class="worker-select">
  146 + <el-option v-for="h in healthWorkerOptions" :key="h.value" :label="h.label" :value="h.value" />
  147 + </el-select>
  148 + <el-button
  149 + v-if="item.workers.length > 1"
  150 + type="text"
  151 + size="mini"
  152 + class="worker-remove"
  153 + @click="removeWorker(idx, wi)"
  154 + >
  155 + <i class="el-icon-close"></i>
  156 + </el-button>
  157 + </div>
  158 + </div>
  159 +
  160 + <div class="worker-section" v-if="item.qt2 === '科美'">
  161 + <div class="worker-label">
  162 + <span>科技部老师</span>
  163 + <el-button type="text" size="mini" @click="addTechTeacher(idx)">
  164 + <i class="el-icon-plus"></i> 添加科技部老师
  165 + </el-button>
  166 + </div>
  167 + <div v-for="(t, ti) in item.techTeachers" :key="'t-' + ti" class="worker-row">
  168 + <el-select v-model="t.teacherId" placeholder="选择老师" filterable size="mini" class="worker-select">
  169 + <el-option v-for="tt in techTeacherOptions" :key="tt.value" :label="tt.label" :value="tt.value" />
  170 + </el-select>
  171 + <el-input :value="t.performance" readonly placeholder="业绩" size="mini" class="worker-field">
  172 + <template slot="prepend">¥</template>
  173 + </el-input>
  174 + <el-input :value="t.laborCost" readonly placeholder="手工费" size="mini" class="worker-field" />
  175 + <el-input :value="t.count" readonly placeholder="次数" size="mini" class="worker-field-sm" />
  176 + <el-button
  177 + type="text"
  178 + size="mini"
  179 + class="worker-remove"
  180 + @click="removeTechTeacher(idx, ti)"
  181 + >
  182 + <i class="el-icon-close"></i>
  183 + </el-button>
  184 + </div>
  185 + </div>
  186 +
  187 + <div class="worker-section" v-if="item.isAllowAccompanied == 1">
  188 + <div class="worker-label">
  189 + <span>陪同健康师</span>
  190 + <el-button type="text" size="mini" @click="addAccompanied(idx)">
  191 + <i class="el-icon-plus"></i> 添加陪同健康师
  192 + </el-button>
  193 + </div>
  194 + <div v-for="(a, ai) in item.accompanied" :key="'a-' + ai" class="worker-row">
  195 + <el-select v-model="a.workerId" placeholder="选择健康师" filterable size="mini" class="worker-select">
  196 + <el-option v-for="h in healthWorkerOptions" :key="h.value" :label="h.label" :value="h.value" />
  197 + </el-select>
  198 + <el-input-number v-model="a.count" :min="1" controls-position="right" size="mini" style="width:100px" />
  199 + <el-button
  200 + type="text"
  201 + size="mini"
  202 + class="worker-remove"
  203 + @click="removeAccompanied(idx, ai)"
  204 + >
  205 + <i class="el-icon-close"></i>
  206 + </el-button>
  207 + </div>
  208 + </div>
  209 + </div>
  210 +
  211 + <div class="add-btn-row">
  212 + <el-button type="text" @click="addItem">
  213 + <i class="el-icon-circle-plus-outline"></i> 添加品项
  214 + </el-button>
  215 + </div>
  216 + </div>
  217 + </el-form>
  218 + </div>
  219 +
  220 + <div class="consume-footer">
  221 + <div class="footer-summary">
  222 + <span class="footer-tip">提示:服务健康师来自右侧品项选择(用于排班占用展示)</span>
  223 + </div>
  224 + <div class="footer-actions">
  225 + <el-button size="small" @click="handleCancel">取 消</el-button>
  226 + <el-button type="primary" size="small" :loading="submitting" @click="handleSubmit">
  227 + {{ submitting ? '提交中...' : '保 存' }}
  228 + </el-button>
  229 + </div>
  230 + </div>
  231 + </div>
  232 +
  233 + <room-usage-dialog
  234 + :visible.sync="roomUsageVisible"
  235 + :date="roomUsageDate"
  236 + :rooms="roomOptions"
  237 + />
  238 + </el-dialog>
  239 +</template>
  240 +
  241 +<script>
  242 +import RoomUsageDialog from '@/components/room-usage-dialog.vue'
  243 +
  244 +const STORAGE_KEY = 'store_pc_pre_consume_bookings'
  245 +
  246 +export default {
  247 + name: 'BookingConsumeDialog',
  248 + components: { RoomUsageDialog },
  249 + props: {
  250 + visible: { type: Boolean, default: false }
  251 + },
  252 + data() {
  253 + return {
  254 + submitting: false,
  255 + roomUsageVisible: false,
  256 + form: this.createEmptyForm(),
  257 + memberOptions: [
  258 + { value: 'cust001', label: '林小纤', phone: '13800138000' },
  259 + { value: 'cust002', label: '王丽', phone: '13800138001' },
  260 + { value: 'cust003', label: '张敏', phone: '13800138002' }
  261 + ],
  262 + memberItemsMap: {
  263 + 'cust001': [
  264 + { value: 'item001', label: '面部深层护理(次卡)', price: 380, remaining: 6, totalPurchased: 10, consumed: 4, sourceType: '购买', qt2: '', healthCoachLaborCost: 50, techBeautyLaborCost: 0, isAllowAccompanied: 0 },
  265 + { value: 'item002', label: '肩颈调理(疗程)', price: 268, remaining: 3, totalPurchased: 5, consumed: 2, sourceType: '购买', qt2: '科美', healthCoachLaborCost: 30, techBeautyLaborCost: 40, isAllowAccompanied: 0 },
  266 + { value: 'item003', label: '眼周护理套餐', price: 198, remaining: 3, totalPurchased: 4, consumed: 1, sourceType: '赠送', qt2: '', healthCoachLaborCost: 25, techBeautyLaborCost: 0, isAllowAccompanied: 1 }
  267 + ],
  268 + 'cust002': [
  269 + { value: 'item004', label: '面部深层护理(次卡)', price: 380, remaining: 4, totalPurchased: 8, consumed: 4, sourceType: '购买', qt2: '', healthCoachLaborCost: 50, techBeautyLaborCost: 0, isAllowAccompanied: 0 }
  270 + ],
  271 + 'cust003': [
  272 + { value: 'item005', label: '肩颈调理(疗程)', price: 268, remaining: 5, totalPurchased: 5, consumed: 0, sourceType: '购买', qt2: '科美', healthCoachLaborCost: 30, techBeautyLaborCost: 40, isAllowAccompanied: 1 }
  273 + ]
  274 + },
  275 + healthWorkerOptions: [
  276 + { value: 'E001', label: '董顺秀' },
  277 + { value: 'E002', label: '张丽' },
  278 + { value: 'E003', label: '贾琳' },
  279 + { value: 'E004', label: '刘恬恬' }
  280 + ],
  281 + techTeacherOptions: [
  282 + { value: 'kjb001', label: '赵科技老师' },
  283 + { value: 'kjb002', label: '钱科技老师' }
  284 + ],
  285 + roomOptions: [
  286 + { id: 'R001', name: '1号房' },
  287 + { id: 'R002', name: '2号房' },
  288 + { id: 'R003', name: '3号房' },
  289 + { id: 'R004', name: 'VIP房' }
  290 + ],
  291 + availableItems: [],
  292 + timeOptions: { start: '09:00', step: '00:30', end: '21:00' },
  293 + rules: {
  294 + memberId: [{ required: true, message: '请选择会员', trigger: 'change' }],
  295 + date: [{ required: true, message: '请选择预约日期', trigger: 'change' }],
  296 + startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
  297 + endTime: [
  298 + { required: true, message: '请选择结束时间', trigger: 'change' },
  299 + { validator: (rule, value, cb) => {
  300 + if (!value || !this.form.startTime) { cb(); return }
  301 + const [sh, sm] = this.form.startTime.split(':').map(Number)
  302 + const [eh, em] = value.split(':').map(Number)
  303 + if (eh < sh || (eh === sh && em <= sm)) cb(new Error('结束时间须晚于开始时间'))
  304 + else cb()
  305 + }, trigger: 'change' }
  306 + ],
  307 + roomId: [{ required: true, message: '请选择房间', trigger: 'change' }],
  308 + items: [{ validator: (rule, value, cb) => {
  309 + const list = this.form.items || []
  310 + if (!Array.isArray(list) || list.length < 1) cb(new Error('请至少添加 1 个品项'))
  311 + else if (list.some(x => !x || !x.projectId)) cb(new Error('请完善品项选择'))
  312 + else if (list.some(x => !x.workers || x.workers.length < 1 || x.workers.some(w => !w.workerId))) cb(new Error('请为每个品项选择服务健康师'))
  313 + else cb()
  314 + }, trigger: 'change' }]
  315 + }
  316 + }
  317 + },
  318 + computed: {
  319 + visibleProxy: {
  320 + get() { return this.visible },
  321 + set(v) { this.$emit('update:visible', v) }
  322 + },
  323 + selectedTherapistIds() {
  324 + const set = new Set()
  325 + ;(this.form.items || []).forEach(it => {
  326 + ;(it.workers || []).forEach(w => {
  327 + if (w && w.workerId) set.add(w.workerId)
  328 + })
  329 + })
  330 + return Array.from(set)
  331 + },
  332 + selectedTherapistNames() {
  333 + const map = new Map((this.healthWorkerOptions || []).map(x => [x.value, x.label]))
  334 + return this.selectedTherapistIds.map(id => map.get(id) || id).filter(Boolean)
  335 + },
  336 + timeOptionsEnd() {
  337 + const base = { ...this.timeOptions }
  338 + if (this.form.startTime) {
  339 + const [h, m] = this.form.startTime.split(':').map(Number)
  340 + const next = (h * 60 + m + 30) % (24 * 60)
  341 + base.start = `${String(Math.floor(next / 60)).padStart(2, '0')}:${String(next % 60).padStart(2, '0')}`
  342 + const [eh, em] = (base.end || '21:00').split(':').map(Number)
  343 + if (next >= eh * 60 + em) base.end = '22:00'
  344 + }
  345 + return base
  346 + },
  347 + roomUsageDate() {
  348 + return this.form.date instanceof Date ? this.formatDate(this.form.date) : this.formatDate(new Date())
  349 + }
  350 + },
  351 + watch: {
  352 + visible(val) {
  353 + if (val) this.resetFormForOpen()
  354 + }
  355 + },
  356 + methods: {
  357 + createEmptyForm() {
  358 + return {
  359 + memberId: '',
  360 + memberName: '',
  361 + date: new Date(),
  362 + startTime: '',
  363 + endTime: '',
  364 + roomId: '',
  365 + roomName: '',
  366 + therapistIds: [],
  367 + items: [this.createEmptyItem()],
  368 + remark: ''
  369 + }
  370 + },
  371 + createEmptyItem() {
  372 + return {
  373 + projectId: '',
  374 + label: '',
  375 + price: 0,
  376 + remaining: 0,
  377 + totalPurchased: 0,
  378 + consumed: 0,
  379 + sourceType: '',
  380 + qt2: '',
  381 + beautyType: '',
  382 + healthCoachLaborCost: 0,
  383 + techBeautyLaborCost: 0,
  384 + isAllowAccompanied: 0,
  385 + count: 1,
  386 + workers: [{ workerId: '' }],
  387 + techTeachers: [],
  388 + accompanied: []
  389 + }
  390 + },
  391 + resetFormForOpen() {
  392 + this.form = this.createEmptyForm()
  393 + this.roomUsageVisible = false
  394 + this.submitting = false
  395 + this.availableItems = []
  396 + this.$nextTick(() => {
  397 + this.$refs.form && this.$refs.form.clearValidate()
  398 + })
  399 + },
  400 + onMemberChange(val) {
  401 + const m = this.memberOptions.find(x => x.value === val)
  402 + this.form.memberName = m ? m.label : ''
  403 + this.form.items = [this.createEmptyItem()]
  404 + this.loadMemberItems(val)
  405 + },
  406 + loadMemberItems(memberId) {
  407 + const items = this.memberItemsMap[memberId] || []
  408 + this.availableItems = items.map(it => ({
  409 + ...it,
  410 + label: `${it.label}(剩余${it.remaining}次)`
  411 + }))
  412 + },
  413 + onRoomChange(val) {
  414 + const r = this.roomOptions.find(x => x.id === val)
  415 + this.form.roomName = r ? r.name : ''
  416 + },
  417 + handleCancel() {
  418 + this.visibleProxy = false
  419 + },
  420 + formatDate(d) {
  421 + const dt = d instanceof Date ? d : new Date(d)
  422 + const y = dt.getFullYear()
  423 + const m = String(dt.getMonth() + 1).padStart(2, '0')
  424 + const day = String(dt.getDate()).padStart(2, '0')
  425 + return `${y}-${m}-${day}`
  426 + },
  427 + readStorage() {
  428 + try {
  429 + const raw = localStorage.getItem(STORAGE_KEY)
  430 + if (!raw) return []
  431 + const arr = JSON.parse(raw)
  432 + return Array.isArray(arr) ? arr : []
  433 + } catch (e) {
  434 + return []
  435 + }
  436 + },
  437 + writeStorage(list) {
  438 + localStorage.setItem(STORAGE_KEY, JSON.stringify(list || []))
  439 + },
  440 + buildColorKey(id) {
  441 + const palette = ['blue', 'green', 'orange', 'purple', 'gray']
  442 + let sum = 0
  443 + for (let i = 0; i < id.length; i++) sum += id.charCodeAt(i)
  444 + return palette[sum % palette.length]
  445 + },
  446 + handleSubmit() {
  447 + this.$refs.form.validate(valid => {
  448 + if (!valid) return
  449 + this.submitting = true
  450 + setTimeout(() => {
  451 + const id = `PCB_${Date.now()}_${Math.floor(Math.random() * 1000)}`
  452 + const dateStr = this.formatDate(this.form.date)
  453 + const therapistIds = this.selectedTherapistIds
  454 + const therapistNames = this.selectedTherapistNames
  455 + const itemLabels = (this.form.items || []).map(x => {
  456 + const base = x.label || this.getProjectLabel(x.projectId)
  457 + const cnt = x.count != null ? `×${x.count}` : ''
  458 + return `${base}${cnt}`.trim()
  459 + }).filter(Boolean)
  460 + const record = {
  461 + id,
  462 + memberId: this.form.memberId,
  463 + memberName: this.form.memberName || '',
  464 + date: dateStr,
  465 + startTime: this.form.startTime,
  466 + endTime: this.form.endTime,
  467 + roomId: this.form.roomId,
  468 + roomName: this.form.roomName || '',
  469 + therapistIds,
  470 + therapistNames,
  471 + items: (this.form.items || []).map(x => ({
  472 + projectId: x.projectId,
  473 + label: x.label || this.getProjectLabel(x.projectId),
  474 + count: x.count || 1,
  475 + workers: Array.isArray(x.workers) ? x.workers.map(w => ({ workerId: w.workerId || '' })) : [],
  476 + techTeachers: Array.isArray(x.techTeachers) ? x.techTeachers.map(t => ({ teacherId: t.teacherId || '' })) : [],
  477 + accompanied: Array.isArray(x.accompanied) ? x.accompanied.map(a => ({ workerId: a.workerId || '', count: a.count || 1 })) : []
  478 + })),
  479 + itemLabels,
  480 + remark: this.form.remark || '',
  481 + status: 'booked',
  482 + colorKey: this.buildColorKey(id),
  483 + createdAt: new Date().toISOString()
  484 + }
  485 + const list = this.readStorage()
  486 + list.unshift(record)
  487 + this.writeStorage(list)
  488 + this.submitting = false
  489 + this.$emit('saved', record)
  490 + }, 500)
  491 + })
  492 + }
  493 + ,
  494 + addItem() {
  495 + this.form.items.push(this.createEmptyItem())
  496 + this.$nextTick(() => {
  497 + this.$refs.form && this.$refs.form.clearValidate('items')
  498 + })
  499 + },
  500 + removeItem(idx) {
  501 + this.form.items.splice(idx, 1)
  502 + if (!this.form.items.length) this.form.items.push(this.createEmptyItem())
  503 + this.$nextTick(() => {
  504 + this.$refs.form && this.$refs.form.clearValidate('items')
  505 + })
  506 + },
  507 + getProjectLabel(pid) {
  508 + const hit = this.availableItems.find(x => x.value === pid)
  509 + if (!hit || !hit.label) return ''
  510 + return (hit.label || '').split('(剩余')[0] || ''
  511 + },
  512 + onProjectChange(idx) {
  513 + const item = this.form.items[idx]
  514 + if (!item) return
  515 + const p = this.availableItems.find(o => o.value === item.projectId)
  516 + if (p) {
  517 + item.label = (p.label || '').split('(剩余')[0] || p.label || ''
  518 + item.price = p.price
  519 + item.remaining = p.remaining
  520 + item.totalPurchased = p.totalPurchased
  521 + item.consumed = p.consumed
  522 + item.sourceType = p.sourceType
  523 + item.qt2 = p.qt2
  524 + item.healthCoachLaborCost = p.healthCoachLaborCost
  525 + item.techBeautyLaborCost = p.techBeautyLaborCost
  526 + item.isAllowAccompanied = p.isAllowAccompanied
  527 + item.count = 1
  528 + item.workers = [{ workerId: '', performance: '', laborCost: '', count: '' }]
  529 + item.techTeachers = []
  530 + item.accompanied = []
  531 + }
  532 + this.redistributeWorkers(idx)
  533 + },
  534 + onCountChange(idx) {
  535 + this.redistributeWorkers(idx)
  536 + this.redistributeTechTeachers(idx)
  537 + },
  538 + redistributeWorkers(idx) {
  539 + const item = this.form.items[idx]
  540 + if (!item.workers || item.workers.length === 0) return
  541 + const totalCount = item.count || 0
  542 + const totalPerf = item.price * totalCount
  543 + const isKemei = item.qt2 === '科美' && item.beautyType !== 'cell'
  544 + const isCell = item.qt2 === '科美' && item.beautyType === 'cell'
  545 + const hasTech = item.techTeachers && item.techTeachers.length > 0
  546 + const n = item.workers.length
  547 + if (isKemei || (isCell && hasTech)) {
  548 + item.workers.forEach(w => {
  549 + w.performance = (totalPerf / n).toFixed(2)
  550 + w.laborCost = '0.00'
  551 + w.count = '0'
  552 + })
  553 + } else {
  554 + const totalLabor = (item.healthCoachLaborCost || 0) * totalCount
  555 + item.workers.forEach(w => {
  556 + w.performance = (totalPerf / n).toFixed(2)
  557 + w.laborCost = (totalLabor / n).toFixed(2)
  558 + w.count = (totalCount / n).toFixed(2)
  559 + })
  560 + }
  561 + },
  562 + redistributeTechTeachers(idx) {
  563 + const item = this.form.items[idx]
  564 + if (!item.techTeachers || item.techTeachers.length === 0) return
  565 + const totalCount = item.count || 0
  566 + const totalPerf = item.price * totalCount
  567 + const totalLabor = (item.techBeautyLaborCost || 0) * totalCount
  568 + const n = item.techTeachers.length
  569 + item.techTeachers.forEach(t => {
  570 + t.performance = (totalPerf / n).toFixed(2)
  571 + t.laborCost = (totalLabor / n).toFixed(2)
  572 + t.count = (totalCount / n).toFixed(2)
  573 + })
  574 + this.redistributeWorkers(idx)
  575 + },
  576 + addWorker(idx) {
  577 + this.form.items[idx].workers.push({ workerId: '', performance: '', laborCost: '', count: '' })
  578 + this.redistributeWorkers(idx)
  579 + },
  580 + removeWorker(idx, wi) {
  581 + this.form.items[idx].workers.splice(wi, 1)
  582 + this.redistributeWorkers(idx)
  583 + },
  584 + addTechTeacher(idx) {
  585 + this.form.items[idx].techTeachers.push({ teacherId: '', performance: '', laborCost: '', count: '' })
  586 + this.redistributeTechTeachers(idx)
  587 + },
  588 + removeTechTeacher(idx, ti) {
  589 + this.form.items[idx].techTeachers.splice(ti, 1)
  590 + this.redistributeTechTeachers(idx)
  591 + },
  592 + addAccompanied(idx) {
  593 + this.form.items[idx].accompanied.push({ workerId: '', count: 1 })
  594 + },
  595 + removeAccompanied(idx, ai) {
  596 + this.form.items[idx].accompanied.splice(ai, 1)
  597 + }
  598 + }
  599 +}
  600 +</script>
  601 +
  602 +<style lang="scss" scoped>
  603 +.ghost-btn {
  604 + border-radius: 999px !important;
  605 + background: rgba(239, 246, 255, 0.9) !important;
  606 + color: #2563eb !important;
  607 + border-color: rgba(37, 99, 235, 0.18) !important;
  608 +}
  609 +
  610 +/* ====== 内部结构(对齐 ConsumeDialog) ====== */
  611 +.consume-dialog-inner {
  612 + display: flex;
  613 + flex-direction: column;
  614 + max-height: 85vh;
  615 +}
  616 +
  617 +.consume-header {
  618 + flex-shrink: 0;
  619 + display: flex;
  620 + align-items: center;
  621 + gap: 8px;
  622 + margin: 18px 22px 0;
  623 + padding: 10px 14px;
  624 + border-radius: 14px;
  625 + background: rgba(219, 234, 254, 0.96);
  626 +}
  627 +
  628 +.consume-title-wrap {
  629 + flex: 1;
  630 +}
  631 +
  632 +.consume-title {
  633 + font-size: 17px;
  634 + font-weight: 600;
  635 + color: #0f172a;
  636 +}
  637 +
  638 +.consume-subtitle {
  639 + font-size: 12px;
  640 + color: #475569;
  641 + margin-top: 2px;
  642 +}
  643 +
  644 +.consume-header-actions {
  645 + display: flex;
  646 + align-items: center;
  647 + gap: 10px;
  648 +}
  649 +
  650 +.consume-close {
  651 + flex-shrink: 0;
  652 + cursor: pointer;
  653 + width: 28px;
  654 + height: 28px;
  655 + display: flex;
  656 + align-items: center;
  657 + justify-content: center;
  658 + border-radius: 999px;
  659 + color: #64748b;
  660 + transition: all 0.15s;
  661 +
  662 + &:hover {
  663 + background: rgba(0, 0, 0, 0.06);
  664 + color: #0f172a;
  665 + }
  666 +}
  667 +
  668 +.consume-content {
  669 + flex: 1;
  670 + min-height: 0;
  671 + overflow: hidden;
  672 + display: flex;
  673 +}
  674 +
  675 +.consume-form {
  676 + display: flex;
  677 + flex: 1;
  678 + min-height: 0;
  679 +}
  680 +
  681 +.consume-left {
  682 + flex: 0 0 440px;
  683 + overflow-y: auto;
  684 + padding: 10px 16px 10px 22px;
  685 + border-right: 1px solid rgba(229, 231, 235, 0.6);
  686 + min-height: 0;
  687 +}
  688 +
  689 +.consume-right {
  690 + flex: 1;
  691 + overflow-y: auto;
  692 + padding: 10px 22px 10px 16px;
  693 + min-height: 0;
  694 +}
  695 +
  696 +.section-title {
  697 + font-size: 13px;
  698 + font-weight: 600;
  699 + color: #334155;
  700 + margin: 14px 0 8px;
  701 + padding: 6px 10px;
  702 + border-radius: 8px;
  703 + background: rgba(241, 245, 249, 0.7);
  704 +
  705 + i {
  706 + margin-right: 4px;
  707 + color: #2563eb;
  708 + }
  709 +
  710 + &:first-child {
  711 + margin-top: 4px;
  712 + }
  713 +}
  714 +
  715 +.item-card {
  716 + border: 1px solid #e5e7eb;
  717 + border-radius: 12px;
  718 + padding: 10px 12px 4px;
  719 + margin-bottom: 10px;
  720 + background: rgba(255, 255, 255, 0.6);
  721 + transition: border-color 0.15s;
  722 +
  723 + &:hover {
  724 + border-color: #93c5fd;
  725 + }
  726 +}
  727 +
  728 +.item-card-head {
  729 + display: flex;
  730 + align-items: center;
  731 + justify-content: space-between;
  732 + margin-bottom: 6px;
  733 +}
  734 +
  735 +.item-card-no {
  736 + font-size: 12px;
  737 + font-weight: 600;
  738 + color: #2563eb;
  739 +}
  740 +
  741 +.item-remove-btn {
  742 + color: #ef4444 !important;
  743 + font-size: 12px;
  744 + padding: 0;
  745 +}
  746 +
  747 +.px-info-panel {
  748 + margin: 4px 0 6px;
  749 + padding: 8px 10px;
  750 + border-radius: 8px;
  751 + background: rgba(241, 245, 249, 0.5);
  752 + font-size: 12px;
  753 + color: #475569;
  754 + line-height: 1.8;
  755 +
  756 + .px-tag {
  757 + color: #94a3b8;
  758 + margin-right: 2px;
  759 + }
  760 +
  761 + b {
  762 + color: #0f172a;
  763 + font-weight: 600;
  764 + }
  765 +
  766 + .remaining {
  767 + color: #2563eb;
  768 + }
  769 +}
  770 +
  771 +.worker-section {
  772 + margin: 2px 0 6px;
  773 + padding: 8px 10px;
  774 + border-radius: 8px;
  775 + background: rgba(241, 245, 249, 0.5);
  776 +}
  777 +
  778 +.worker-label {
  779 + display: flex;
  780 + align-items: center;
  781 + justify-content: space-between;
  782 + margin-bottom: 6px;
  783 + font-size: 12px;
  784 + color: #475569;
  785 + font-weight: 500;
  786 +}
  787 +
  788 +.worker-row {
  789 + display: flex;
  790 + align-items: center;
  791 + gap: 8px;
  792 + margin-bottom: 6px;
  793 +}
  794 +
  795 +.worker-select {
  796 + flex: 1;
  797 +}
  798 +
  799 +.worker-field {
  800 + width: 120px;
  801 + flex-shrink: 0;
  802 +}
  803 +
  804 +.worker-field-sm {
  805 + width: 70px;
  806 + flex-shrink: 0;
  807 +}
  808 +
  809 +.worker-remove {
  810 + color: #ef4444 !important;
  811 + padding: 0;
  812 +}
  813 +
  814 +.add-btn-row {
  815 + text-align: center;
  816 + margin-bottom: 6px;
  817 +}
  818 +
  819 +.consume-footer {
  820 + flex-shrink: 0;
  821 + display: flex;
  822 + align-items: center;
  823 + justify-content: space-between;
  824 + padding: 10px 22px 14px;
  825 + border-top: 1px solid rgba(229, 231, 235, 0.6);
  826 +}
  827 +
  828 +.footer-summary {
  829 + display: flex;
  830 + gap: 16px;
  831 + font-size: 12px;
  832 + color: #64748b;
  833 +
  834 + b {
  835 + color: #0f172a;
  836 + }
  837 +
  838 + .highlight {
  839 + color: #2563eb;
  840 + font-size: 14px;
  841 + }
  842 +}
  843 +
  844 +.footer-actions {
  845 + display: flex;
  846 + gap: 10px;
  847 +}
  848 +
  849 +::v-deep .booking-consume-dialog {
  850 + max-width: 1200px;
  851 + margin-top: 8vh !important;
  852 + border-radius: 20px;
  853 + padding: 0;
  854 + 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%);
  855 + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18), 0 0 0 1px rgba(255, 255, 255, 0.9);
  856 + backdrop-filter: blur(22px);
  857 + -webkit-backdrop-filter: blur(22px);
  858 +}
  859 +
  860 +::v-deep .booking-consume-dialog .el-dialog__header { display: none; }
  861 +::v-deep .booking-consume-dialog .el-dialog__body { padding: 0; }
  862 +
  863 +::v-deep .booking-consume-dialog .el-form-item__label { white-space: nowrap; }
  864 +::v-deep .booking-consume-dialog .el-input__inner {
  865 + border-radius: 999px;
  866 + height: 32px;
  867 + line-height: 32px;
  868 + border-color: #e5e7eb;
  869 + background-color: #f9fafb;
  870 +}
  871 +::v-deep .booking-consume-dialog .el-input__inner:focus {
  872 + border-color: #2563eb;
  873 + box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.18);
  874 +}
  875 +::v-deep .booking-consume-dialog .el-textarea__inner {
  876 + border-radius: 12px;
  877 + border-color: #e5e7eb;
  878 + background-color: #f9fafb;
  879 +}
  880 +::v-deep .booking-consume-dialog .el-input-group__prepend {
  881 + border-radius: 999px 0 0 999px;
  882 + background: #f1f5f9;
  883 + border-color: #e5e7eb;
  884 + padding: 0 10px;
  885 + color: #64748b;
  886 +}
  887 +::v-deep .booking-consume-dialog .el-input-group .el-input__inner {
  888 + border-radius: 0 999px 999px 0;
  889 +}
  890 +::v-deep .booking-consume-dialog .el-input-number {
  891 + .el-input__inner {
  892 + border-radius: 999px;
  893 + }
  894 +}
  895 +::v-deep .booking-consume-dialog .el-button--primary {
  896 + border-radius: 999px;
  897 + padding: 0 20px;
  898 + height: 30px;
  899 + line-height: 30px;
  900 + background: #2563eb;
  901 + border-color: #2563eb;
  902 + box-shadow: 0 4px 10px rgba(37, 99, 235, 0.35);
  903 + font-size: 12px;
  904 +}
  905 +::v-deep .booking-consume-dialog .el-button--default {
  906 + border-radius: 999px;
  907 + padding: 0 18px;
  908 + height: 30px;
  909 + line-height: 30px;
  910 + background: rgba(239, 246, 255, 0.9);
  911 + color: #2563eb;
  912 + border-color: rgba(37, 99, 235, 0.18);
  913 + font-size: 12px;
  914 +}
  915 +::v-deep .booking-consume-dialog .el-form-item {
  916 + margin-bottom: 12px;
  917 +}
  918 +::v-deep .booking-consume-dialog .el-select {
  919 + width: 100%;
  920 +}
  921 +</style>
  922 +
... ...
store-pc/src/components/room-usage-dialog.vue 0 → 100644
  1 +<template>
  2 + <el-dialog
  3 + :visible.sync="visibleProxy"
  4 + :show-close="false"
  5 + width="860px"
  6 + :close-on-click-modal="false"
  7 + custom-class="room-usage-dialog"
  8 + append-to-body
  9 + >
  10 + <div class="inner">
  11 + <div class="header">
  12 + <div class="title">房态 · {{ date || '—' }}</div>
  13 + <span class="close" @click="visibleProxy = false"><i class="el-icon-close"></i></span>
  14 + </div>
  15 +
  16 + <div class="body">
  17 + <div class="legend">
  18 + <span class="legend-item"><i class="dot dot--free"></i>空闲</span>
  19 + <span class="legend-item"><i class="dot dot--busy"></i>占用</span>
  20 + <span class="legend-item"><i class="dot dot--slot"></i>30分钟/格</span>
  21 + <span class="legend-tip">当前为 mock 房态,用于前端闭环演示</span>
  22 + </div>
  23 +
  24 + <div class="table">
  25 + <div class="thead">
  26 + <div class="cell cell--room">房间</div>
  27 + <div class="cell cell--grid">
  28 + <div class="grid">
  29 + <div v-for="i in 48" :key="i" class="t">
  30 + {{ (i - 1) % 2 === 0 ? formatSlotTime(i - 1) : '' }}
  31 + </div>
  32 + </div>
  33 + </div>
  34 + </div>
  35 +
  36 + <div v-for="r in normalizedRooms" :key="r.id" class="row">
  37 + <div class="cell cell--room">
  38 + <div class="room-name">{{ r.name }}</div>
  39 + <div class="room-sub">今日占用 {{ countBusySlots(r.id) }} 格</div>
  40 + </div>
  41 + <div class="cell cell--grid">
  42 + <div class="grid">
  43 + <div
  44 + v-for="i in 48"
  45 + :key="i"
  46 + :class="['slot', isBusy(r.id, i - 1) ? 'slot--busy' : 'slot--free']"
  47 + :title="slotTitle(r.id, i - 1)"
  48 + ></div>
  49 + </div>
  50 + </div>
  51 + </div>
  52 + </div>
  53 + </div>
  54 +
  55 + <div class="footer">
  56 + <el-button size="small" @click="visibleProxy = false">关 闭</el-button>
  57 + </div>
  58 + </div>
  59 + </el-dialog>
  60 +</template>
  61 +
  62 +<script>
  63 +export default {
  64 + name: 'RoomUsageDialog',
  65 + props: {
  66 + visible: { type: Boolean, default: false },
  67 + date: { type: String, default: '' },
  68 + rooms: { type: Array, default: () => [] }
  69 + },
  70 + data() {
  71 + return {
  72 + // mock:key = roomId, value = [{ startSlot, endSlot, label }]
  73 + mockBusy: {
  74 + R001: [{ startSlot: 18, endSlot: 22, label: '预约占用' }, { startSlot: 30, endSlot: 34, label: '清洁消杀' }],
  75 + R002: [{ startSlot: 20, endSlot: 24, label: '预约占用' }],
  76 + R003: [{ startSlot: 26, endSlot: 28, label: '设备维护' }],
  77 + R004: [{ startSlot: 32, endSlot: 40, label: 'VIP预约' }]
  78 + }
  79 + }
  80 + },
  81 + computed: {
  82 + visibleProxy: {
  83 + get() { return this.visible },
  84 + set(v) { this.$emit('update:visible', v) }
  85 + },
  86 + normalizedRooms() {
  87 + if (this.rooms && this.rooms.length) return this.rooms
  88 + return [
  89 + { id: 'R001', name: '1号房' },
  90 + { id: 'R002', name: '2号房' },
  91 + { id: 'R003', name: '3号房' },
  92 + { id: 'R004', name: 'VIP房' }
  93 + ]
  94 + }
  95 + },
  96 + methods: {
  97 + formatSlotTime(slotIndex) {
  98 + const h = Math.floor(slotIndex / 2)
  99 + const m = (slotIndex % 2) * 30
  100 + return `${String(h).padStart(2, '0')}:${m === 0 ? '00' : '30'}`
  101 + },
  102 + isBusy(roomId, slotIndex) {
  103 + const list = this.mockBusy[roomId] || []
  104 + return list.some(x => slotIndex >= x.startSlot && slotIndex < x.endSlot)
  105 + },
  106 + slotTitle(roomId, slotIndex) {
  107 + const time = this.formatSlotTime(slotIndex)
  108 + const list = this.mockBusy[roomId] || []
  109 + const hit = list.find(x => slotIndex >= x.startSlot && slotIndex < x.endSlot)
  110 + if (!hit) return `${time} 空闲`
  111 + return `${time} ${hit.label}`
  112 + },
  113 + countBusySlots(roomId) {
  114 + const list = this.mockBusy[roomId] || []
  115 + return list.reduce((sum, x) => sum + Math.max((x.endSlot - x.startSlot), 0), 0)
  116 + }
  117 + }
  118 +}
  119 +</script>
  120 +
  121 +<style lang="scss" scoped>
  122 +.inner {
  123 + padding: 18px 22px 14px;
  124 +}
  125 +
  126 +.header {
  127 + display: flex;
  128 + align-items: center;
  129 + justify-content: space-between;
  130 + gap: 10px;
  131 + margin-bottom: 12px;
  132 + padding: 10px 14px;
  133 + border-radius: 14px;
  134 + background: rgba(219, 234, 254, 0.96);
  135 +}
  136 +
  137 +.title {
  138 + font-size: 17px;
  139 + font-weight: 600;
  140 + color: #0f172a;
  141 +}
  142 +
  143 +.close {
  144 + cursor: pointer;
  145 + width: 28px;
  146 + height: 28px;
  147 + display: flex;
  148 + align-items: center;
  149 + justify-content: center;
  150 + border-radius: 999px;
  151 + color: #64748b;
  152 + transition: all 0.15s;
  153 + &:hover { background: rgba(0, 0, 0, 0.06); color: #0f172a; }
  154 +}
  155 +
  156 +.legend {
  157 + display: flex;
  158 + align-items: center;
  159 + gap: 14px;
  160 + flex-wrap: wrap;
  161 + font-size: 12px;
  162 + color: #64748b;
  163 + margin-bottom: 10px;
  164 +}
  165 +
  166 +.legend-item { display: inline-flex; align-items: center; gap: 6px; }
  167 +.legend-tip { margin-left: auto; color: #94a3b8; }
  168 +
  169 +.dot { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }
  170 +.dot--free { background: rgba(148, 163, 184, 0.35); }
  171 +.dot--busy { background: rgba(239, 68, 68, 0.55); }
  172 +.dot--slot { background: #e2e8f0; }
  173 +
  174 +.table {
  175 + border: 1px solid #e2e8f0;
  176 + border-radius: 12px;
  177 + overflow: hidden;
  178 + background: #fff;
  179 +}
  180 +
  181 +.thead, .row {
  182 + display: grid;
  183 + grid-template-columns: 140px 1fr;
  184 +}
  185 +
  186 +.cell {
  187 + padding: 10px 10px;
  188 + border-right: 1px solid #f1f5f9;
  189 + border-bottom: 1px solid #f1f5f9;
  190 +}
  191 +
  192 +.cell--room {
  193 + background: #f8fafc;
  194 +}
  195 +
  196 +.room-name {
  197 + font-size: 14px;
  198 + font-weight: 600;
  199 + color: #111827;
  200 +}
  201 +
  202 +.room-sub {
  203 + font-size: 11px;
  204 + color: #94a3b8;
  205 + margin-top: 2px;
  206 +}
  207 +
  208 +.grid {
  209 + display: grid;
  210 + grid-template-columns: repeat(48, 1fr);
  211 + gap: 0;
  212 + min-height: 24px;
  213 +}
  214 +
  215 +.t {
  216 + font-size: 9px;
  217 + color: #94a3b8;
  218 + text-align: center;
  219 + white-space: nowrap;
  220 + overflow: hidden;
  221 +}
  222 +
  223 +.slot {
  224 + min-height: 18px;
  225 + background: #f8fafc;
  226 +}
  227 +
  228 +.slot--free {
  229 + background: rgba(148, 163, 184, 0.18);
  230 +}
  231 +
  232 +.slot--busy {
  233 + background: rgba(239, 68, 68, 0.5);
  234 +}
  235 +
  236 +.footer {
  237 + display: flex;
  238 + justify-content: flex-end;
  239 + gap: 10px;
  240 + margin-top: 10px;
  241 + padding-top: 12px;
  242 + border-top: 1px solid #f1f5f9;
  243 +}
  244 +
  245 +::v-deep .room-usage-dialog {
  246 + max-width: 860px;
  247 + margin-top: 7vh !important;
  248 + border-radius: 20px;
  249 + padding: 0;
  250 + 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%);
  251 + box-shadow: 0 24px 48px rgba(15, 23, 42, 0.18), 0 0 0 1px rgba(255, 255, 255, 0.9);
  252 + backdrop-filter: blur(22px);
  253 + -webkit-backdrop-filter: blur(22px);
  254 +}
  255 +::v-deep .room-usage-dialog .el-dialog__header { display: none; }
  256 +::v-deep .room-usage-dialog .el-dialog__body { padding: 0; }
  257 +::v-deep .room-usage-dialog .el-button--default {
  258 + border-radius: 999px;
  259 + padding: 0 18px;
  260 + height: 30px;
  261 + line-height: 30px;
  262 + background: rgba(239, 246, 255, 0.9);
  263 + color: #2563eb;
  264 + border-color: rgba(37, 99, 235, 0.18);
  265 + font-size: 12px;
  266 +}
  267 +</style>
  268 +
... ...
store-pc/src/views/dashboard/index.vue
... ... @@ -371,6 +371,10 @@
371 371 @action="handleMemberAction"
372 372 />
373 373 <tuoke-lead-dialog :visible.sync="tuokeDialogVisible" />
  374 + <booking-consume-dialog
  375 + :visible.sync="bookingConsumeDialogVisible"
  376 + @saved="handlePreConsumeBookingSaved"
  377 + />
374 378 <booking-dialog :visible.sync="bookingDialogVisible" :prefill="bookingPrefill" />
375 379 <billing-dialog :visible.sync="billingDialogVisible" :prefill="billingPrefill" />
376 380 <consume-dialog :visible.sync="consumeDialogVisible" />
... ... @@ -433,6 +437,7 @@ import TuokeLeadDialog from &#39;@/components/TuokeLeadDialog.vue&#39;
433 437 import InviteAddDialog from '@/components/InviteAddDialog.vue'
434 438 import CompanyCalendarDialog from '@/components/CompanyCalendarDialog.vue'
435 439 import BookingDialog from '@/components/BookingDialog.vue'
  440 +import BookingConsumeDialog from '@/components/booking-consume-dialog.vue'
436 441 import BillingDialog from '@/components/BillingDialog.vue'
437 442 import ConsumeDialog from '@/components/ConsumeDialog.vue'
438 443 import RefundDialog from '@/components/RefundDialog.vue'
... ... @@ -452,6 +457,7 @@ export default {
452 457 InviteAddDialog,
453 458 CompanyCalendarDialog,
454 459 BookingDialog,
  460 + BookingConsumeDialog,
455 461 BillingDialog,
456 462 ConsumeDialog,
457 463 RefundDialog,
... ... @@ -797,6 +803,7 @@ export default {
797 803 name: '',
798 804 phone: ''
799 805 },
  806 + bookingConsumeDialogVisible: false,
800 807 billingDialogVisible: false,
801 808 billingPrefill: {},
802 809 consumeDialogVisible: false,
... ... @@ -1025,8 +1032,7 @@ export default {
1025 1032 return
1026 1033 }
1027 1034 if (module.key === 'booking') {
1028   - this.bookingPrefill = { name: '', phone: '' }
1029   - this.bookingDialogVisible = true
  1035 + this.bookingConsumeDialogVisible = true
1030 1036 return
1031 1037 }
1032 1038 if (module.key === 'order') {
... ... @@ -1252,6 +1258,15 @@ export default {
1252 1258 }
1253 1259 this.bookingDialogVisible = true
1254 1260 },
  1261 + handlePreConsumeBookingSaved(payload) {
  1262 + // mock:新建预约消耗单后,直接打开排班便于查看时间块
  1263 + this.$message.success('预约消耗单已保存')
  1264 + this.bookingConsumeDialogVisible = false
  1265 + this.$store.commit('SET_SCHEDULE_DIALOG', true)
  1266 + this.$store.commit('SET_SCHEDULE_DIALOG_MODE', 'view')
  1267 + // payload 可按需用于后续联动(例如定位到某天/某单)
  1268 + void payload
  1269 + },
1255 1270 handleQuickBilling(item) {
1256 1271 this.billingPrefill = {
1257 1272 name: item.name,
... ...