Commit 22bddb5cabd07b12f5c723a24435c47a9398f1b2

Authored by 王彪总
1 parent 2c6690f5

xinzdev

api/common.js
@@ -57,7 +57,7 @@ export const getByType = (params) => { @@ -57,7 +57,7 @@ export const getByType = (params) => {
57 }; 57 };
58 58
59 /** 59 /**
60 - * 拉取枚举选项 用于 `fieldType=3`(枚举)且 `options` 为空(即非静态来源)时获取可选项。 60 + * 拉取选项 用于 fieldType 3(枚举)/6(多选)/9(关联记录) 且 field.options 为空(即非静态来源)时获取可选项。
61 * @param {Object} params {} 61 * @param {Object} params {}
62 * @returns {Promise} 62 * @returns {Promise}
63 */ 63 */
@@ -66,7 +66,8 @@ export const getOptionsByCode = (params) => { @@ -66,7 +66,8 @@ export const getOptionsByCode = (params) => {
66 }; 66 };
67 67
68 /** 68 /**
69 - * 拉取拉取物料列表 用于`fieldType=6`(物料)字段,列出可领物料及库存。 69 + * 拉取物料列表 用于 optionSource=3 的字段(物料不是字段类型,是「fieldType=3 + optionSource=3」的配置组合),
  70 + * 比 options 多返回单位与库存余量。
70 * @param {Object} params {} 71 * @param {Object} params {}
71 * @returns {Promise} 72 * @returns {Promise}
72 */ 73 */
@@ -74,6 +75,17 @@ export const getMaterialsByCode = (params) => { @@ -74,6 +75,17 @@ export const getMaterialsByCode = (params) => {
74 return get('/app-api/app/garden/work-field-config/materials', params); 75 return get('/app-api/app/garden/work-field-config/materials', params);
75 }; 76 };
76 77
  78 +/**
  79 + * 提交耗材作业记录(动态字段落库)。
  80 + * 注意与 maintain-plan-commit/create 是两条链路:后者提交养护计划本身(图片/描述),
  81 + * 本接口提交动态字段值,bizId 用后者返回的 commit.id。
  82 + * @param {Object} params {businessType, bizType, bizId, workTypeId, clientSeq, remark, fields}
  83 + * @returns {Promise} recordId
  84 + */
  85 +export const workRecordSubmit = (data) => {
  86 + return post('/app-api/app/garden/work-record/submit', data);
  87 +};
  88 +
77 89
78 90
79 91
pages-sub/daily/maintain-manage/add-record.vue
@@ -5,7 +5,7 @@ @@ -5,7 +5,7 @@
5 <!-- ========== 动态表单区域 ========== --> 5 <!-- ========== 动态表单区域 ========== -->
6 <template v-for="item in dynamicFields" :key="item.fieldCode"> 6 <template v-for="item in dynamicFields" :key="item.fieldCode">
7 <up-form-item :label="item.fieldName" :prop="item.fieldCode" :required="item.required === 1" class="form-item" 7 <up-form-item :label="item.fieldName" :prop="item.fieldCode" :required="item.required === 1" class="form-item"
8 - @click="(item.fieldType === 3 || item.fieldType === 6) && item.multiple === 0 && openActionSheet(item)"> 8 + @click="openPickerIfNeeded(item)">
9 <!-- type 1 数值:u-number-box,最多2位小数,min=0,max取item.length --> 9 <!-- type 1 数值:u-number-box,最多2位小数,min=0,max取item.length -->
10 <view v-if="item.fieldType === 1" class="number-box-wrap"> 10 <view v-if="item.fieldType === 1" class="number-box-wrap">
11 <u-number-box v-model="inspectForm[item.fieldCode]" min="1" :step="1" 11 <u-number-box v-model="inspectForm[item.fieldCode]" min="1" :step="1"
@@ -17,11 +17,11 @@ @@ -17,11 +17,11 @@
17 <!-- type 2 文本 --> 17 <!-- type 2 文本 -->
18 <up-input v-else-if="item.fieldType === 2" v-model="inspectForm[item.fieldCode]" 18 <up-input v-else-if="item.fieldType === 2" v-model="inspectForm[item.fieldCode]"
19 :maxlength="item.length || 50" :placeholder="`请输入${item.fieldName}`"></up-input> 19 :maxlength="item.length || 50" :placeholder="`请输入${item.fieldName}`"></up-input>
20 - <!-- type3 枚举:多选 multiple=1 使用复选框组 --> 20 + <!-- type3 枚举:多选 multiple=1 使用复选框组(name 必须是 value,提交按逗号拼接 value,与值格式契约一致) -->
21 <u-checkbox-group v-else-if="item.fieldType === 3 && item.multiple === 1" 21 <u-checkbox-group v-else-if="item.fieldType === 3 && item.multiple === 1"
22 v-model="inspectForm[item.fieldCode]"> 22 v-model="inspectForm[item.fieldCode]">
23 - <u-checkbox v-for="opt in item.selectOptions" :key="opt.value" :label="opt.label" :name="opt.label">  
24 - 23 + <u-checkbox v-for="opt in item.selectOptions" :key="opt.value" :label="opt.label" :name="opt.value">
  24 +
25 </u-checkbox> 25 </u-checkbox>
26 </u-checkbox-group> 26 </u-checkbox-group>
27 <!-- type3 枚举:单选 用up-action-sheet 底部选择 --> 27 <!-- type3 枚举:单选 用up-action-sheet 底部选择 -->
@@ -40,13 +40,60 @@ @@ -40,13 +40,60 @@
40 <up-radio label="是" name="是"></up-radio> 40 <up-radio label="是" name="是"></up-radio>
41 <up-radio label="否" name="否"></up-radio> 41 <up-radio label="否" name="否"></up-radio>
42 </up-radio-group> 42 </up-radio-group>
43 - <!-- type6 物料选择:up-action-sheet 底部选择 -->  
44 - <up-input v-else-if="item.fieldType === 6" v-model="labelMap[item.fieldCode]" readonly 43 + <!-- type6 多选:复选框组;选项来源可为静态/字典/物料/数据表/SQL/接口(物料不再占用 6 号) -->
  44 + <view v-else-if="item.fieldType === 6" class="multi-wrap">
  45 + <u-checkbox-group v-model="inspectForm[item.fieldCode]" @change="onMultiChange(item)">
  46 + <u-checkbox v-for="opt in item.selectOptions" :key="opt.value" :label="opt.label" :name="opt.value"
  47 + :disabled="isOptionLocked(item, opt)">
  48 + </u-checkbox>
  49 + </u-checkbox-group>
  50 + <!-- 有限上限时提示已选数。上限靠「禁用未选中项」实现:u-checkbox 的勾选态在组件内部,回写 v-model 撤不掉 -->
  51 + <text v-if="multiHint(item)" class="multi-hint">{{ multiHint(item) }}</text>
  52 + <!-- needQuantity:已选项各配一个数量输入,值按 {value,label,quantity} 提交。此形态仅记录用量、不扣库存 -->
  53 + <view v-if="needQty(item)" class="qty-list">
  54 + <view v-for="opt in checkedOptions(item)" :key="opt.value" class="qty-row">
  55 + <text class="qty-label">{{ opt.label }}</text>
  56 + <u-number-box v-model="qtyMap[item.fieldCode][opt.value]" :min="0" :step="1"></u-number-box>
  57 + </view>
  58 + </view>
  59 + </view>
  60 + <!-- type7 图片:多张上传,提交 JSON 数组(URL) -->
  61 + <up-upload v-else-if="item.fieldType === 7" :file-list="fileListMap[item.fieldCode] || []"
  62 + @after-read="(event) => uploadFieldFiles(item, event)" @delete="(event) => deleteFieldFile(item, event)"
  63 + multiple :max-count="pickCountOf(item, 5)" upload-text="选择图片" del-color="#ff4d4f"
  64 + class="upload-wrap"></up-upload>
  65 + <!-- type8 附件:微信只能从聊天记录选文件(无通用文件选择器),提交 JSON 数组(URL) -->
  66 + <view v-else-if="item.fieldType === 8" class="file-wrap">
  67 + <view v-for="(file, idx) in fileListMap[item.fieldCode] || []" :key="idx" class="file-row">
  68 + <text class="file-name">{{ file.name || fileNameOf(file.url) }}</text>
  69 + <text class="file-del" @click.stop="deleteFieldFile(item, { index: idx })">删除</text>
  70 + </view>
  71 + <up-button size="small" type="primary" plain :text="fileButtonText(item)"
  72 + @click.stop="chooseFieldFiles(item)"></up-button>
  73 + </view>
  74 + <!-- type9 关联记录:底部选择,提交 JSON 数组 [{id,name}] -->
  75 + <up-input v-else-if="item.fieldType === 9" v-model="labelMap[item.fieldCode]" readonly
45 disabled-color="#ffffff" :placeholder="`请选择${item.fieldName}`" border="none"> 76 disabled-color="#ffffff" :placeholder="`请选择${item.fieldName}`" border="none">
46 <template #right> 77 <template #right>
47 <up-icon name="arrow-right" size="16"></up-icon> 78 <up-icon name="arrow-right" size="16"></up-icon>
48 </template> 79 </template>
49 </up-input> 80 </up-input>
  81 + <!-- type10 链接:URL 输入,提交裸 URL -->
  82 + <up-input v-else-if="item.fieldType === 10" v-model.trim="inspectForm[item.fieldCode]"
  83 + :placeholder="`请输入${item.fieldName}(http/https 开头)`"></up-input>
  84 + <!-- type11 地理位置:uni.chooseLocation 选点,提交 JSON 对象 {lng,lat,address} -->
  85 + <up-input v-else-if="item.fieldType === 11" v-model="labelMap[item.fieldCode]" readonly
  86 + disabled-color="#ffffff" :placeholder="`点击选择${item.fieldName}`" border="none">
  87 + <template #right>
  88 + <up-icon name="arrow-right" size="16"></up-icon>
  89 + </template>
  90 + </up-input>
  91 + <!-- 物料(fieldType=3 + optionSource=3):额外收领用数量;数量>0 才提交 materialId/materialName/quantity 触发扣库存。
  92 + 必须放在 v-if/v-else-if 链末尾——插在中间会打断链导致编译报错 -->
  93 + <view v-if="item.fieldType === 3 && item.optionSource === 3" class="qty-row qty-row-material">
  94 + <text class="qty-label">领用数量</text>
  95 + <u-number-box v-model="qtyMap[item.fieldCode]" :min="0" :step="1"></u-number-box>
  96 + </view>
50 </up-form-item> 97 </up-form-item>
51 </template> 98 </template>
52 <!-- ========== 动态表单结束 ========== --> 99 <!-- ========== 动态表单结束 ========== -->
@@ -79,7 +126,7 @@ @@ -79,7 +126,7 @@
79 <script lang="ts"> 126 <script lang="ts">
80 import { uploadImages } from '@/common/utils/upload' 127 import { uploadImages } from '@/common/utils/upload'
81 import { maintainCreate } from "@/api/maintain-manage/maintain-manage" 128 import { maintainCreate } from "@/api/maintain-manage/maintain-manage"
82 -import { getByType, getOptionsByCode, getMaterialsByCode } from "@/api/common" 129 +import { getByType, getOptionsByCode, getMaterialsByCode, workRecordSubmit } from "@/api/common"
83 export default { 130 export default {
84 data() { 131 data() {
85 return { 132 return {
@@ -90,7 +137,15 @@ export default { @@ -90,7 +137,15 @@ export default {
90 }, 137 },
91 // ========== 新增:用来存放展示文本label的映射对象 ========== 138 // ========== 新增:用来存放展示文本label的映射对象 ==========
92 labelMap: {}, 139 labelMap: {},
  140 + // 类型7图片/8附件的每字段文件列表(fieldCode -> u-upload 兼容数组 {url,status,name})
  141 + fileListMap: {},
  142 + // 数量收集:类型6+needQuantity 为 {选项值: 数量};类型3物料为标量(领用数量)
  143 + qtyMap: {},
93 paramsOptins: {}, 144 paramsOptins: {},
  145 + // 幂等流水号:本次填报页面生命周期内保持不变,重复提交(如网络超时后重试)只会落一条记录
  146 + clientSeq: '',
  147 + // 养护计划本体的 commit.id:取到后缓存,重试时不再重复创建养护记录
  148 + commitId: null,
94 inspectFormRules: { 149 inspectFormRules: {
95 images: [ 150 images: [
96 { 151 {
@@ -168,17 +223,37 @@ export default { @@ -168,17 +223,37 @@ export default {
168 this.datePickerShow = false 223 this.datePickerShow = false
169 }, 224 },
170 /** 225 /**
  226 + * 点击表单项时按类型决定弹出哪个选择器:
  227 + * 3 枚举(单选) / 9 关联记录 → 底部 actionSheet;11 地理位置 → 微信地图选点
  228 + * 其余类型(多选、图片、附件、可输入的链接)不在这里处理,由控件自身响应
  229 + */
  230 + openPickerIfNeeded(item: any) {
  231 + // 条件与模板互补:模板用 multiple === 1 渲染复选框组,这里就必须用 !== 1 才打开弹窗,
  232 + // 否则 multiple 为 NULL/0 的存量单选枚举会既不渲染复选框、又点不开弹窗
  233 + if (item.fieldType === 3 && item.multiple !== 1) {
  234 + return this.openActionSheet(item)
  235 + }
  236 + if (item.fieldType === 9) {
  237 + return this.openActionSheet(item)
  238 + }
  239 + if (item.fieldType === 11) {
  240 + return this.openLocation(item)
  241 + }
  242 + },
  243 + /**
171 * 打开底部选择弹窗 actionSheet 244 * 打开底部选择弹窗 actionSheet
172 */ 245 */
173 openActionSheet(item: any) { 246 openActionSheet(item: any) {
174 - console.log('12312');  
175 this.actionSheet.fieldCode = item.fieldCode 247 this.actionSheet.fieldCode = item.fieldCode
176 this.actionSheet.fieldItem = item 248 this.actionSheet.fieldItem = item
177 - // 把selectOptions / materialOptions 转为 actionSheet 需要格式 {name, value}  
178 - let sourceOptions = item.fieldType === 3 ? item.selectOptions : item.materialOptions  
179 - this.actionSheet.options = (sourceOptions || []).map(opt => { 249 + // 选项统一挂在 selectOptions(静态来源直接用 field.options,物料来源调 materials 接口拿,其余调 options 接口)
  250 + // 物料带 currentNum 时在展示名后附库存余量(只影响展示名,提交的 materialName 仍取干净的 opt.label)
  251 + this.actionSheet.options = (item.selectOptions || []).map(opt => {
  252 + const stock = opt.currentNum === null || opt.currentNum === undefined || opt.currentNum === ''
  253 + ? NaN
  254 + : Number(opt.currentNum)
180 return { 255 return {
181 - name: opt.label, 256 + name: isNaN(stock) ? opt.label : `${opt.label}(库存${stock}${opt.unit || ''})`,
182 value: opt.value 257 value: opt.value
183 } 258 }
184 }) 259 })
@@ -190,8 +265,12 @@ export default { @@ -190,8 +265,12 @@ export default {
190 onActionSheetSelect(selectedItem: any) { 265 onActionSheetSelect(selectedItem: any) {
191 const fieldItem = this.actionSheet.fieldItem 266 const fieldItem = this.actionSheet.fieldItem
192 const fieldCode = this.actionSheet.fieldCode 267 const fieldCode = this.actionSheet.fieldCode
193 - // 1. 表单保存value,提交后端  
194 - this.inspectForm[fieldCode] = selectedItem.value 268 + // 1. 表单保存提交值:9 关联记录存 {id,name}(提交时转成 [{id,name}]),其余存裸 value
  269 + if (fieldItem && fieldItem.fieldType === 9) {
  270 + this.inspectForm[fieldCode] = { id: selectedItem.value, name: selectedItem.name }
  271 + } else {
  272 + this.inspectForm[fieldCode] = selectedItem.value
  273 + }
195 // 2. labelMap保存label,给input展示 274 // 2. labelMap保存label,给input展示
196 this.labelMap[fieldCode] = selectedItem.name 275 this.labelMap[fieldCode] = selectedItem.name
197 this.actionSheet.show = false 276 this.actionSheet.show = false
@@ -199,6 +278,29 @@ export default { @@ -199,6 +278,29 @@ export default {
199 this.$refs.inspectFormRef.validateField(fieldCode) 278 this.$refs.inspectFormRef.validateField(fieldCode)
200 }, 279 },
201 /** 280 /**
  281 + * 类型11 地理位置:微信地图选点(无需地图 AK,manifest.json 已声明 requiredPrivateInfos)
  282 + * 提交形态 {lng,lat,address}
  283 + */
  284 + openLocation(item: any) {
  285 + uni.chooseLocation({
  286 + success: (res) => {
  287 + this.inspectForm[item.fieldCode] = {
  288 + lng: res.longitude,
  289 + lat: res.latitude,
  290 + address: res.address || res.name || ''
  291 + }
  292 + this.labelMap[item.fieldCode] = res.address || res.name || `${res.latitude}, ${res.longitude}`
  293 + this.$refs.inspectFormRef.validateField(item.fieldCode)
  294 + },
  295 + fail: (err) => {
  296 + console.error('地图选择失败', err)
  297 + if (err && err.errMsg && err.errMsg.indexOf('auth deny') > -1) {
  298 + uni.showToast({ title: '请授权位置权限', icon: 'none' })
  299 + }
  300 + }
  301 + })
  302 + },
  303 + /**
202 * 加载动态字段 304 * 加载动态字段
203 */ 305 */
204 async loadDynamicFields(businessType: string, workTypeId: string) { 306 async loadDynamicFields(businessType: string, workTypeId: string) {
@@ -221,45 +323,67 @@ export default { @@ -221,45 +323,67 @@ export default {
221 return 323 return
222 } 324 }
223 for (const field of fields) { 325 for (const field of fields) {
224 - // type3多选checkbox,初始值为空数组,其余字段空字符串  
225 - if (field.fieldType === 3 && field.multiple === 1) {  
226 - this.$set(this.inspectForm, field.fieldCode, []) 326 + // 初值:多选为数组;9关联记录/11地理位置 为对象(未选为 null);7图片/8附件为 URL 数组;其余空字符串
  327 + if (field.fieldType === 6 || (field.fieldType === 3 && field.multiple === 1)) {
  328 + this.inspectForm[field.fieldCode] = []
  329 + } else if (field.fieldType === 7 || field.fieldType === 8) {
  330 + this.inspectForm[field.fieldCode] = []
  331 + } else if (field.fieldType === 9 || field.fieldType === 11) {
  332 + this.inspectForm[field.fieldCode] = null
227 } else { 333 } else {
228 - this.$set(this.inspectForm, field.fieldCode, '') 334 + this.inspectForm[field.fieldCode] = ''
229 } 335 }
230 // 初始化展示label为空 336 // 初始化展示label为空
231 - this.$set(this.labelMap, field.fieldCode, '')  
232 -  
233 - if (field.required === 1) {  
234 - const msg = field.fieldType === 5 ? `请选择${field.fieldName}` : `请填写${field.fieldName}`  
235 - this.inspectFormRules[field.fieldCode] = [  
236 - { required: true, message: msg, trigger: 'change' }  
237 - ] 337 + this.labelMap[field.fieldCode] = ''
  338 + // 7图片/8附件的文件列表(驱动 up-upload 与自绘文件列表展示)
  339 + if (field.fieldType === 7 || field.fieldType === 8) {
  340 + this.fileListMap[field.fieldCode] = []
238 } 341 }
239 - // type3 枚举:优先使用field.options,有则不请求getOptionsByCode  
240 - if (field.fieldType === 3) {  
241 - if (field.options && Array.isArray(field.options) && field.options.length > 0) {  
242 - field.selectOptions = field.options 342 + // 选项装载:静态来源(1)直接用下发的 field.options,其余来源(2字典/3物料/4数据表/5SQL/6接口)调 options 接口,
  343 + // 由后端按 optionSource 分发。类型 3/6/9 需要选项,其余类型不需要
  344 + if (field.fieldType === 3 || field.fieldType === 6 || field.fieldType === 9) {
  345 + if (field.optionSource === 1 && Array.isArray(field.options)) {
  346 + field.selectOptions = field.options.map(opt => ({ label: opt.label, value: String(opt.value) }))
  347 + } else if (field.optionSource === 3) {
  348 + // 物料来源:调 materials(比 options 多返回 currentNum 库存余量/unit),供选择时显示库存
  349 + const matRes = await getMaterialsByCode({
  350 + businessType,
  351 + bizType,
  352 + workTypeId,
  353 + fieldCode: field.fieldCode
  354 + })
  355 + field.selectOptions = (matRes || []).map(mat => ({
  356 + label: mat.materialName,
  357 + value: String(mat.materialId),
  358 + currentNum: mat.currentNum,
  359 + unit: mat.unit
  360 + }))
243 } else { 361 } else {
244 - console.log('12312')  
245 - const dictRes = await getOptionsByCode({ 362 + const optRes = await getOptionsByCode({
246 businessType, 363 businessType,
247 bizType, 364 bizType,
248 workTypeId, 365 workTypeId,
249 fieldCode: field.fieldCode 366 fieldCode: field.fieldCode
250 }) 367 })
251 - field.selectOptions = dictRes.map(item => ({ label: item.label, value: item.value })) 368 + field.selectOptions = (optRes || []).map(opt => ({ label: opt.label, value: String(opt.value) }))
252 } 369 }
253 } 370 }
254 - // type6 物料选项  
255 - if (field.fieldType === 6) {  
256 - const matRes = await getMaterialsByCode({  
257 - businessType,  
258 - bizType,  
259 - workTypeId,  
260 - fieldCode: field.fieldCode 371 + // 数量收集初始化(必须在 selectOptions 装载之后):
  372 + // 6多选+needQuantity 预建每个选项的键,避免模板 v-model 写到未定义的键上;
  373 + // 3物料为标量(领用数量),默认 0 = 本次不领用、不扣库存
  374 + if (field.fieldType === 6 && this.needQty(field)) {
  375 + const qty = {}
  376 + ;(field.selectOptions || []).forEach(opt => {
  377 + qty[opt.value] = ''
261 }) 378 })
262 - field.materialOptions = matRes.map(item => ({ label: item.materialName, value: item.materialId })) 379 + this.qtyMap[field.fieldCode] = qty
  380 + }
  381 + if (field.fieldType === 3 && field.optionSource === 3) {
  382 + this.qtyMap[field.fieldCode] = 0
  383 + }
  384 + // 必填规则
  385 + if (field.required === 1) {
  386 + this.inspectFormRules[field.fieldCode] = [this.buildRequiredRule(field)]
263 } 387 }
264 } 388 }
265 this.dynamicFields = fields 389 this.dynamicFields = fields
@@ -331,22 +455,373 @@ export default { @@ -331,22 +455,373 @@ export default {
331 getImgUrlList(imgList) { 455 getImgUrlList(imgList) {
332 return imgList.filter(item => item.status === 'success').map(item => item.url) 456 return imgList.filter(item => item.status === 'success').map(item => item.url)
333 }, 457 },
  458 + // ========== 动态字段:渲染与取值辅助 ==========
  459 + /**
  460 + * 类型6 多选是否要求每项填「使用数量」(ext_config.needQuantity)
  461 + * 仅记录用量,不写物料三列 ⇒ 结构性不触发扣库存(扣减一律按值行 materialId 非空判定)
  462 + */
  463 + needQty(item: any) {
  464 + return !!(item.extConfig && item.extConfig.needQuantity === true)
  465 + },
  466 + /**
  467 + * 有效上限:ext_config.maxCount 的「填 0 表示不限」(PC 端提示语)在提交侧照此生效
  468 + * 未配置 → 类型默认值;配 0 → Infinity(不限);配正数 → 该数
  469 + */
  470 + maxCountOf(item: any, defaultValue: number) {
  471 + const raw = item.extConfig ? item.extConfig.maxCount : undefined
  472 + if (raw === undefined || raw === null || raw === '') {
  473 + return defaultValue
  474 + }
  475 + const num = Number(raw)
  476 + if (isNaN(num)) {
  477 + return defaultValue
  478 + }
  479 + return num > 0 ? num : Infinity
  480 + },
  481 + /** 传给选择器的单次可选数量:上限为「不限」时用组件默认值 52(平台要求 count 是有限数) */
  482 + pickCountOf(item: any, defaultValue: number) {
  483 + const max = this.maxCountOf(item, defaultValue)
  484 + return max === Infinity ? 52 : max
  485 + },
  486 + /** 单文件大小上限(MB):ext_config.maxSizeMb,未配置时用默认值 */
  487 + maxSizeMbOf(item: any, defaultValue: number) {
  488 + const num = item.extConfig ? Number(item.extConfig.maxSizeMb) : NaN
  489 + return num > 0 ? num : defaultValue
  490 + },
  491 + /** 类型8 允许的扩展名数组(ext_config.fileType,不含点) */
  492 + extensionsOf(item: any) {
  493 + const list = item.extConfig && item.extConfig.fileType
  494 + return Array.isArray(list) ? list : []
  495 + },
  496 + fileButtonText(item: any) {
  497 + const exts = this.extensionsOf(item)
  498 + return exts.length ? `选择文件(${exts.join('/')})` : '选择文件'
  499 + },
  500 + /** 从 URL 取末段当文件名(与 PC 端 FieldValueCell 同口径) */
  501 + fileNameOf(url: string) {
  502 + if (!url) return ''
  503 + const path = String(url).split('?')[0].split('#')[0]
  504 + const name = path.substring(path.lastIndexOf('/') + 1)
  505 + try {
  506 + return decodeURIComponent(name) || url
  507 + } catch (err) {
  508 + return name || url
  509 + }
  510 + },
  511 + /** 类型6 已勾选的选项(用于渲染每个已选项的数量输入行) */
  512 + checkedOptions(item: any) {
  513 + const values = this.inspectForm[item.fieldCode] || []
  514 + return (item.selectOptions || []).filter(opt => values.indexOf(opt.value) > -1)
  515 + },
  516 + /**
  517 + * 多选是否达到上限(用于禁用未选中项)。
  518 + * 不用「超限后回写 v-model 截断」:u-checkbox 的勾选态是组件内部状态(只受 checked prop 影响),
  519 + * 回写父级 v-model 撤不掉已经勾上的项,下一次点击还会按内部状态重新算一遍,等于没限住
  520 + */
  521 + isOptionLocked(item: any, opt: any) {
  522 + const max = this.maxCountOf(item, Infinity)
  523 + if (max === Infinity) {
  524 + return false
  525 + }
  526 + const values = this.inspectForm[item.fieldCode] || []
  527 + return values.length >= max && values.indexOf(opt.value) === -1
  528 + },
  529 + /** 多选的「已选 x/上限」提示,不限时返回空串 */
  530 + multiHint(item: any) {
  531 + const max = this.maxCountOf(item, Infinity)
  532 + if (max === Infinity) {
  533 + return ''
  534 + }
  535 + return `已选 ${(this.inspectForm[item.fieldCode] || []).length}/${max}`
  536 + },
  537 + onMultiChange(item: any) {
  538 + // 上限由 isOptionLocked 禁用未选项保证,这里只负责触发必填校验
  539 + this.$refs.inspectFormRef.validateField(item.fieldCode)
  540 + },
  541 + /**
  542 + * 必填规则。
  543 + * 7/8/9/11 的值是数组或对象,async-validator 的 required 按默认 string 类型判定,
  544 + * 会放过「空数组」「null」⇒ 必须用 validator 显式判空,否则未选也能提交
  545 + */
  546 + buildRequiredRule(field: any) {
  547 + const type = field.fieldType
  548 + if (type === 6 || type === 7 || type === 8) {
  549 + const action = type === 6 ? '请选择' : '请上传'
  550 + return {
  551 + required: true,
  552 + trigger: 'change',
  553 + validator: (rule, value, callback) => {
  554 + Array.isArray(value) && value.length > 0 ? callback() : callback(new Error(`${action}${field.fieldName}`))
  555 + }
  556 + }
  557 + }
  558 + if (type === 9 || type === 11) {
  559 + return {
  560 + required: true,
  561 + trigger: 'change',
  562 + validator: (rule, value, callback) => {
  563 + value ? callback() : callback(new Error(type === 9 ? `请选择${field.fieldName}` : `请选择${field.fieldName}的位置`))
  564 + }
  565 + }
  566 + }
  567 + // 1数值/2文本/4日期/5布尔/10链接 为字符串;3枚举/6单选为选择项
  568 + const action = type === 3 || type === 5 ? '请选择' : '请填写'
  569 + return { required: true, message: `${action}${field.fieldName}`, trigger: 'change' }
  570 + },
  571 + /** 幂等流水号(后端按 userId+clientSeq 去重,命中则返回已存在的 recordId) */
  572 + genClientSeq() {
  573 + return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
  574 + },
  575 + // ========== 动态字段:7图片/8附件 ==========
  576 + /** 单文件是否超过 ext_config.maxSizeMb(超限即 toast 并返回 true 以便过滤掉) */
  577 + isOversize(file: any, maxSizeMb: number) {
  578 + if (!maxSizeMb || !file || !file.size) return false
  579 + const over = file.size / 1024 / 1024 > maxSizeMb
  580 + if (over) {
  581 + uni.showToast({ title: `单个文件不能超过${maxSizeMb}MB`, icon: 'none' })
  582 + }
  583 + return over
  584 + },
  585 + /** 把已上传成功的 URL 同步进表单值(提交与校验都读 inspectForm) */
  586 + syncFileValue(item: any) {
  587 + const list = this.fileListMap[item.fieldCode] || []
  588 + this.inspectForm[item.fieldCode] = list.filter(file => file.status === 'success').map(file => file.url)
  589 + },
  590 + /**
  591 + * 7图片 上传(u-upload 的 after-read 回调)
  592 + */
  593 + uploadFieldFiles(item: any, event: any) {
  594 + const rawFile = event && event.file
  595 + const fileList = Array.isArray(rawFile) ? rawFile : [rawFile]
  596 + return this.uploadToField(item, fileList.filter(file => file && file.url).map(file => ({
  597 + url: file.url,
  598 + name: file.name || '',
  599 + size: file.size
  600 + })))
  601 + },
  602 + /**
  603 + * 8附件 选择并上传。
  604 + * 微信小程序没有通用文件选择器,只能从聊天记录里选(uni.chooseMessageFile)
  605 + */
  606 + chooseFieldFiles(item: any) {
  607 + const maxCount = this.maxCountOf(item, 5)
  608 + const remain = maxCount - (this.fileListMap[item.fieldCode] || []).length
  609 + if (remain <= 0) {
  610 + uni.showToast({ title: `最多只能上传${maxCount}个`, icon: 'none' })
  611 + return
  612 + }
  613 + const exts = this.extensionsOf(item)
  614 + const options: any = { count: Math.min(remain, this.pickCountOf(item, 5)), type: 'file' }
  615 + if (exts.length) {
  616 + options.extension = exts
  617 + }
  618 + uni.chooseMessageFile({
  619 + ...options,
  620 + success: (res) => {
  621 + const tempFiles = res.tempFiles || []
  622 + this.uploadToField(item, tempFiles.map(file => ({
  623 + url: file.path,
  624 + name: file.name || '',
  625 + size: file.size
  626 + })))
  627 + },
  628 + fail: (err) => {
  629 + console.error('选择附件失败:', err)
  630 + }
  631 + })
  632 + },
  633 + /**
  634 + * 把选中的本地文件逐个上传并把 http 地址回填到 fileListMap / inspectForm。
  635 + * 7图片与8附件共用(uploadImages 是通用 uni.uploadFile,不限图片类型)
  636 + */
  637 + async uploadToField(item: any, files: any[]) {
  638 + const code = item.fieldCode
  639 + const maxCount = this.maxCountOf(item, 5)
  640 + const maxSizeMb = this.maxSizeMbOf(item, 5)
  641 + const validFiles = (files || []).filter(file => file && file.url && !this.isOversize(file, maxSizeMb))
  642 + if (!validFiles.length) {
  643 + return
  644 + }
  645 + const targetList = this.fileListMap[code] || (this.fileListMap[code] = [])
  646 + if (targetList.length + validFiles.length > maxCount) {
  647 + uni.showToast({ title: `最多只能上传${maxCount}个`, icon: 'none' })
  648 + return
  649 + }
  650 + const startIndex = targetList.length
  651 + targetList.push(...validFiles.map(file => ({
  652 + url: file.url, status: 'uploading', message: '上传中', name: file.name || ''
  653 + })))
  654 + try {
  655 + const uploadResultUrls = await uploadImages({
  656 + filePaths: validFiles.map(file => file.url),
  657 + ignoreError: true
  658 + })
  659 + uploadResultUrls.forEach((url, index) => {
  660 + if (targetList[startIndex + index]) {
  661 + targetList.splice(startIndex + index, 1, {
  662 + url, status: 'success', message: '', name: validFiles[index].name || ''
  663 + })
  664 + }
  665 + })
  666 + for (let i = uploadResultUrls.length; i < validFiles.length; i++) {
  667 + if (targetList[startIndex + i]) {
  668 + targetList.splice(startIndex + i, 1, {
  669 + url: validFiles[i].url, status: 'failed', message: '上传失败', name: validFiles[i].name || ''
  670 + })
  671 + }
  672 + }
  673 + if (uploadResultUrls.length < validFiles.length) {
  674 + uni.showToast({
  675 + title: `成功${uploadResultUrls.length}个,失败${validFiles.length - uploadResultUrls.length}个`,
  676 + icon: 'none'
  677 + })
  678 + }
  679 + } catch (err) {
  680 + console.error(`【${code}】文件上传失败:`, err)
  681 + for (let i = 0; i < validFiles.length; i++) {
  682 + if (targetList[startIndex + i]) {
  683 + targetList.splice(startIndex + i, 1, {
  684 + url: validFiles[i].url, status: 'failed', message: '上传失败', name: validFiles[i].name || ''
  685 + })
  686 + }
  687 + }
  688 + uni.showToast({ title: '上传失败,请重试', icon: 'none' })
  689 + }
  690 + this.syncFileValue(item)
  691 + this.$refs.inspectFormRef.validateField(code)
  692 + },
  693 + /**
  694 + * 删除 7图片/8附件 的某一个已选文件
  695 + */
  696 + deleteFieldFile(item: any, event: any) {
  697 + const list = this.fileListMap[item.fieldCode] || []
  698 + const index = Number(event && event.index)
  699 + if (isNaN(index) || index < 0 || index >= list.length) {
  700 + return
  701 + }
  702 + list.splice(index, 1)
  703 + this.syncFileValue(item)
  704 + this.$refs.inspectFormRef.validateField(item.fieldCode)
  705 + },
  706 + // ========== 动态字段:组装提交体 ==========
334 /** 707 /**
335 - * 提交巡查表单 708 + * 按值格式契约组装 fields[](见 docs/design/app-work-field-api.md §四)。
  709 + * 两条硬规则:
  710 + * ① 空值一律不出现在 fields[] 里(不提交 ''/'[]'/'null')
  711 + * ② 物料的数量>0 才写 materialId/materialName/quantity —— 非空 materialId 即触发扣库存,
  712 + * 因此 6多选+needQuantity(只记录用量)绝不能写这三列,只把 quantity 放进 valueText 的 JSON 里
  713 + */
  714 + buildDynamicFields() {
  715 + const fields: any[] = []
  716 + for (const item of this.dynamicFields) {
  717 + const code = item.fieldCode
  718 + const value = this.inspectForm[code]
  719 + const type = item.fieldType
  720 + if (type === 9 || type === 11) {
  721 + if (!value) continue
  722 + } else if (type === 6 || type === 7 || type === 8) {
  723 + if (!Array.isArray(value) || value.length === 0) continue
  724 + } else if (value === '' || value === null || value === undefined) {
  725 + continue
  726 + }
  727 +
  728 + if (type === 1) {
  729 + fields.push({ fieldCode: code, valueNumber: Number(value) })
  730 + } else if (type === 3 && item.optionSource === 3) {
  731 + // 物料:value_text 存物料ID;数量>0 才落物料三列(0 表示本次不领用)
  732 + const qty = Number(this.qtyMap[code] || 0)
  733 + const matched = (item.selectOptions || []).filter(opt => opt.value === String(value))[0]
  734 + const row: any = { fieldCode: code, valueText: String(value) }
  735 + if (qty > 0) {
  736 + row.materialId = Number(value)
  737 + row.materialName = matched ? matched.label : ''
  738 + row.quantity = qty
  739 + }
  740 + fields.push(row)
  741 + } else if (type === 3) {
  742 + // 3枚举:单选取裸 value;历史「枚举+是否多选」存逗号分隔
  743 + fields.push({
  744 + fieldCode: code,
  745 + valueText: item.multiple === 1 ? (value || []).join(',') : String(value)
  746 + })
  747 + } else if (type === 6) {
  748 + const selected = (item.selectOptions || []).filter(opt => (value || []).indexOf(opt.value) > -1)
  749 + if (this.needQty(item)) {
  750 + // 元素 {value,label,quantity};数量留空的不带 quantity 键(不臆测成 0),label 作快照落库
  751 + const qtyMap = this.qtyMap[code] || {}
  752 + fields.push({
  753 + fieldCode: code,
  754 + valueText: JSON.stringify(selected.map(opt => {
  755 + const raw = qtyMap[opt.value]
  756 + const row: any = { value: opt.value, label: opt.label }
  757 + if (raw !== '' && raw !== null && raw !== undefined) {
  758 + row.quantity = Number(raw)
  759 + }
  760 + return row
  761 + }))
  762 + })
  763 + } else {
  764 + fields.push({ fieldCode: code, valueText: JSON.stringify(value.map(v => String(v))) })
  765 + }
  766 + } else if (type === 7 || type === 8) {
  767 + fields.push({ fieldCode: code, valueText: JSON.stringify(value) })
  768 + } else if (type === 9) {
  769 + fields.push({ fieldCode: code, valueText: JSON.stringify([{ id: value.id, name: value.name }]) })
  770 + } else if (type === 11) {
  771 + fields.push({
  772 + fieldCode: code,
  773 + valueText: JSON.stringify({ lng: value.lng, lat: value.lat, address: value.address || '' })
  774 + })
  775 + } else {
  776 + // 2文本 / 4日期 / 5布尔 / 10链接 均为裸文本
  777 + fields.push({ fieldCode: code, valueText: String(value) })
  778 + }
  779 + }
  780 + return fields
  781 + },
  782 + /**
  783 + * 提交养护记录 —— 双写两条链路:
  784 + * ① maintain-plan-commit/create:养护计划本身(描述 + 图片 + 人员),返回 commit.id
  785 + * ② work-record/submit:动态字段值,bizId 用 ① 的 commit.id(旧接口的 VO 没有动态字段,会被 Jackson 静默丢弃)
  786 + * 重试安全:commitId 一旦取到就缓存住,重试只补写 ②(② 自身按 clientSeq 幂等),避免重复创建养护记录
336 */ 787 */
337 async submitInspect() { 788 async submitInspect() {
338 try { 789 try {
339 await this.$refs.inspectFormRef.validate() 790 await this.$refs.inspectFormRef.validate()
340 - const submitData = {  
341 - planNo: this.paramsOptins.planNo,  
342 - imgHost: "1",  
343 - beginImg: this.getImgUrlList(this.imagesList),  
344 - commonUserList: [],  
345 - remark: this.inspectForm.remark.trim(),  
346 - ...this.inspectForm 791 + const dynamicValues = this.buildDynamicFields()
  792 + if (!this.clientSeq) {
  793 + this.clientSeq = this.genClientSeq()
347 } 794 }
348 uni.showLoading({ title: '提交中...' }) 795 uni.showLoading({ title: '提交中...' })
349 - await maintainCreate(submitData) 796 + // ① 养护计划本体:仅首次提交时写入
  797 + if (!this.commitId) {
  798 + const submitData = {
  799 + planNo: this.paramsOptins.planNo,
  800 + imgHost: "1",
  801 + beginImg: this.getImgUrlList(this.imagesList),
  802 + commonUserList: [],
  803 + remark: this.inspectForm.remark.trim(),
  804 + }
  805 + const commitRes: any = await maintainCreate(submitData)
  806 + // 兼容后端返回裸 id 或 {id:...} 两种形态
  807 + this.commitId = commitRes && typeof commitRes === 'object' ? commitRes.id : commitRes
  808 + }
  809 + // ② 动态字段值:无动态字段时不产生空的字段值记录
  810 + if (dynamicValues.length > 0) {
  811 + if (!this.commitId) {
  812 + console.warn('未取到养护计划的 commit.id,跳过动态字段提交')
  813 + } else {
  814 + await workRecordSubmit({
  815 + businessType: this.paramsOptins.businessType,
  816 + bizType: 3,
  817 + bizId: this.commitId,
  818 + workTypeId: Number(this.paramsOptins.planTypeId),
  819 + clientSeq: this.clientSeq,
  820 + remark: this.inspectForm.remark.trim(),
  821 + fields: dynamicValues
  822 + })
  823 + }
  824 + }
350 uni.hideLoading() 825 uni.hideLoading()
351 uni.showToast({ title: '提交成功', icon: 'success', duration: 1000 }) 826 uni.showToast({ title: '提交成功', icon: 'success', duration: 1000 })
352 setTimeout(() => { 827 setTimeout(() => {
@@ -354,8 +829,21 @@ export default { @@ -354,8 +829,21 @@ export default {
354 }, 1000) 829 }, 1000)
355 } catch (error) { 830 } catch (error) {
356 uni.hideLoading() 831 uni.hideLoading()
357 - if (!Array.isArray(error)) {  
358 - console.error('巡查表单提交失败:', error) 832 + // 表单校验失败时 error 是字段数组(uview-plus 已提示),不重复弹
  833 + if (Array.isArray(error)) {
  834 + return
  835 + }
  836 + console.error('养护记录提交失败:', error)
  837 + // request.js 已把后端 msg 弹过一次(如「库存不足」),此处不要用通用文案盖掉真实原因
  838 + const backendMsg = error && error.msg
  839 + if (this.commitId) {
  840 + // 养护计划已落库,失败只发生在耗材字段。必须说清「已提交」,否则用户会以为整单没提交而重复填
  841 + uni.showToast({
  842 + title: backendMsg ? `养护记录已提交:${backendMsg}` : '养护记录已提交,耗材数据保存失败,请重试',
  843 + icon: 'none',
  844 + duration: 2500
  845 + })
  846 + } else if (!backendMsg) {
359 uni.showToast({ title: '提交失败,请重试', icon: 'none', duration: 2000 }) 847 uni.showToast({ title: '提交失败,请重试', icon: 'none', duration: 2000 })
360 } 848 }
361 } 849 }
@@ -406,4 +894,62 @@ export default { @@ -406,4 +894,62 @@ export default {
406 color: #333; 894 color: #333;
407 } 895 }
408 } 896 }
  897 +// 6多选:复选框组 + needQuantity 时每个已选项一行数量输入
  898 +.multi-wrap {
  899 + width: 100%;
  900 + .multi-hint {
  901 + display: block;
  902 + margin-top: 8rpx;
  903 + font-size: 24rpx;
  904 + color: #909399;
  905 + }
  906 + .qty-list {
  907 + margin-top: 16rpx;
  908 + padding-left: 20rpx;
  909 + border-left: 4rpx solid #f0f0f0;
  910 + }
  911 +}
  912 +// 数量行(6多选的每个已选项 / 3物料的领用数量共用)
  913 +.qty-row {
  914 + display: flex;
  915 + align-items: center;
  916 + justify-content: space-between;
  917 + gap: 16rpx;
  918 + padding: 8rpx 0;
  919 + .qty-label {
  920 + flex: 1;
  921 + min-width: 0;
  922 + font-size: 28rpx;
  923 + color: #333;
  924 + }
  925 +}
  926 +.qty-row-material {
  927 + margin-top: 12rpx;
  928 + padding-top: 12rpx;
  929 + border-top: 1rpx solid #f0f0f0;
  930 +}
  931 +// 8附件:自绘文件列表 + 选择按钮(微信无通用文件选择器,走 chooseMessageFile)
  932 +.file-wrap {
  933 + width: 100%;
  934 + .file-row {
  935 + display: flex;
  936 + align-items: center;
  937 + justify-content: space-between;
  938 + gap: 16rpx;
  939 + padding: 8rpx 0;
  940 + .file-name {
  941 + flex: 1;
  942 + min-width: 0;
  943 + font-size: 28rpx;
  944 + color: #333;
  945 + overflow: hidden;
  946 + white-space: nowrap;
  947 + text-overflow: ellipsis;
  948 + }
  949 + .file-del {
  950 + font-size: 26rpx;
  951 + color: #ff4d4f;
  952 + }
  953 + }
  954 +}
409 </style> 955 </style>