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,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 '@/components/TuokeLeadDialog.vue' | @@ -433,6 +437,7 @@ import TuokeLeadDialog from '@/components/TuokeLeadDialog.vue' | ||
| 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, |