Commit c8f0ff5f17a68c17bd3bb428e57a84fcc8a48f65
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.
Showing
8 changed files
with
1731 additions
and
20 deletions
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 '@/components/TuokeLeadDialog.vue' |
| 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, | ... | ... |