work-field-form.vue 35.4 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887
<template>
  <view class="work-field-form">
    <up-form label-position="left" :model="inspectForm" ref="inspectFormRef" :label-width="labelWidth">
      <!-- ========== 动态表单区域 ========== -->
      <template v-for="item in dynamicFields" :key="item.fieldCode">
        <up-form-item :label="item.fieldName" :prop="item.fieldCode" class="form-item"
          @click="openPickerIfNeeded(item)">

          <!-- type 1 数值:u-number-box,最多2位小数,min=0,max取item.length -->
          <view v-if="item.fieldType === 1" class="number-box-wrap">
            <u-number-box v-model="inspectForm[item.fieldCode]" min="1" :step="1"
              :max="item.length ? 10 ** Number(item.length) - 1 : null"
              :placeholder="`请输入${item.fieldName}${item.unit} `" @change="valChange(item)" @blur="valChange(item)"
              class="large-number-box">
            </u-number-box>

            <text class="unit-text" v-if="item.unit">{{ item.unit }}</text>
          </view>
          <!-- type 2 文本 -->
          <up-input v-else-if="item.fieldType === 2" v-model="inspectForm[item.fieldCode]"
            :maxlength="item.length || 50" :placeholder="`请输入${item.fieldName}`"></up-input>
          <!-- 分层选项(3枚举/6多选,且选项带 path):统一只读入口,点开分组弹层选择。
               必须放在下面的 3/6 分支之前——v-else-if 短路,放后面就永远进不来 -->
          <up-input v-else-if="isGroupedField(item)" v-model="labelMap[item.fieldCode]" readonly
            disabled-color="#ffffff" :placeholder="`请选择${item.fieldName}`" border="none">
            <template #right>
              <up-icon name="arrow-right" size="16"></up-icon>
            </template>
          </up-input>
          <!-- type3 枚举:多选 multiple=1 使用复选框组(name 必须是 value,提交按逗号拼接 value,与值格式契约一致) -->
          <u-checkbox-group v-else-if="item.fieldType === 3 && item.multiple === 1"
            v-model="inspectForm[item.fieldCode]">
            <u-checkbox v-for="opt in item.selectOptions" :key="opt.value" :label="opt.label" :name="opt.value">
            </u-checkbox>
          </u-checkbox-group>
          <!-- type3 枚举:单选 用up-action-sheet 底部选择 -->
          <up-input v-else-if="item.fieldType === 3" v-model="labelMap[item.fieldCode]" readonly
            disabled-color="#ffffff" :placeholder="`请选择${item.fieldName}`" border="none">
            <template #right>
              <up-icon name="arrow-right" size="16"></up-icon>
            </template>
          </up-input>
          <!-- type4 日期时间 -->
          <view v-else-if="item.fieldType === 4" @click.stop="openDatePicker(item)" class="date-input-wrap">
            <up-input v-model="inspectForm[item.fieldCode]" disabled placeholder="点击选择时间"></up-input>
          </view>
          <!-- type5 布尔单选 是/否 -->
          <up-radio-group v-else-if="item.fieldType === 5" v-model="inspectForm[item.fieldCode]">
            <up-radio label="是" name="是"></up-radio>
            <up-radio label="否" name="否"></up-radio>
          </up-radio-group>
          <!-- type6 多选:复选框组;选项来源可为静态/字典/物料/数据表/SQL/接口 -->
          <view v-else-if="item.fieldType === 6" class="multi-wrap">
            <u-checkbox-group v-model="inspectForm[item.fieldCode]" @change="onMultiChange(item)">
              <u-checkbox v-for="opt in item.selectOptions" :key="opt.value" :label="opt.label" :name="opt.value"
                :disabled="isOptionLocked(item, opt)">
              </u-checkbox>
            </u-checkbox-group>
            <!-- 有限上限时提示已选数。上限靠「禁用未选中项」实现 -->
            <text v-if="multiHint(item)" class="multi-hint">{{ multiHint(item) }}</text>
            <!-- needQuantity:已选项各配一个数量输入,值按 {value,label,quantity} 提交。此形态仅记录用量、不扣库存 -->
            <view v-if="needQty(item)" class="qty-list">
              <view v-for="opt in checkedOptions(item)" :key="opt.value" class="qty-row">
                <text class="qty-label">{{ opt.label }}</text>
                <u-number-box v-model="qtyMap[item.fieldCode][opt.value]" :min="0" :step="1"></u-number-box>
              </view>
            </view>
          </view>
          <!-- type7 图片:多张上传,提交 JSON 数组(URL) -->
          <up-upload v-else-if="item.fieldType === 7" :file-list="fileListMap[item.fieldCode] || []"
            @after-read="(event) => uploadFieldFiles(item, event)" @delete="(event) => deleteFieldFile(item, event)"
            multiple width="70" height="70" :sizeType="['compressed']" :max-count="pickCountOf(item, 5)"
            upload-text="选择图片" del-color="#ff4d4f" class="upload-wrap"></up-upload>

          <!-- type8 附件:微信只能从聊天记录选文件(无通用文件选择器),提交 JSON 数组(URL) -->
          <view v-else-if="item.fieldType === 8" class="file-wrap">
            <view v-for="(file, idx) in fileListMap[item.fieldCode] || []" :key="idx" class="file-row">
              <text class="file-name">{{ file.name || fileNameOf(file.url) }}</text>
              <text class="file-del" @click.stop="deleteFieldFile(item, { index: idx })">删除</text>
            </view>
            <up-button size="small" type="primary" plain :text="fileButtonText(item)"
              @click.stop="chooseFieldFiles(item)"></up-button>
          </view>
          <!-- type9 关联记录:底部选择,提交 JSON 数组 [{id,name}] -->
          <up-input v-else-if="item.fieldType === 9" v-model="labelMap[item.fieldCode]" readonly
            disabled-color="#ffffff" :placeholder="`请选择${item.fieldName}`" border="none">
            <template #right>
              <up-icon name="arrow-right" size="16"></up-icon>
            </template>
          </up-input>
          <!-- type10 链接:URL 输入,提交裸 URL -->
          <up-input v-else-if="item.fieldType === 10" v-model.trim="inspectForm[item.fieldCode]"
            :placeholder="`请输入${item.fieldName}(http/https 开头)`"></up-input>
          <!-- type11 地理位置:uni.chooseLocation 选点,提交 JSON 对象 {lng,lat,address} -->
          <up-input v-else-if="item.fieldType === 11" v-model="labelMap[item.fieldCode]" readonly
            disabled-color="#ffffff" :placeholder="`点击选择${item.fieldName}`" border="none">
            <template #right>
              <up-icon name="arrow-right" size="16"></up-icon>
            </template>
          </up-input>
          <!-- 物料(fieldType=3 + optionSource=3):额外收领用数量;数量>0 才提交 materialId/materialName/quantity 触发扣库存。
               必须放在 v-if/v-else-if 链末尾——插在中间会打断链导致编译报错 -->
          <view v-if="item.fieldType === 3 && item.optionSource === 3" class="qty-row qty-row-material">
            <text class="qty-label">领用数量</text>
            <u-number-box v-model="qtyMap[item.fieldCode]" :min="0" :step="1"></u-number-box>
          </view>
        </up-form-item>
      </template>
      <!-- ========== 动态表单结束 ========== -->
    </up-form>

    <!-- uview-plus 日期时间选择器(全局复用) -->
    <up-datetime-picker :show="datePickerShow" v-model="datePickerValue" mode="datetime" close-on-click-overlay
      @confirm="onDateConfirm" @cancel="onDateClose" @close="onDateClose"></up-datetime-picker>
    <!-- ActionSheet 底部选择弹窗 -->
    <up-action-sheet :show="actionSheet.show" :actions="actionSheet.options" @select="onActionSheetSelect"
      @close="actionSheet.show = false" cancel-text="取消"></up-action-sheet>
    <!-- 分层选项选择器(选项带 path 的动态字段) -->
    <grouped-picker :show="groupPicker.show" :title="groupPicker.title" :options="groupPicker.options"
      :multiple="groupPicker.multiple" :max-count="groupPicker.maxCount" :need-quantity="groupPicker.needQuantity"
      :value="groupPicker.value" :container-width="groupPicker.containerWidth" :quantities="groupPicker.quantities"
      @update:show="groupPicker.show = $event" @confirm="onGroupedConfirm"></grouped-picker>
  </view>
</template>

<script>
import { uploadImages } from '@/common/utils/upload'
import { getByType, getOptionsByCode, getMaterialByCode } from '@/api/common'

/**
 * 动态字段表单(业务类型字段配置驱动)
 *
 * 数据来源:数据中心 / 业务类型字段配置(按 businessType + bizType + workTypeId 定位分类与版本)。
 * 使用方式:
 *   <work-field-form ref="workFieldFormRef" :business-type="bizLine" :biz-type="1" :work-type-id="workTypeId"
 *                    @loaded="onFieldsLoaded" />
 * 父页面通过 ref 调用 validate() / getValues() 完成校验与提交体组装。
 *
 * 说明:businessType / bizType / workTypeId 任一变化都会重新加载;bizType 1=工单 2=道路 3=养护。
 */
export default {
  name: 'WorkFieldForm',
  props: {
    // 业务类型(园林养护数据口径):yl园林 / hj环卫 / sz市政 / zx / wy
    businessType: { type: String, default: '' },
    // 来源:1工单 / 2道路 / 3养护
    bizType: { type: Number, default: 1 },
    // 作业类型(dict: maintain_type)
    workTypeId: { type: [String, Number], default: '' },
    // 与父表单对齐的标签宽度
    labelWidth: { type: String, default: '170rpx' }
  },
  data() {
    return {
      inspectForm: {},
      // 用来存放展示文本label的映射对象
      labelMap: {},
      // 类型7图片/8附件的每字段文件列表(fieldCode -> u-upload 兼容数组 {url,status,name})
      fileListMap: {},
      // 数量收集:类型6+needQuantity 为 {选项值: 数量};类型3物料为标量(领用数量)
      qtyMap: {},
      // 动态字段列表
      dynamicFields: [],
      // 必填规则(随字段配置动态生成)
      inspectFormRules: {},
      // 日期时间选择器
      datePickerShow: false,
      datePickerValue: Number(new Date()),
      currentDateFieldCode: '',
      // actionSheet配置
      actionSheet: {
        show: false,
        fieldCode: '',
        options: [],
        fieldItem: null
      },
      // 分层选项选择器配置(选项带 path 的 3/6 字段用,其余字段仍走上面的 actionSheet / 内联复选框)
      groupPicker: {
        show: false,
        fieldCode: '',
        fieldItem: null,
        title: '',
        options: [],
        multiple: false,
        maxCount: Infinity,
        needQuantity: false,
        value: '',
        quantities: {},
        containerWidth: 0
      }
    }
  },
  watch: {
    businessType() {
      this.reload()
    },
    workTypeId() {
      this.reload()
    }
  },
  mounted() {
    this.reload()
  },
  methods: {
    /**
     * 重新加载字段配置(作业类型切换时复用同一实例)
     */
    reload() {
      if (!this.businessType || !this.workTypeId) {
        this.dynamicFields = []
        this.$emit('loaded', 0)
        return
      }
      this.resetForm()
      this.loadDynamicFields(this.businessType, this.workTypeId)
    },
    resetForm() {
      this.inspectForm = {}
      this.labelMap = {}
      this.fileListMap = {}
      this.qtyMap = {}
      this.dynamicFields = []
      this.inspectFormRules = {}
    },
    // ========== 对外方法 ==========
    /** 表单校验,失败时 reject 字段数组(与 uview-plus 一致) */
    validate() {
      const formRef = this.$refs.inspectFormRef
      if (!formRef) {
        return Promise.resolve()
      }
      return formRef.validate()
    },
    /** 组装 fields[] 提交体(空值不出现;物料数量>0 才带物料三列) */
    getValues() {
      return this.buildDynamicFields()
    },
    /** 当前是否有可填字段 */
    hasFields() {
      return this.dynamicFields.length > 0
    },
    // number-box数值变更回调
    valChange(item) {
      this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(item.fieldCode)
    },
    // ========== 日期时间相关方法 ==========
    openDatePicker(field) {
      this.currentDateFieldCode = field.fieldCode
      const val = this.inspectForm[field.fieldCode]
      this.datePickerValue = val ? new Date(val).getTime() : Number(new Date())
      this.datePickerShow = true
      uni.hideKeyboard()
    },
    onDateConfirm(e) {
      const ts = e.value
      const d = new Date(ts)
      const y = d.getFullYear()
      const m = String(d.getMonth() + 1).padStart(2, '0')
      const day = String(d.getDate()).padStart(2, '0')
      const h = String(d.getHours()).padStart(2, '0')
      const mi = String(d.getMinutes()).padStart(2, '0')
      const s = String(d.getSeconds()).padStart(2, '0')
      this.inspectForm[this.currentDateFieldCode] = `${y}-${m}-${day} ${h}:${mi}:${s}`
      this.datePickerShow = false
      this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(this.currentDateFieldCode)
    },
    onDateClose() {
      this.datePickerShow = false
    },
    /**
     * 点击表单项时按类型决定弹出哪个选择器
     */
    openPickerIfNeeded(item) {
      if (this.isGroupedField(item)) {
        return this.openGroupedPicker(item)
      }
      // 模板用 multiple === 1 渲染复选框组,这里就必须用 !== 1 才打开弹窗
      if (item.fieldType === 3 && item.multiple !== 1) {
        return this.openActionSheet(item)
      }
      if (item.fieldType === 9) {
        return this.openActionSheet(item)
      }
      if (item.fieldType === 11) {
        return this.openLocation(item)
      }
    },
    /** 是否为「分层选项」字段:3枚举/6多选,且选项带了后端下发的 path */
    isGroupedField(item) {
      if (!item || (item.fieldType !== 3 && item.fieldType !== 6)) {
        return false
      }
      return (item.selectOptions || []).some((opt) => Array.isArray(opt.path) && opt.path.length > 0)
    },
    /** 打开分层选项弹层 */
    openGroupedPicker(item) {
      const code = item.fieldCode
      const multiple = item.fieldType === 6 || item.multiple === 1
      this.groupPicker.fieldCode = code
      this.groupPicker.fieldItem = item
      this.groupPicker.title = item.fieldName
      this.groupPicker.options = item.selectOptions || []
      this.groupPicker.multiple = multiple
      this.groupPicker.maxCount = this.maxCountOf(item, Infinity)
      // 只有 6 多选需要每项数量;3 枚举没有这层语义
      this.groupPicker.needQuantity = item.fieldType === 6 && this.needQty(item)
      this.groupPicker.value = multiple ? (this.inspectForm[code] || []).slice() : (this.inspectForm[code] || '')
      this.groupPicker.quantities = Object.assign({}, this.qtyMap[code] || {})
      // 打开弹窗瞬间获取父容器宽度
      const query = uni.createSelectorQuery().in(this)
      query.select('.work-field-form').boundingClientRect((rect) => {
        if (rect && rect.width) {
          this.groupPicker.containerWidth = rect.width
        }
        this.groupPicker.show = true
      }).exec()
    },
    /** 分层弹层确定回调 */
    onGroupedConfirm(payload) {
      const code = this.groupPicker.fieldCode
      this.inspectForm[code] = payload.values
      this.labelMap[code] = payload.labelText
      if (this.groupPicker.needQuantity) {
        // 合并而不是替换:保留 loadDynamicFields 预建的选项键
        this.qtyMap[code] = Object.assign({}, this.qtyMap[code] || {}, payload.quantities || {})
      }
      this.groupPicker.show = false
      this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(code)
    },
    /** 打开底部选择弹窗 actionSheet */
    openActionSheet(item) {
      this.actionSheet.fieldCode = item.fieldCode
      this.actionSheet.fieldItem = item
      // 物料带 currentNum 时在展示名后附库存余量(只影响展示名,提交的 materialName 仍取干净的 opt.label)
      this.actionSheet.options = (item.selectOptions || []).map((opt) => {
        const stock = opt.currentNum === null || opt.currentNum === undefined || opt.currentNum === ''
          ? NaN
          : Number(opt.currentNum)
        return {
          name: isNaN(stock) ? opt.label : `${opt.label}(库存${stock}${opt.unit || ''})`,
          value: opt.value
        }
      })
      this.actionSheet.show = true
    },
    /** actionSheet选中回调 */
    onActionSheetSelect(selectedItem) {
      const fieldItem = this.actionSheet.fieldItem
      const fieldCode = this.actionSheet.fieldCode
      // 9 关联记录存 {id,name}(提交时转成 [{id,name}]),其余存裸 value
      if (fieldItem && fieldItem.fieldType === 9) {
        this.inspectForm[fieldCode] = { id: selectedItem.value, name: selectedItem.name }
      } else {
        this.inspectForm[fieldCode] = selectedItem.value
      }
      this.labelMap[fieldCode] = selectedItem.name
      this.actionSheet.show = false
      this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(fieldCode)
    },
    /** 类型11 地理位置:微信地图选点 */
    openLocation(item) {
      uni.chooseLocation({
        success: (res) => {
          this.inspectForm[item.fieldCode] = {
            lng: res.longitude,
            lat: res.latitude,
            address: res.address || res.name || ''
          }
          this.labelMap[item.fieldCode] = res.address || res.name || `${res.latitude}, ${res.longitude}`
          this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(item.fieldCode)
        },
        fail: (err) => {
          console.error('地图选择失败', err)
          if (err && err.errMsg && err.errMsg.indexOf('auth deny') > -1) {
            uni.showToast({ title: '请授权位置权限', icon: 'none' })
          }
        }
      })
    },
    /**
     * 加载动态字段
     */
    async loadDynamicFields(businessType, workTypeId) {
      try {
        const res = await getByType({
          businessType,
          workTypeId,
          bizType: this.bizType
        })
        let fields = res.fields || []
        fields.sort((a, b) => a.sort - b.sort)
        if (fields.length === 0) {
          this.dynamicFields = []
          this.$emit('loaded', 0)
          return
        }
        for (const field of fields) {
          // 初值:多选为数组;9关联记录/11地理位置 为对象(未选为 null);7图片/8附件为 URL 数组;其余空字符串
          if (field.fieldType === 6 || (field.fieldType === 3 && field.multiple === 1)) {
            this.inspectForm[field.fieldCode] = []
          } else if (field.fieldType === 7 || field.fieldType === 8) {
            this.inspectForm[field.fieldCode] = []
          } else if (field.fieldType === 9 || field.fieldType === 11) {
            this.inspectForm[field.fieldCode] = null
          } else {
            this.inspectForm[field.fieldCode] = ''
          }
          this.labelMap[field.fieldCode] = ''
          if (field.fieldType === 7 || field.fieldType === 8) {
            this.fileListMap[field.fieldCode] = []
          }
          // 选项装载:静态来源(1)直接用下发的 field.options,其余来源(2字典/3物料/4数据表/5SQL/6接口)调 options 接口
          if (field.fieldType === 3 || field.fieldType === 6 || field.fieldType === 9) {
            if (field.optionSource === 1 && Array.isArray(field.options)) {
              field.selectOptions = field.options.map((opt) => ({ label: opt.label, value: String(opt.value), path: opt.path }))
            } else if (field.optionSource === 3) {
              const matRes = await getMaterialByCode({
                businessType,
                bizType: this.bizType,
                workTypeId,
                fieldCode: field.fieldCode
              })
              field.selectOptions = (matRes || []).map((mat) => ({
                label: mat.materialName,
                value: String(mat.materialId),
                currentNum: mat.currentNum,
                unit: mat.unit
              }))
            } else {
              const optRes = await getOptionsByCode({
                businessType,
                bizType: this.bizType,
                workTypeId,
                fieldCode: field.fieldCode
              })
              field.selectOptions = (optRes || []).map((opt) => ({ label: opt.label, value: String(opt.value), path: opt.path }))
            }
          }
          // 数量收集初始化(必须在 selectOptions 装载之后)
          if (field.fieldType === 6 && this.needQty(field)) {
            const qty = {}
            ;(field.selectOptions || []).forEach((opt) => {
              qty[opt.value] = ''
            })
            this.qtyMap[field.fieldCode] = qty
          }
          if (field.fieldType === 3 && field.optionSource === 3) {
            this.qtyMap[field.fieldCode] = 0
          }
          // 必填规则
          if (field.required === 1) {
            this.inspectFormRules[field.fieldCode] = [this.buildRequiredRule(field)]
          }
        }
        this.dynamicFields = fields
        this.$nextTick(() => {
          this.$refs.inspectFormRef && this.$refs.inspectFormRef.setRules(this.inspectFormRules)
        })
        this.$emit('loaded', fields.length)
      } catch (err) {
        console.error('获取动态表单字段失败:', err)
        uni.showToast({ title: '加载表单配置失败', icon: 'none' })
        this.$emit('loaded', 0)
      }
    },
    // ========== 动态字段:渲染与取值辅助 ==========
    /** 类型6 多选是否要求每项填「使用数量」(ext_config.needQuantity) */
    needQty(item) {
      return !!(item.extConfig && item.extConfig.needQuantity === true)
    },
    /** 有效上限:ext_config.maxCount(0 表示不限) */
    maxCountOf(item, defaultValue) {
      const raw = item.extConfig ? item.extConfig.maxCount : undefined
      if (raw === undefined || raw === null || raw === '') {
        return defaultValue
      }
      const num = Number(raw)
      if (isNaN(num)) {
        return defaultValue
      }
      return num > 0 ? num : Infinity
    },
    /** 传给选择器的单次可选数量:上限为「不限」时用组件默认值 52 */
    pickCountOf(item, defaultValue) {
      const max = this.maxCountOf(item, defaultValue)
      return max === Infinity ? 52 : max
    },
    /** 单文件大小上限(MB):ext_config.maxSizeMb */
    maxSizeMbOf(item, defaultValue) {
      const num = item.extConfig ? Number(item.extConfig.maxSizeMb) : NaN
      return num > 0 ? num : defaultValue
    },
    /** 类型8 允许的扩展名数组(ext_config.fileType,不含点) */
    extensionsOf(item) {
      const list = item.extConfig && item.extConfig.fileType
      return Array.isArray(list) ? list : []
    },
    fileButtonText(item) {
      const exts = this.extensionsOf(item)
      return exts.length ? `选择文件(${exts.join('/')})` : '选择文件'
    },
    /** 从 URL 取末段当文件名 */
    fileNameOf(url) {
      if (!url) return ''
      const path = String(url).split('?')[0].split('#')[0]
      const name = path.substring(path.lastIndexOf('/') + 1)
      try {
        return decodeURIComponent(name) || url
      } catch (err) {
        return name || url
      }
    },
    /** 类型6 已勾选的选项 */
    checkedOptions(item) {
      const values = this.inspectForm[item.fieldCode] || []
      return (item.selectOptions || []).filter((opt) => values.indexOf(opt.value) > -1)
    },
    /** 多选是否达到上限(用于禁用未选中项) */
    isOptionLocked(item, opt) {
      const max = this.maxCountOf(item, Infinity)
      if (max === Infinity) {
        return false
      }
      const values = this.inspectForm[item.fieldCode] || []
      return values.length >= max && values.indexOf(opt.value) === -1
    },
    /** 多选的「已选 x/上限」提示,不限时返回空串 */
    multiHint(item) {
      const max = this.maxCountOf(item, Infinity)
      if (max === Infinity) {
        return ''
      }
      return `已选 ${(this.inspectForm[item.fieldCode] || []).length}/${max}`
    },
    onMultiChange(item) {
      this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(item.fieldCode)
    },
    /** 必填规则:7/8/9/11 的值是数组或对象,必须用 validator 显式判空 */
    buildRequiredRule(field) {
      const type = field.fieldType
      if (type === 6 || type === 7 || type === 8) {
        const action = type === 6 ? '请选择' : '请上传'
        return {
          required: true,
          trigger: ['change', 'blur'],
          validator: (rule, value, callback) => {
            Array.isArray(value) && value.length > 0 ? callback() : callback(new Error(`${action}${field.fieldName}`))
          }
        }
      }
      if (type === 9 || type === 11) {
        return {
          required: true,
          trigger: ['change', 'blur'],
          validator: (rule, value, callback) => {
            value ? callback() : callback(new Error(type === 9 ? `请选择${field.fieldName}` : `请选择${field.fieldName}的位置`))
          }
        }
      }
      if (type === 1) {
        return {
          required: true,
          trigger: ['change', 'blur'],
          validator: (rule, value, callback) => {
            // 空字符串、null、undefined 才是空;数字 0 合法
            if (value === '' || value === null || value === undefined) {
              callback(new Error(`请填写${field.fieldName}`))
            } else {
              callback()
            }
          }
        }
      }
      // 2文本 /4日期 /5布尔 /10链接
      const action = type === 3 || type === 5 ? '请选择' : '请填写'
      return {
        required: true,
        message: `${action}${field.fieldName}`,
        trigger: ['change', 'blur']
      }
    },
    // ========== 动态字段:7图片/8附件 ==========
    /** 单文件是否超过 ext_config.maxSizeMb */
    isOversize(file, maxSizeMb) {
      if (!maxSizeMb || !file || !file.size) return false
      const over = file.size / 1024 / 1024 > maxSizeMb
      if (over) {
        uni.showToast({ title: `单个文件不能超过${maxSizeMb}MB`, icon: 'none' })
      }
      return over
    },
    /** 把已上传成功的 URL 同步进表单值(提交与校验都读 inspectForm) */
    syncFileValue(item) {
      const list = this.fileListMap[item.fieldCode] || []
      this.inspectForm[item.fieldCode] = list.filter((file) => file.status === 'success').map((file) => file.url)
    },
    /** 7图片 上传(u-upload 的 after-read 回调) */
    uploadFieldFiles(item, event) {
      const rawFile = event && event.file
      const fileList = Array.isArray(rawFile) ? rawFile : [rawFile]
      this.uploadToField(item, fileList.filter((file) => file && file.url).map((file) => ({
        url: file.url,
        name: file.name || '',
        size: file.size
      })))
    },
    /**
     * 8附件 选择并上传。
     * 微信小程序没有通用文件选择器,只能从聊天记录里选(uni.chooseMessageFile)
     */
    chooseFieldFiles(item) {
      const maxCount = this.maxCountOf(item, 5)
      const remain = maxCount - (this.fileListMap[item.fieldCode] || []).length
      if (remain <= 0) {
        uni.showToast({ title: `最多只能上传${maxCount}个`, icon: 'none' })
        return
      }
      const exts = this.extensionsOf(item)
      const options = { count: Math.min(remain, this.pickCountOf(item, 5)), type: 'file' }
      if (exts.length) {
        options.extension = exts
      }
      uni.chooseMessageFile({
        ...options,
        success: (res) => {
          const tempFiles = res.tempFiles || []
          this.uploadToField(item, tempFiles.map((file) => ({
            url: file.path,
            name: file.name || '',
            size: file.size
          })))
        },
        fail: (err) => {
          console.error('选择附件失败:', err)
        }
      })
    },
    /** 把选中的本地文件逐个上传并把 http 地址回填到 fileListMap / inspectForm */
    async uploadToField(item, files) {
      const code = item.fieldCode
      const maxCount = this.maxCountOf(item, 5)
      const maxSizeMb = this.maxSizeMbOf(item, 5)
      const validFiles = (files || []).filter((file) => file && file.url && !this.isOversize(file, maxSizeMb))
      if (!validFiles.length) {
        return
      }
      const targetList = this.fileListMap[code] || (this.fileListMap[code] = [])
      if (targetList.length + validFiles.length > maxCount) {
        uni.showToast({ title: `最多只能上传${maxCount}个`, icon: 'none' })
        return
      }
      const startIndex = targetList.length
      targetList.push(...validFiles.map((file) => ({
        url: file.url, status: 'uploading', message: '上传中', name: file.name || ''
      })))
      try {
        const uploadResultUrls = await uploadImages({
          filePaths: validFiles.map((file) => file.url),
          ignoreError: true
        })
        uploadResultUrls.forEach((url, index) => {
          if (targetList[startIndex + index]) {
            targetList.splice(startIndex + index, 1, {
              url, status: 'success', message: '', name: validFiles[index].name || ''
            })
          }
        })
        for (let i = uploadResultUrls.length; i < validFiles.length; i++) {
          if (targetList[startIndex + i]) {
            targetList.splice(startIndex + i, 1, {
              url: validFiles[i].url, status: 'failed', message: '上传失败', name: validFiles[i].name || ''
            })
          }
        }
        if (uploadResultUrls.length < validFiles.length) {
          uni.showToast({
            title: `成功${uploadResultUrls.length}个,失败${validFiles.length - uploadResultUrls.length}个`,
            icon: 'none'
          })
        }
      } catch (err) {
        console.error(`【${code}】文件上传失败:`, err)
        for (let i = 0; i < validFiles.length; i++) {
          if (targetList[startIndex + i]) {
            targetList.splice(startIndex + i, 1, {
              url: validFiles[i].url, status: 'failed', message: '上传失败', name: validFiles[i].name || ''
            })
          }
        }
        uni.showToast({ title: '上传失败,请重试', icon: 'none' })
      }
      this.syncFileValue(item)
      this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(code)
    },
    /** 删除 7图片/8附件 的某一个已选文件 */
    deleteFieldFile(item, event) {
      const list = this.fileListMap[item.fieldCode] || []
      const index = Number(event && event.index)
      if (isNaN(index) || index < 0 || index >= list.length) {
        return
      }
      list.splice(index, 1)
      this.syncFileValue(item)
      this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(item.fieldCode)
    },
    // ========== 动态字段:组装提交体 ==========
    /**
     * 按值格式契约组装 fields[]。
     * ① 空值一律不出现在 fields[] 里(不提交 ''/'[]'/'null')
     * ② 物料的数量>0 才写 materialId/materialName/quantity —— 非空 materialId 即触发扣库存
     */
    buildDynamicFields() {
      const fields = []
      for (const item of this.dynamicFields) {
        const code = item.fieldCode
        const value = this.inspectForm[code]
        const type = item.fieldType
        if (type === 9 || type === 11) {
          if (!value) continue
        } else if (type === 6 || type === 7 || type === 8) {
          if (!Array.isArray(value) || value.length === 0) continue
        } else if (value === '' || value === null || value === undefined) {
          continue
        }

        if (type === 1) {
          fields.push({ fieldCode: code, valueNumber: Number(value) })
        } else if (type === 3 && item.optionSource === 3) {
          // 物料:value_text 存物料ID;数量>0 才落物料三列(0 表示本次不领用)
          const qty = Number(this.qtyMap[code] || 0)
          const matched = (item.selectOptions || []).filter((opt) => opt.value === String(value))[0]
          const row = { fieldCode: code, valueText: String(value) }
          if (qty > 0) {
            row.materialId = Number(value)
            row.materialName = matched ? matched.label : ''
            row.quantity = qty
          }
          fields.push(row)
        } else if (type === 3) {
          // 3枚举:单选取裸 value;历史「枚举+是否多选」存逗号分隔
          fields.push({
            fieldCode: code,
            valueText: item.multiple === 1
              ? (Array.isArray(value) ? value.join(',') : String(value || ''))
              : String(value)
          })
        } else if (type === 6) {
          const selected = (item.selectOptions || []).filter((opt) => (value || []).indexOf(opt.value) > -1)
          if (this.needQty(item)) {
            // 元素 {value,label,quantity};数量留空的不带 quantity 键,label 作快照落库
            const qtyMap = this.qtyMap[code] || {}
            fields.push({
              fieldCode: code,
              valueText: JSON.stringify(selected.map((opt) => {
                const raw = qtyMap[opt.value]
                const row = { value: opt.value, label: opt.label }
                if (raw !== '' && raw !== null && raw !== undefined) {
                  row.quantity = Number(raw)
                }
                return row
              }))
            })
          } else {
            fields.push({ fieldCode: code, valueText: JSON.stringify(value.map((v) => String(v))) })
          }
        } else if (type === 7 || type === 8) {
          fields.push({ fieldCode: code, valueText: JSON.stringify(value) })
        } else if (type === 9) {
          fields.push({ fieldCode: code, valueText: JSON.stringify([{ id: value.id, name: value.name }]) })
        } else if (type === 11) {
          fields.push({
            fieldCode: code,
            valueText: JSON.stringify({ lng: value.lng, lat: value.lat, address: value.address || '' })
          })
        } else {
          // 2文本 / 4日期 / 5布尔 / 10链接 均为裸文本
          fields.push({ fieldCode: code, valueText: String(value) })
        }
      }
      return fields
    }
  }
}
</script>

<style lang="scss" scoped>
.work-field-form {
  width: 100%;
}

.date-input-wrap {
  width: 100%;
}

// 放大u-number-box输入框
.large-number-box {
  :deep(.u-number-box__input) {
    width: 264rpx !important;
    height: 72rpx;
    font-size: 32rpx;
  }

  :deep(.u-number-box__btn) {
    width: 64rpx;
    height: 72rpx;
  }
}

.number-box-wrap {
  display: flex;
  align-items: center;
  gap: 16rpx;

  .unit-text {
    font-size: 28rpx;
    color: #333;
  }
}

// 6多选:复选框组 + needQuantity 时每个已选项一行数量输入
.multi-wrap {
  width: 100%;

  .multi-hint {
    display: block;
    margin-top: 8rpx;
    font-size: 24rpx;
    color: #909399;
  }

  .qty-list {
    margin-top: 16rpx;
    padding-left: 20rpx;
    border-left: 4rpx solid #f0f0f0;
  }
}

// 数量行(6多选的每个已选项 / 3物料的领用数量共用)
.qty-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16rpx;
  padding: 8rpx 0;

  .qty-label {
    flex: 1;
    min-width: 0;
    font-size: 28rpx;
    color: #333;
  }
}

.qty-row-material {
  margin-top: 12rpx;
  padding-top: 12rpx;
  border-top: 1rpx solid #f0f0f0;
}

// 8附件:自绘文件列表 + 选择按钮
.file-wrap {
  width: 100%;

  .file-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16rpx;
    padding: 8rpx 0;

    .file-name {
      flex: 1;
      min-width: 0;
      font-size: 28rpx;
      color: #333;
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
    }

    .file-del {
      font-size: 26rpx;
      color: #ff4d4f;
    }
  }
}
</style>