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,6 +25,7 @@ using NCC.ClayObject;
25 using NCC.Common.Const; 25 using NCC.Common.Const;
26 using NCC.Extend.Entitys.Enum; 26 using NCC.Extend.Entitys.Enum;
27 using Microsoft.AspNetCore.Http; 27 using Microsoft.AspNetCore.Http;
  28 +using System.Globalization;
28 using System.IO; 29 using System.IO;
29 using System.Data; 30 using System.Data;
30 31
@@ -288,22 +289,17 @@ namespace NCC.Extend.LqCooperationCost @@ -288,22 +289,17 @@ namespace NCC.Extend.LqCooperationCost
288 /// <summary> 289 /// <summary>
289 /// 导出合作成本表 290 /// 导出合作成本表
290 /// </summary> 291 /// </summary>
  292 + /// <remarks>
  293 + /// 未传入任何筛选参数时,默认导出全部数据;传入 storeId、storeName、year、month 时按条件筛选导出。
  294 + /// </remarks>
291 /// <param name="input">请求参数</param> 295 /// <param name="input">请求参数</param>
292 /// <returns></returns> 296 /// <returns></returns>
293 [HttpGet("Actions/Export")] 297 [HttpGet("Actions/Export")]
294 public async Task<dynamic> Export([FromQuery] LqCooperationCostListQueryInput input) 298 public async Task<dynamic> Export([FromQuery] LqCooperationCostListQueryInput input)
295 { 299 {
296 var userInfo = await _userManager.GetUserInfo(); 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 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>(); 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 ExcelConfig excelconfig = new ExcelConfig(); 304 ExcelConfig excelconfig = new ExcelConfig();
309 excelconfig.FileName = "合作成本表.xls"; 305 excelconfig.FileName = "合作成本表.xls";
@@ -402,6 +398,9 @@ namespace NCC.Extend.LqCooperationCost @@ -402,6 +398,9 @@ namespace NCC.Extend.LqCooperationCost
402 /// 第一行为标题行:门店名称、年份、月份、合计金额、成本类型、备注说明 398 /// 第一行为标题行:门店名称、年份、月份、合计金额、成本类型、备注说明
403 /// 从第二行开始为数据行 399 /// 从第二行开始为数据行
404 /// 400 ///
  401 + /// 合计金额支持正数和负数,负数可用于冲减、退款等场景;
  402 + /// 支持格式:-100、-100.50、会计格式(100)等
  403 + ///
405 /// 注意:导入时通过门店名称查找门店ID,不需要填写门店ID 404 /// 注意:导入时通过门店名称查找门店ID,不需要填写门店ID
406 /// 405 ///
407 /// 示例请求: 406 /// 示例请求:
@@ -511,10 +510,17 @@ namespace NCC.Extend.LqCooperationCost @@ -511,10 +510,17 @@ namespace NCC.Extend.LqCooperationCost
511 continue; 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 failCount++; 524 failCount++;
519 continue; 525 continue;
520 } 526 }
@@ -616,6 +622,41 @@ namespace NCC.Extend.LqCooperationCost @@ -616,6 +622,41 @@ namespace NCC.Extend.LqCooperationCost
616 throw NCCException.Oh($"导入失败:{ex.Message}"); 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 <PropertyGroup> 3 <PropertyGroup>
4 <TargetFramework>net6.0</TargetFramework> 4 <TargetFramework>net6.0</TargetFramework>
5 </PropertyGroup> 5 </PropertyGroup>
6 6
  7 + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
  8 + <DocumentationFile>bin\Debug\$(AssemblyName).xml</DocumentationFile>
  9 + </PropertyGroup>
7 <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'"> 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 </PropertyGroup> 12 </PropertyGroup>
10 13
11 <ItemGroup> 14 <ItemGroup>
store-pc/src/components/ConsumeDialog.vue
@@ -372,7 +372,37 @@ export default { @@ -372,7 +372,37 @@ export default {
372 this.form.memberName = this.prefill.name || '' 372 this.form.memberName = this.prefill.name || ''
373 this.loadMemberItems(this.prefill.memberId) 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 this.$nextTick(() => { 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 this.$refs.form && this.$refs.form.clearValidate() 406 this.$refs.form && this.$refs.form.clearValidate()
377 }) 407 })
378 }, 408 },
store-pc/src/components/EmployeeScheduleDialog.vue
@@ -103,7 +103,7 @@ @@ -103,7 +103,7 @@
103 </div> 103 </div>
104 </div> 104 </div>
105 105
106 - <!-- 预约详情弹窗:风格与主弹窗统一 --> 106 + <!-- 预约详情弹窗:风格与主弹窗统一(旧预约 mock) -->
107 <el-dialog 107 <el-dialog
108 :visible.sync="detailDialogVisible" 108 :visible.sync="detailDialogVisible"
109 width="440px" 109 width="440px"
@@ -137,15 +137,35 @@ @@ -137,15 +137,35 @@
137 :prefill="addBookingPrefill" 137 :prefill="addBookingPrefill"
138 :health-worker-options="healthWorkerOptionsFromEmployees" 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 </el-dialog> 156 </el-dialog>
141 </template> 157 </template>
142 158
143 <script> 159 <script>
144 import BookingDialog from '@/components/BookingDialog.vue' 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 export default { 166 export default {
147 name: 'EmployeeScheduleDialog', 167 name: 'EmployeeScheduleDialog',
148 - components: { BookingDialog }, 168 + components: { BookingDialog, BookingConsumeDetailDialog, ConsumeDialog },
149 props: { 169 props: {
150 visible: { type: Boolean, default: false }, 170 visible: { type: Boolean, default: false },
151 openMode: { type: String, default: 'view' } 171 openMode: { type: String, default: 'view' }
@@ -159,6 +179,12 @@ export default { @@ -159,6 +179,12 @@ export default {
159 selectedBooking: null, 179 selectedBooking: null,
160 addBookingPrefill: {}, 180 addBookingPrefill: {},
161 dragState: null, 181 dragState: null,
  182 + preConsumeBookings: [],
  183 + activePreConsumeId: '',
  184 + preConsumeDetailVisible: false,
  185 + selectedPreConsumeBooking: null,
  186 + consumeDialogVisible: false,
  187 + consumePrefill: {},
162 employeeList: [ 188 employeeList: [
163 { id: 'E001', name: '董顺秀', role: '健康师' }, 189 { id: 'E001', name: '董顺秀', role: '健康师' },
164 { id: 'E002', name: '张丽', role: '健康师' }, 190 { id: 'E002', name: '张丽', role: '健康师' },
@@ -260,24 +286,82 @@ export default { @@ -260,24 +286,82 @@ export default {
260 const timeEnd = `${String(Math.floor(b.slotEnd / 2)).padStart(2, '0')}:${(b.slotEnd % 2) ? '30' : '00'}` 286 const timeEnd = `${String(Math.floor(b.slotEnd / 2)).padStart(2, '0')}:${(b.slotEnd % 2) ? '30' : '00'}`
261 return { ...b, date: `${y}-${m}-${day}`, timeRange: `${timeStart}-${timeEnd}` } 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 watch: { 309 watch: {
266 visible(v) { 310 visible(v) {
267 if (v && !this.weekStart) this.initWeek() 311 if (v && !this.weekStart) this.initWeek()
  312 + if (v) this.loadPreConsumeBookings()
268 if (v && this.openMode === 'set') { 313 if (v && this.openMode === 'set') {
269 this.$nextTick(() => { 314 this.$nextTick(() => {
270 this.gotoNextWeek() 315 this.gotoNextWeek()
271 this.$store.commit('SET_SCHEDULE_DIALOG_MODE', 'view') 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 mounted() { 326 mounted() {
278 this.initWeek() 327 this.initWeek()
  328 + this.loadPreConsumeBookings()
279 }, 329 },
280 methods: { 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 initWeek() { 365 initWeek() {
282 const now = new Date() 366 const now = new Date()
283 const dow = now.getDay() 367 const dow = now.getDay()
@@ -354,6 +438,17 @@ export default { @@ -354,6 +438,17 @@ export default {
354 return slotIndex >= lo && slotIndex <= hi 438 return slotIndex >= lo && slotIndex <= hi
355 }, 439 },
356 slotClass(empId, date, slotIndex) { 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 const b = this.bookingsWithDate.find(x => 452 const b = this.bookingsWithDate.find(x =>
358 x.employeeId === empId && 453 x.employeeId === empId &&
359 x.date === date && 454 x.date === date &&
@@ -370,6 +465,13 @@ export default { @@ -370,6 +465,13 @@ export default {
370 return '' 465 return ''
371 }, 466 },
372 getSlotTooltip(empId, date, slotIndex) { 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 const b = this.bookingsWithDate.find(x => 475 const b = this.bookingsWithDate.find(x =>
374 x.employeeId === empId && 476 x.employeeId === empId &&
375 x.date === date && 477 x.date === date &&
@@ -405,6 +507,13 @@ export default { @@ -405,6 +507,13 @@ export default {
405 this.addBookingDialogVisible = true 507 this.addBookingDialogVisible = true
406 }, 508 },
407 handleSlotMouseDown(e, emp, date, slotIndex) { 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 const b = this.bookingsWithDate.find(x => 517 const b = this.bookingsWithDate.find(x =>
409 x.employeeId === emp.id && 518 x.employeeId === emp.id &&
410 x.date === date && 519 x.date === date &&
@@ -558,6 +667,59 @@ export default { @@ -558,6 +667,59 @@ export default {
558 if (!d || this.dragState.dayOffset !== d.dayOffset) return 667 if (!d || this.dragState.dayOffset !== d.dayOffset) return
559 this.dragState.endSlot = slotIndex 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 </script> 725 </script>
@@ -935,6 +1097,40 @@ export default { @@ -935,6 +1097,40 @@ export default {
935 .slot--resize-start { cursor: w-resize; } 1097 .slot--resize-start { cursor: w-resize; }
936 .slot--resize-end { cursor: e-resize; } 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 ::v-deep .schedule-detail-dialog { 1135 ::v-deep .schedule-detail-dialog {
940 border-radius: 20px; 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,6 +371,10 @@
371 @action="handleMemberAction" 371 @action="handleMemberAction"
372 /> 372 />
373 <tuoke-lead-dialog :visible.sync="tuokeDialogVisible" /> 373 <tuoke-lead-dialog :visible.sync="tuokeDialogVisible" />
  374 + <booking-consume-dialog
  375 + :visible.sync="bookingConsumeDialogVisible"
  376 + @saved="handlePreConsumeBookingSaved"
  377 + />
374 <booking-dialog :visible.sync="bookingDialogVisible" :prefill="bookingPrefill" /> 378 <booking-dialog :visible.sync="bookingDialogVisible" :prefill="bookingPrefill" />
375 <billing-dialog :visible.sync="billingDialogVisible" :prefill="billingPrefill" /> 379 <billing-dialog :visible.sync="billingDialogVisible" :prefill="billingPrefill" />
376 <consume-dialog :visible.sync="consumeDialogVisible" /> 380 <consume-dialog :visible.sync="consumeDialogVisible" />
@@ -433,6 +437,7 @@ import TuokeLeadDialog from &#39;@/components/TuokeLeadDialog.vue&#39; @@ -433,6 +437,7 @@ import TuokeLeadDialog from &#39;@/components/TuokeLeadDialog.vue&#39;
433 import InviteAddDialog from '@/components/InviteAddDialog.vue' 437 import InviteAddDialog from '@/components/InviteAddDialog.vue'
434 import CompanyCalendarDialog from '@/components/CompanyCalendarDialog.vue' 438 import CompanyCalendarDialog from '@/components/CompanyCalendarDialog.vue'
435 import BookingDialog from '@/components/BookingDialog.vue' 439 import BookingDialog from '@/components/BookingDialog.vue'
  440 +import BookingConsumeDialog from '@/components/booking-consume-dialog.vue'
436 import BillingDialog from '@/components/BillingDialog.vue' 441 import BillingDialog from '@/components/BillingDialog.vue'
437 import ConsumeDialog from '@/components/ConsumeDialog.vue' 442 import ConsumeDialog from '@/components/ConsumeDialog.vue'
438 import RefundDialog from '@/components/RefundDialog.vue' 443 import RefundDialog from '@/components/RefundDialog.vue'
@@ -452,6 +457,7 @@ export default { @@ -452,6 +457,7 @@ export default {
452 InviteAddDialog, 457 InviteAddDialog,
453 CompanyCalendarDialog, 458 CompanyCalendarDialog,
454 BookingDialog, 459 BookingDialog,
  460 + BookingConsumeDialog,
455 BillingDialog, 461 BillingDialog,
456 ConsumeDialog, 462 ConsumeDialog,
457 RefundDialog, 463 RefundDialog,
@@ -797,6 +803,7 @@ export default { @@ -797,6 +803,7 @@ export default {
797 name: '', 803 name: '',
798 phone: '' 804 phone: ''
799 }, 805 },
  806 + bookingConsumeDialogVisible: false,
800 billingDialogVisible: false, 807 billingDialogVisible: false,
801 billingPrefill: {}, 808 billingPrefill: {},
802 consumeDialogVisible: false, 809 consumeDialogVisible: false,
@@ -1025,8 +1032,7 @@ export default { @@ -1025,8 +1032,7 @@ export default {
1025 return 1032 return
1026 } 1033 }
1027 if (module.key === 'booking') { 1034 if (module.key === 'booking') {
1028 - this.bookingPrefill = { name: '', phone: '' }  
1029 - this.bookingDialogVisible = true 1035 + this.bookingConsumeDialogVisible = true
1030 return 1036 return
1031 } 1037 }
1032 if (module.key === 'order') { 1038 if (module.key === 'order') {
@@ -1252,6 +1258,15 @@ export default { @@ -1252,6 +1258,15 @@ export default {
1252 } 1258 }
1253 this.bookingDialogVisible = true 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 handleQuickBilling(item) { 1270 handleQuickBilling(item) {
1256 this.billingPrefill = { 1271 this.billingPrefill = {
1257 name: item.name, 1272 name: item.name,