Commit 629df8b3a9663e265004f97b7567cfea234e3058

Authored by 王彪总
1 parent ee792d52

业务线 小程序不用写死,从后端接口获取

pages-sub/daily/maintain-manage/add-record.vue
@@ -4,541 +4,233 @@ @@ -4,541 +4,233 @@
4 <up-form label-position="left" :model="inspectForm" ref="inspectFormRef" label-width="140rpx"> 4 <up-form label-position="left" :model="inspectForm" ref="inspectFormRef" label-width="140rpx">
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  
8 - :label="item.fieldName"  
9 - :prop="item.fieldCode"  
10 - :required="item.required === 1"  
11 - class="form-item"  
12 - @click="openPickerIfNeeded(item)"  
13 - >  
14 - <!-- type1 数值:u-number-box --> 7 + <up-form-item :label="item.fieldName" :prop="item.fieldCode" :required="item.required === 1" class="form-item"
  8 + @click="openPickerIfNeeded(item)">
  9 + <!-- type 1 数值:u-number-box,最多2位小数,min=0,max取item.length -->
15 <view v-if="item.fieldType === 1" class="number-box-wrap"> 10 <view v-if="item.fieldType === 1" class="number-box-wrap">
16 - <u-number-box  
17 - v-model="inspectForm[item.fieldCode]"  
18 - min="1"  
19 - step="1"  
20 - :max="item.length ? 10 ** Number(item.length) - 1 : null"  
21 - :placeholder="`请输入${item.fieldName}${item.unit ? item.unit : ''}`"  
22 - @change="valChange(item)"  
23 - class="large-number-box"  
24 - ></u-number-box> 11 + <u-number-box v-model="inspectForm[item.fieldCode]" min="1" :step="1"
  12 + :max="item.length ? 10 ** Number(item.length) - 1 : null"
  13 + :placeholder="`请输入${item.fieldName}${item.unit} `" @change="valChange(item)" class="large-number-box">
  14 + </u-number-box>
25 <text class="unit-text" v-if="item.unit">{{ item.unit }}</text> 15 <text class="unit-text" v-if="item.unit">{{ item.unit }}</text>
26 </view> 16 </view>
27 -  
28 - <!-- type2 文本 -->  
29 - <up-input  
30 - v-else-if="item.fieldType === 2"  
31 - v-model="inspectForm[item.fieldCode]"  
32 - :maxlength="item.length || 50"  
33 - :placeholder="`请输入${item.fieldName}`"  
34 - ></up-input>  
35 -  
36 - <!-- type3 枚举多选 multiple=1 -->  
37 - <u-checkbox-group v-else-if="item.fieldType === 3 && item.multiple === 1" v-model="inspectForm[item.fieldCode]">  
38 - <u-checkbox  
39 - v-for="opt in item.selectOptions"  
40 - :key="opt.value"  
41 - :label="opt.label"  
42 - :name="opt.value"  
43 - ></u-checkbox>  
44 - </u-checkbox-group>  
45 -  
46 - <!-- type3 枚举单选 multiple=0 -->  
47 - <up-input  
48 - v-else-if="item.fieldType === 3"  
49 - v-model="labelMap[item.fieldCode]"  
50 - readonly  
51 - disabled-color="#ffffff"  
52 - :placeholder="`请选择${item.fieldName}`"  
53 - border="none"  
54 - > 17 + <!-- type 2 文本 -->
  18 + <up-input v-else-if="item.fieldType === 2" v-model="inspectForm[item.fieldCode]"
  19 + :maxlength="item.length || 50" :placeholder="`请输入${item.fieldName}`"></up-input>
  20 + <!-- 分层选项(3枚举/6多选,且选项带 path):统一只读入口,点开分组弹层选择。
  21 + 必须放在下面的 3/6 分支之前——v-else-if 短路,放后面就永远进不来 -->
  22 + <up-input v-else-if="isGroupedField(item)" v-model="labelMap[item.fieldCode]" readonly
  23 + disabled-color="#ffffff" :placeholder="`请选择${item.fieldName}`" border="none">
55 <template #right> 24 <template #right>
56 <up-icon name="arrow-right" size="16"></up-icon> 25 <up-icon name="arrow-right" size="16"></up-icon>
57 </template> 26 </template>
58 </up-input> 27 </up-input>
  28 + <!-- type3 枚举:多选 multiple=1 使用复选框组(name 必须是 value,提交按逗号拼接 value,与值格式契约一致) -->
  29 + <u-checkbox-group v-else-if="item.fieldType === 3 && item.multiple === 1"
  30 + v-model="inspectForm[item.fieldCode]">
  31 + <u-checkbox v-for="opt in item.selectOptions" :key="opt.value" :label="opt.label" :name="opt.value">
59 32
60 - <!-- type4 日期时间 -->  
61 - <view v-else-if="item.fieldType === 4" @click.stop="openDatePicker(item)" class="date-input-wrap">  
62 - <up-input  
63 - v-model="inspectForm[item.fieldCode]"  
64 - disabled  
65 - placeholder="点击选择时间"  
66 - border="none"  
67 - ></up-input>  
68 - </view>  
69 -  
70 - <!-- type5 布尔单选 是/否 -->  
71 - <u-radio-group v-else-if="item.fieldType === 5" v-model="inspectForm[item.fieldCode]">  
72 - <u-radio label="是" name="是"></u-radio>  
73 - <u-radio label="否" name="否"></u-radio>  
74 - </u-radio-group>  
75 -  
76 - <!-- ===================== type6 弹窗选择 ===================== -->  
77 - <up-input  
78 - v-else-if="item.fieldType === 6"  
79 - :model-value="getType6ShowLabel(item)"  
80 - readonly  
81 - disabled-color="#ffffff"  
82 - :placeholder="`请选择${item.fieldName}`"  
83 - border="none"  
84 - > 33 + </u-checkbox>
  34 + </u-checkbox-group>
  35 + <!-- type3 枚举:单选 用up-action-sheet 底部选择 -->
  36 + <up-input v-else-if="item.fieldType === 3" v-model="labelMap[item.fieldCode]" readonly
  37 + disabled-color="#ffffff" :placeholder="`请选择${item.fieldName}`" border="none">
85 <template #right> 38 <template #right>
86 <up-icon name="arrow-right" size="16"></up-icon> 39 <up-icon name="arrow-right" size="16"></up-icon>
87 </template> 40 </template>
88 </up-input> 41 </up-input>
89 -  
90 - <!-- type7 图片上传 -->  
91 - <up-upload  
92 - v-else-if="item.fieldType === 7"  
93 - :file-list="fileListMap[item.fieldCode] || []"  
94 - @after-read="(event) => uploadFieldFiles(item, event)"  
95 - @delete="(event) => deleteFieldFile(item, event)"  
96 - multiple  
97 - :max-count="pickCountOf(item, 5)"  
98 - upload-text="上传图片"  
99 - class="upload-wrap"  
100 - ></up-upload>  
101 -  
102 - <!-- type8 附件 --> 42 + <!-- type4 日期时间 -->
  43 + <view v-else-if="item.fieldType === 4" @click.stop="openDatePicker(item)" class="date-input-wrap">
  44 + <up-input v-model="inspectForm[item.fieldCode]" disabled placeholder="点击选择时间"></up-input>
  45 + </view>
  46 + <!-- type5 布尔单选 是/否 【重点修复】增加空label=""占位项 -->
  47 + <up-radio-group v-else-if="item.fieldType === 5" v-model="inspectForm[item.fieldCode]">
  48 + <up-radio label="是" name="是"></up-radio>
  49 + <up-radio label="否" name="否"></up-radio>
  50 + </up-radio-group>
  51 + <!-- type6 多选:复选框组;选项来源可为静态/字典/物料/数据表/SQL/接口(物料不再占用 6 号) -->
  52 + <view v-else-if="item.fieldType === 6" class="multi-wrap">
  53 + <u-checkbox-group v-model="inspectForm[item.fieldCode]" @change="onMultiChange(item)">
  54 + <u-checkbox v-for="opt in item.selectOptions" :key="opt.value" :label="opt.label" :name="opt.value"
  55 + :disabled="isOptionLocked(item, opt)">
  56 + </u-checkbox>
  57 + </u-checkbox-group>
  58 + <!-- 有限上限时提示已选数。上限靠「禁用未选中项」实现:u-checkbox 的勾选态在组件内部,回写 v-model 撤不掉 -->
  59 + <text v-if="multiHint(item)" class="multi-hint">{{ multiHint(item) }}</text>
  60 + <!-- needQuantity:已选项各配一个数量输入,值按 {value,label,quantity} 提交。此形态仅记录用量、不扣库存 -->
  61 + <view v-if="needQty(item)" class="qty-list">
  62 + <view v-for="opt in checkedOptions(item)" :key="opt.value" class="qty-row">
  63 + <text class="qty-label">{{ opt.label }}</text>
  64 + <u-number-box v-model="qtyMap[item.fieldCode][opt.value]" :min="0" :step="1"></u-number-box>
  65 + </view>
  66 + </view>
  67 + </view>
  68 + <!-- type7 图片:多张上传,提交 JSON 数组(URL) -->
  69 + <up-upload v-else-if="item.fieldType === 7" :file-list="fileListMap[item.fieldCode] || []"
  70 + @after-read="(event) => uploadFieldFiles(item, event)" @delete="(event) => deleteFieldFile(item, event)"
  71 + multiple :max-count="pickCountOf(item, 5)" upload-text="选择图片" del-color="#ff4d4f"
  72 + class="upload-wrap"></up-upload>
  73 + <!-- type8 附件:微信只能从聊天记录选文件(无通用文件选择器),提交 JSON 数组(URL) -->
103 <view v-else-if="item.fieldType === 8" class="file-wrap"> 74 <view v-else-if="item.fieldType === 8" class="file-wrap">
104 - <view  
105 - v-for="(file, idx) in fileListMap[item.fieldCode] || []"  
106 - :key="idx"  
107 - class="file-row"  
108 - > 75 + <view v-for="(file, idx) in fileListMap[item.fieldCode] || []" :key="idx" class="file-row">
109 <text class="file-name">{{ file.name || fileNameOf(file.url) }}</text> 76 <text class="file-name">{{ file.name || fileNameOf(file.url) }}</text>
110 - <text class="file-del" @click.stop="deleteFieldFile(item, {index: idx})">删除</text> 77 + <text class="file-del" @click.stop="deleteFieldFile(item, { index: idx })">删除</text>
111 </view> 78 </view>
112 - <up-button  
113 - size="small"  
114 - type="primary"  
115 - plain  
116 - :text="fileButtonText(item)"  
117 - @click.stop="chooseFieldFiles(item)"  
118 - ></up-button> 79 + <up-button size="small" type="primary" plain :text="fileButtonText(item)"
  80 + @click.stop="chooseFieldFiles(item)"></up-button>
119 </view> 81 </view>
120 -  
121 - <!-- type9 关联记录 单选 -->  
122 - <up-input  
123 - v-else-if="item.fieldType === 9"  
124 - v-model="labelMap[item.fieldCode]"  
125 - readonly  
126 - disabled-color="#ffffff"  
127 - :placeholder="`请选择${item.fieldName}`"  
128 - border="none"  
129 - > 82 + <!-- type9 关联记录:底部选择,提交 JSON 数组 [{id,name}] -->
  83 + <up-input v-else-if="item.fieldType === 9" v-model="labelMap[item.fieldCode]" readonly
  84 + disabled-color="#ffffff" :placeholder="`请选择${item.fieldName}`" border="none">
130 <template #right> 85 <template #right>
131 <up-icon name="arrow-right" size="16"></up-icon> 86 <up-icon name="arrow-right" size="16"></up-icon>
132 </template> 87 </template>
133 </up-input> 88 </up-input>
134 -  
135 - <!-- type10 URL链接 -->  
136 - <up-input  
137 - v-else-if="item.fieldType === 10"  
138 - v-model.trim="inspectForm[item.fieldCode]"  
139 - placeholder="请输入链接(http/https开头)"  
140 - ></up-input>  
141 -  
142 - <!-- type11 地理位置 -->  
143 - <up-input  
144 - v-else-if="item.fieldType === 11"  
145 - v-model="labelMap[item.fieldCode]"  
146 - readonly  
147 - disabled-color="#ffffff"  
148 - placeholder="点击选择位置"  
149 - border="none"  
150 - > 89 + <!-- type10 链接:URL 输入,提交裸 URL -->
  90 + <up-input v-else-if="item.fieldType === 10" v-model.trim="inspectForm[item.fieldCode]"
  91 + :placeholder="`请输入${item.fieldName}(http/https 开头)`"></up-input>
  92 + <!-- type11 地理位置:uni.chooseLocation 选点,提交 JSON 对象 {lng,lat,address} -->
  93 + <up-input v-else-if="item.fieldType === 11" v-model="labelMap[item.fieldCode]" readonly
  94 + disabled-color="#ffffff" :placeholder="`点击选择${item.fieldName}`" border="none">
151 <template #right> 95 <template #right>
152 <up-icon name="arrow-right" size="16"></up-icon> 96 <up-icon name="arrow-right" size="16"></up-icon>
153 </template> 97 </template>
154 </up-input> 98 </up-input>
155 -  
156 - <!-- 物料附加数量输入框 type3 + optionSource=5 -->  
157 - <view v-if="item.fieldType === 3 && item.optionSource === 5" class="qty-row qty-row-material"> 99 + <!-- 物料(fieldType=3 + optionSource=3):额外收领用数量;数量>0 才提交 materialId/materialName/quantity 触发扣库存。
  100 + 必须放在 v-if/v-else-if 链末尾——插在中间会打断链导致编译报错 -->
  101 + <view v-if="item.fieldType === 3 && item.optionSource === 3" class="qty-row qty-row-material">
158 <text class="qty-label">领用数量</text> 102 <text class="qty-label">领用数量</text>
159 - <u-number-box  
160 - v-model="qtyMap[item.fieldCode]"  
161 - min="0"  
162 - step="1"  
163 - ></u-number-box> 103 + <u-number-box v-model="qtyMap[item.fieldCode]" :min="0" :step="1"></u-number-box>
164 </view> 104 </view>
165 </up-form-item> 105 </up-form-item>
166 </template> 106 </template>
167 <!-- ========== 动态表单结束 ========== --> 107 <!-- ========== 动态表单结束 ========== -->
168 -  
169 - <!-- 养护描述 -->  
170 - <up-form-item prop="remark" label="养护描述">  
171 - <up-textarea  
172 - v-model.trim="inspectForm.remark"  
173 - placeholder="请输入养护描述"  
174 - maxlength="200"  
175 - count  
176 - ></up-textarea> 108 + <!-- 1. 养护描述(文本域) -->
  109 + <up-form-item prop="remark" class="form-item">
  110 + <up-textarea v-model.trim="inspectForm.remark" placeholder="请输入养护描述" maxlength="200" count></up-textarea>
177 </up-form-item> 111 </up-form-item>
178 -  
179 - <!-- 图片上传 -->  
180 - <up-form-item label="上传图片" prop="images" required>  
181 - <up-upload  
182 - :file-list="imageList"  
183 - @after-read="uploadImages"  
184 - @delete="deleteImage"  
185 - @on-exceed="handleExceed"  
186 - multiple  
187 - :max-count="3"  
188 - upload-text="上传图片"  
189 - ></up-upload> 112 + <!-- 2. 上传图片 -->
  113 + <up-form-item label="上传图片" prop="images" required class="form-item">
  114 + <up-upload :file-list="imagesList" @after-read="(event) => uploadImgs(event)"
  115 + @delete="(event) => deleteImg(event)" @on-exceed="handleExceed" multiple :max-count="3" upload-text="选择图片"
  116 + del-color="#ff4d4f" class="upload-wrap"></up-upload>
190 </up-form-item> 117 </up-form-item>
191 </up-form> 118 </up-form>
192 </view> 119 </view>
193 -  
194 <!-- 底部提交按钮 --> 120 <!-- 底部提交按钮 -->
195 - <view class="fixed-bottom-wrap">  
196 - <up-button  
197 - type="primary"  
198 - text="提交"  
199 - @click="submitInspect"  
200 - style="width:100%;height:88rpx;font-size:32rpx;border-radius:0"  
201 - ></up-button> 121 + <view class="fixed-bottom-btn-wrap">
  122 + <up-button type="primary" text="提交" @click="submitInspect"
  123 + style="width: 100%; height: 88rpx; font-size:32rpx; border-radius: 0;"></up-button>
202 </view> 124 </view>
203 -  
204 - <!-- 日期选择弹窗 -->  
205 - <up-datetime-picker  
206 - :show="datePickerShow"  
207 - v-model="datePickerValue"  
208 - mode="datetime"  
209 - close-on-click-overlay  
210 - @confirm="onDateConfirm"  
211 - @cancel="onDateClose"  
212 - ></up-datetime-picker>  
213 - 125 + <!-- uview-plus 日期时间选择器(全局复用) -->
  126 + <up-datetime-picker :show="datePickerShow" v-model="datePickerValue" mode="datetime" close-on-click-overlay
  127 + @confirm="onDateConfirm" @cancel="onDateClose" @close="onDateClose"></up-datetime-picker>
214 <!-- ActionSheet 底部选择弹窗 --> 128 <!-- ActionSheet 底部选择弹窗 -->
215 - <up-action-sheet  
216 - :show="actionSheetShow"  
217 - :actions="actionSheetOptions"  
218 - @select="onActionSheetSelect"  
219 - @close="actionSheetShow = false"  
220 - cancel-text="取消"  
221 - ></up-action-sheet>  
222 -  
223 - <!-- ===================== type6 全屏弹窗 ===================== -->  
224 - <up-popup  
225 - :show="popupType6.show"  
226 - round="0"  
227 - close-on-click-overlay="false"  
228 - @close="closeType6Popup"  
229 - style="height:100%;width:100%"  
230 - >  
231 - <view class="popup-header">  
232 - <text class="popup-title">{{ popupType6.fieldItem?.fieldName || '选择项目' }}</text>  
233 - <up-icon name="close" size="32rpx" @click="closeType6Popup"></up-icon>  
234 - </view>  
235 - <!-- tabs -->  
236 - <up-tabs  
237 - v-model="popupType6.activeTab"  
238 - :list="popupType6.tabs"  
239 - @change="onTabChange"  
240 - swipeable="false"  
241 - ></up-tabs>  
242 - <view class="popup-body">  
243 - <view class="left-sidebar">  
244 - <view  
245 - v-for="subItem in popupType6.subMenuList"  
246 - :key="subItem.key"  
247 - class="sidebar-item"  
248 - :class="{active: popupType6.activeSubKey === subItem.key}"  
249 - @click="popupType6.activeSubKey = subItem.key; filterRightList()"  
250 - >  
251 - {{ subItem.label }}  
252 - </view>  
253 - </view>  
254 - <view class="right-content">  
255 - <view class="list-item" v-for="row in popupType6.rightList" :key="row.value">  
256 - <!-- multiple:0 = 多选checkbox; multiple:1 = 单选radio -->  
257 - <view v-if="popupType6.multiple === 0">  
258 - <u-checkbox  
259 - v-model="inspectForm[popupType6.fieldCode]"  
260 - :name="row.value"  
261 - :disabled="isType6OptionLocked(row)"  
262 - ></u-checkbox>  
263 - </view>  
264 - <u-radio  
265 - v-else  
266 - v-model="inspectForm[popupType6.fieldCode]"  
267 - :label="row.value"  
268 - ></u-radio>  
269 -  
270 - <text class="row-label">{{ row.label }}</text>  
271 -  
272 - <!-- needQuantity 数量输入框 -->  
273 - <u-number-box  
274 - v-if="popupType6.extConfig?.needQuantity === true"  
275 - v-model="qtyMap[popupType6.fieldCode][row.value]"  
276 - min="0"  
277 - step="1"  
278 - size="small"  
279 - ></u-number-box>  
280 - </view>  
281 - <!-- 数量提示,仅多选 multiple:0 才显示 -->  
282 - <view  
283 - class="tip-text"  
284 - v-if="popupType6.multiple === 0 && popupType6.extConfig?.maxCount"  
285 - >  
286 - 已选:{{ (inspectForm[popupType6.fieldCode] || []).length }} / {{ popupType6.extConfig.maxCount }}  
287 - </view>  
288 - </view>  
289 - </view>  
290 - <view class="popup-footer">  
291 - <up-button type="primary" text="确定" @click="confirmType6Select"></up-button>  
292 - </view>  
293 - </up-popup> 129 + <up-action-sheet :show="actionSheet.show" :actions="actionSheet.options" @select="onActionSheetSelect"
  130 + @close="actionSheet.show = false" cancel-text="取消"></up-action-sheet>
  131 + <!-- 分层选项选择器(选项带 path 的动态字段) -->
  132 + <grouped-picker :show="groupPicker.show" :title="groupPicker.title" :options="groupPicker.options"
  133 + :multiple="groupPicker.multiple" :max-count="groupPicker.maxCount"
  134 + :need-quantity="groupPicker.needQuantity" :value="groupPicker.value"
  135 + :quantities="groupPicker.quantities" :unit="groupPicker.unit" @update:show="groupPicker.show = $event"
  136 + @confirm="onGroupedConfirm"></grouped-picker>
294 </view> 137 </view>
295 </template> 138 </template>
296 139
297 -  
298 -  
299 -  
300 <script lang="ts"> 140 <script lang="ts">
301 import { uploadImages } from '@/common/utils/upload' 141 import { uploadImages } from '@/common/utils/upload'
302 import { maintainCreate } from "@/api/maintain-manage/maintain-manage" 142 import { maintainCreate } from "@/api/maintain-manage/maintain-manage"
303 import { getByType, getOptionsByCode, getMaterialByCode, workRecordSubmit } from "@/api/common" 143 import { getByType, getOptionsByCode, getMaterialByCode, workRecordSubmit } from "@/api/common"
304 -  
305 export default { 144 export default {
306 data() { 145 data() {
307 return { 146 return {
308 - imageList: [], 147 + imagesList: [],
309 initProgress: 0, 148 initProgress: 0,
310 inspectForm: { 149 inspectForm: {
311 remark: '', 150 remark: '',
312 }, 151 },
  152 + // ========== 新增:用来存放展示文本label的映射对象 ==========
313 labelMap: {}, 153 labelMap: {},
  154 + // 类型7图片/8附件的每字段文件列表(fieldCode -> u-upload 兼容数组 {url,status,name})
314 fileListMap: {}, 155 fileListMap: {},
  156 + // 数量收集:类型6+needQuantity 为 {选项值: 数量};类型3物料为标量(领用数量)
315 qtyMap: {}, 157 qtyMap: {},
316 - paramsOptions: {}, 158 + paramsOptins: {},
  159 + // 幂等流水号:本次填报页面生命周期内保持不变,重复提交(如网络超时后重试)只会落一条记录
317 clientSeq: '', 160 clientSeq: '',
  161 + // 养护计划本体的 commit.id:取到后缓存,重试时不再重复创建养护记录
318 commitId: null, 162 commitId: null,
319 inspectFormRules: { 163 inspectFormRules: {
320 images: [ 164 images: [
321 { 165 {
322 required: true, 166 required: true,
323 - message: '最少上传1张图片', 167 + message: '请上传图片',
324 trigger: 'change', 168 trigger: 'change',
325 validator: (rule, value, callback) => { 169 validator: (rule, value, callback) => {
326 - const hasSuccess = this.imageList.some(item => item.status === 'success')  
327 - const count = this.imageList.filter(item => item.status === 'success').length  
328 - if (!hasSuccess || count < 1) {  
329 - callback(new Error('最少上传1张图片'))  
330 - } else if (count > 3) {  
331 - callback(new Error('最多上传3张图片')) 170 + const hasSuccessImg = this.imagesList.some(item => item.status === 'success')
  171 + const imgCount = this.imagesList.filter(item => item.status === 'success').length
  172 + if (!hasSuccessImg || imgCount < 1) {
  173 + callback(new Error('最少需要上传1张图片'))
  174 + } else if (imgCount > 3) {
  175 + callback(new Error('最多只能上传3张图片'))
332 } else { 176 } else {
333 callback() 177 callback()
334 } 178 }
335 } 179 }
336 } 180 }
337 - ] 181 + ],
338 }, 182 },
339 - dynamicFields: [],  
340 - 183 + // 动态字段列表
  184 + dynamicFields: [] as any[],
  185 + // 日期时间选择器
341 datePickerShow: false, 186 datePickerShow: false,
342 - datePickerValue: new Date().getTime(), 187 + datePickerValue: Number(new Date()),
343 currentDateFieldCode: '', 188 currentDateFieldCode: '',
344 -  
345 - actionSheetShow: false,  
346 - actionSheetOptions: [],  
347 - actionSheetFieldItem: null,  
348 -  
349 - // ===== type6弹窗状态 =====  
350 - popupType6: { 189 + // actionSheet配置
  190 + actionSheet: {
  191 + show: false,
  192 + fieldCode: '', // 当前操作的字段
  193 + options: [] as any[],
  194 + fieldItem: null // 保存当前点击的field对象,用于后面查找label
  195 + },
  196 + // 分层选项选择器配置(选项带 path 的 3/6 字段用,其余字段仍走上面的 actionSheet / 内联复选框)
  197 + groupPicker: {
351 show: false, 198 show: false,
352 fieldCode: '', 199 fieldCode: '',
353 - fieldItem: null,  
354 - activeTab: 0,  
355 - tabs: [],  
356 - activeSubKey: '',  
357 - subMenuList: [],  
358 - allRawData: [],  
359 - rightList: [] 200 + fieldItem: null as any,
  201 + title: '',
  202 + options: [] as any[],
  203 + multiple: false,
  204 + maxCount: Infinity,
  205 + needQuantity: false,
  206 + value: '' as any,
  207 + quantities: {} as any,
  208 + unit: ''
360 } 209 }
361 } 210 }
362 }, 211 },
363 onLoad(option) { 212 onLoad(option) {
364 console.log('页面参数:', option) 213 console.log('页面参数:', option)
365 - this.paramsOptions = option 214 + this.paramsOptins = option
366 const { businessType, planTypeId } = option 215 const { businessType, planTypeId } = option
367 - const workTypeId = planTypeId  
368 - this.loadDynamicFields(businessType, workTypeId) 216 + this.loadDynamicFields(businessType, planTypeId)
369 }, 217 },
370 onReady() { 218 onReady() {
371 this.$refs.inspectFormRef.setRules(this.inspectFormRules) 219 this.$refs.inspectFormRef.setRules(this.inspectFormRules)
372 - console.log('表单规则加载完成') 220 + console.log('巡查表单规则初始化完成')
373 }, 221 },
374 methods: { 222 methods: {
375 - // 获取type6展示文本  
376 - getType6ShowLabel(item) {  
377 - const val = this.inspectForm[item.fieldCode]  
378 - const optList = item.selectOptions || []  
379 - if (item.multiple === 0) {  
380 - // multiple:0 多选,数组  
381 - if (!val || val.length === 0) return ''  
382 - const nameArr = optList.filter(o => val.includes(o.value)).map(o => o.label)  
383 - return nameArr.join('、')  
384 - } else {  
385 - // multiple:1 单选,单个值  
386 - if (!val) return ''  
387 - const findOpt = optList.find(o => o.value === val)  
388 - return findOpt ? findOpt.label : ''  
389 - }  
390 - },  
391 -  
392 - // 打开type6弹窗  
393 - openType6Popup(item) {  
394 - this.popupType6.fieldItem = item  
395 - this.popupType6.fieldCode = item.fieldCode  
396 - this.popupType6.allRawData = this.mockLargeTreeData(item.selectOptions || [])  
397 - this.popupType6.tabs = [  
398 - { label: '机电设备', value: 0 },  
399 - { label: '消防器材', value: 1 },  
400 - { label: '给排水', value: 2 },  
401 - { label: '照明系统', value: 3 },  
402 - { label: '安防监控', value: 4 }  
403 - ]  
404 - this.popupType6.activeTab = 0  
405 - this.onTabChange(0)  
406 - this.popupType6.show = true  
407 - },  
408 -  
409 - mockLargeTreeData(baseOpts) {  
410 - const tree = [  
411 - {  
412 - tabIndex: 0,  
413 - subMenu: [  
414 - { key: 'sub01', label: '水泵类' },  
415 - { key: 'sub02', label: '风机类' },  
416 - { key: 'sub03', label: '控制柜' },  
417 - { key: 'sub04', label: '换热器' },  
418 - ],  
419 - items: [  
420 - { subKey: 'sub01', value: 'p001', label: '立式离心泵A' },  
421 - { subKey: 'sub01', value: 'p002', label: '卧式增压泵B' },  
422 - { subKey: 'sub01', value: 'p003', label: '潜污泵C' },  
423 - { subKey: 'sub02', value: 'f001', label: '排烟风机1号' },  
424 - { subKey: 'sub02', value: 'f002', label: '正压送风机' },  
425 - { subKey: 'sub03', value: 'c001', label: '动力控制柜#1' },  
426 - { subKey: 'sub03', value: 'c002', label: 'PLC控制柜#2' },  
427 - { subKey: 'sub04', value: 'h001', label: '板式换热器' },  
428 - ]  
429 - },  
430 - {  
431 - tabIndex: 1,  
432 - subMenu: [  
433 - { key: 'sub11', label: '灭火器' },  
434 - { key: 'sub12', label: '消火栓' },  
435 - { key: 'sub13', label: '烟感温感' },  
436 - ],  
437 - items: [  
438 - { subKey: 'sub11', value: 'xf001', label: '4kg干粉灭火器' },  
439 - { subKey: 'sub11', value: 'xf002', label: '二氧化碳灭火器' },  
440 - { subKey: 'sub12', value: 'xf010', label: '室内消火栓箱' },  
441 - { subKey: 'sub13', value: 'xf020', label: '光电烟感探测器' },  
442 - { subKey: 'sub13', value: 'xf021', label: '温度探测器' },  
443 - ]  
444 - },  
445 - {  
446 - tabIndex: 2,  
447 - subMenu: [  
448 - { key: 'sub21', label: '阀门' },  
449 - { key: 'sub22', label: '管道配件' },  
450 - ],  
451 - items: [  
452 - { subKey: 'sub21', value: 'w001', label: 'DN100闸阀' },  
453 - { subKey: 'sub21', value: 'w002', label: '止回阀' },  
454 - { subKey: 'sub22', value: 'w010', label: '柔性接头' },  
455 - ]  
456 - },  
457 - {  
458 - tabIndex: 3,  
459 - subMenu: [  
460 - { key: 'sub31', label: 'LED灯具' },  
461 - { key: 'sub32', label: '应急照明' },  
462 - ],  
463 - items: [  
464 - { subKey: 'sub31', value: 'l001', label: 'LED平板灯 300*600' },  
465 - { subKey: 'sub32', value: 'l002', label: '消防应急灯' },  
466 - ]  
467 - },  
468 - {  
469 - tabIndex: 4,  
470 - subMenu: [  
471 - { key: 'sub41', label: '摄像头' },  
472 - { key: 'sub42', label: '门禁' },  
473 - ],  
474 - items: [  
475 - { subKey: 'sub41', value: 'a001', label: '枪机摄像头' },  
476 - { subKey: 'sub41', value: 'a002', label: '半球摄像机' },  
477 - { subKey: 'sub42', value: 'a010', label: '刷卡门禁主机' },  
478 - ]  
479 - }  
480 - ]  
481 - return tree  
482 - },  
483 -  
484 - onTabChange(tabVal) {  
485 - const tabData = this.popupType6.allRawData.find(item => item.tabIndex === tabVal)  
486 - if (!tabData) return  
487 - this.popupType6.subMenuList = tabData.subMenu  
488 - this.popupType6.activeSubKey = tabData.subMenu[0].key  
489 - this.filterRightList()  
490 - },  
491 -  
492 - filterRightList() {  
493 - const tabData = this.popupType6.allRawData.find(item => item.tabIndex === this.popupType6.activeTab)  
494 - if (!tabData) return  
495 - this.popupType6.rightList = tabData.items.filter(it => it.subKey === this.popupType6.activeSubKey)  
496 - },  
497 -  
498 - // 多选maxCount禁用判断,仅 multiple:0(多选)启用  
499 - isType6OptionLocked(opt) {  
500 - const item = this.popupType6.fieldItem  
501 - if (item.multiple !== 0) return false  
502 - const max = item.extConfig?.maxCount  
503 - if (!max) return false  
504 - const selectedArr = this.inspectForm[item.fieldCode] || []  
505 - if (selectedArr.length >= max && !selectedArr.includes(opt.value)) {  
506 - return true  
507 - }  
508 - return false  
509 - },  
510 -  
511 - confirmType6Select() {  
512 - this.$refs.inspectFormRef.validateField(this.popupType6.fieldCode)  
513 - this.popupType6.show = false  
514 - },  
515 - closeType6Popup() {  
516 - this.popupType6.show = false  
517 - },  
518 -  
519 - openPickerIfNeeded(item) {  
520 - if (item.fieldType === 6) {  
521 - return this.openType6Popup(item)  
522 - }  
523 - if (item.fieldType === 3 && item.multiple !== 1) {  
524 - return this.openActionSheet(item)  
525 - }  
526 - if (item.fieldType === 9) {  
527 - return this.openActionSheet(item)  
528 - }  
529 - if (item.fieldType === 11) {  
530 - return this.openLocationPicker(item)  
531 - }  
532 - },  
533 - 223 + // number-box数值变更回调
534 valChange(item) { 224 valChange(item) {
  225 + // 数值修改后触发表单校验
535 this.$refs.inspectFormRef.validateField(item.fieldCode) 226 this.$refs.inspectFormRef.validateField(item.fieldCode)
536 }, 227 },
537 - 228 + // ========== 日期时间相关方法 ==========
538 openDatePicker(field) { 229 openDatePicker(field) {
  230 + console.log('打开日期选择器', field)
539 this.currentDateFieldCode = field.fieldCode 231 this.currentDateFieldCode = field.fieldCode
540 const val = this.inspectForm[field.fieldCode] 232 const val = this.inspectForm[field.fieldCode]
541 - this.datePickerValue = val ? new Date(val).getTime() : new Date().getTime() 233 + this.datePickerValue = val ? new Date(val).getTime() : Number(new Date())
542 this.datePickerShow = true 234 this.datePickerShow = true
543 uni.hideKeyboard() 235 uni.hideKeyboard()
544 }, 236 },
@@ -558,71 +250,158 @@ export default { @@ -558,71 +250,158 @@ export default {
558 onDateClose() { 250 onDateClose() {
559 this.datePickerShow = false 251 this.datePickerShow = false
560 }, 252 },
561 -  
562 - openActionSheet(item) {  
563 - this.actionSheetFieldItem = item  
564 - this.actionSheetOptions = (item.selectOptions || []).map(opt => ({  
565 - name: opt.label,  
566 - value: opt.value  
567 - }))  
568 - this.actionSheetShow = true 253 + /**
  254 + * 点击表单项时按类型决定弹出哪个选择器:
  255 + * 分层选项(带 path 的 3/6) → 分组弹层;3 枚举(单选) / 9 关联记录 → 底部 actionSheet;11 地理位置 → 微信地图选点
  256 + * 其余类型(多选、图片、附件、可输入的链接)不在这里处理,由控件自身响应
  257 + */
  258 + openPickerIfNeeded(item: any) {
  259 + // 分层选项优先:它的只读入口是靠本方法打开弹层的(模板里 3/6 的原有分支已被 isGroupedField 挡在前面)
  260 + if (this.isGroupedField(item)) {
  261 + return this.openGroupedPicker(item)
  262 + }
  263 + // 条件与模板互补:模板用 multiple === 1 渲染复选框组,这里就必须用 !== 1 才打开弹窗,
  264 + // 否则 multiple 为 NULL/0 的存量单选枚举会既不渲染复选框、又点不开弹窗
  265 + if (item.fieldType === 3 && item.multiple !== 1) {
  266 + return this.openActionSheet(item)
  267 + }
  268 + if (item.fieldType === 9) {
  269 + return this.openActionSheet(item)
  270 + }
  271 + if (item.fieldType === 11) {
  272 + return this.openLocation(item)
  273 + }
  274 + },
  275 + /**
  276 + * 是否为「分层选项」字段:3枚举/6多选,且选项带了后端下发的 path。
  277 + * path 是纯增量字段,老字段(静态/字典/物料/数据表/接口/无 gN_ 别名的 SQL)没有它 → 走原路径,行为不变
  278 + */
  279 + isGroupedField(item: any) {
  280 + if (!item || (item.fieldType !== 3 && item.fieldType !== 6)) {
  281 + return false
  282 + }
  283 + return (item.selectOptions || []).some((opt: any) => Array.isArray(opt.path) && opt.path.length > 0)
  284 + },
  285 + /**
  286 + * 打开分层选项弹层。单选/多选判定与模板同一口径:
  287 + * 6 → 多选;3 且 multiple===1 → 多选(提交按逗号拼接);3 其余 → 单选
  288 + */
  289 + openGroupedPicker(item: any) {
  290 + const code = item.fieldCode
  291 + const multiple = item.fieldType === 6 || item.multiple === 1
  292 + this.groupPicker.fieldCode = code
  293 + this.groupPicker.fieldItem = item
  294 + this.groupPicker.title = item.fieldName
  295 + this.groupPicker.options = item.selectOptions || []
  296 + this.groupPicker.multiple = multiple
  297 + this.groupPicker.maxCount = this.maxCountOf(item, Infinity)
  298 + // 只有 6 多选需要每项数量;3 枚举没有这层语义
  299 + this.groupPicker.needQuantity = item.fieldType === 6 && this.needQty(item)
  300 + this.groupPicker.value = multiple ? (this.inspectForm[code] || []).slice() : (this.inspectForm[code] || '')
  301 + this.groupPicker.quantities = Object.assign({}, this.qtyMap[code] || {})
  302 + this.groupPicker.unit = item.unit || ''
  303 + this.groupPicker.show = true
569 }, 304 },
570 - onActionSheetSelect(selected) {  
571 - const fieldItem = this.actionSheetFieldItem  
572 - const fieldCode = fieldItem.fieldCode  
573 - if (fieldItem.fieldType === 9) {  
574 - this.inspectForm[fieldCode] = { id: selected.value, name: selected.name } 305 + /**
  306 + * 分层弹层确定回调。写入位置与原有两条路径完全一致(inspectForm/labelMap/qtyMap),
  307 + * 因此 buildDynamicFields() 一行都不用改
  308 + */
  309 + onGroupedConfirm(payload: any) {
  310 + const code = this.groupPicker.fieldCode
  311 + this.inspectForm[code] = payload.values
  312 + this.labelMap[code] = payload.labelText
  313 + if (this.groupPicker.needQuantity) {
  314 + // 合并而不是替换:保留 loadDynamicFields 预建的选项键,避免非分层路径读到 undefined
  315 + this.qtyMap[code] = Object.assign({}, this.qtyMap[code] || {}, payload.quantities || {})
  316 + }
  317 + this.groupPicker.show = false
  318 + this.$refs.inspectFormRef.validateField(code)
  319 + },
  320 + /**
  321 + * 打开底部选择弹窗 actionSheet
  322 + */
  323 + openActionSheet(item: any) {
  324 + this.actionSheet.fieldCode = item.fieldCode
  325 + this.actionSheet.fieldItem = item
  326 + // 选项统一挂在 selectOptions(静态来源直接用 field.options,物料来源调 materials 接口拿,其余调 options 接口)
  327 + // 物料带 currentNum 时在展示名后附库存余量(只影响展示名,提交的 materialName 仍取干净的 opt.label)
  328 + this.actionSheet.options = (item.selectOptions || []).map(opt => {
  329 + const stock = opt.currentNum === null || opt.currentNum === undefined || opt.currentNum === ''
  330 + ? NaN
  331 + : Number(opt.currentNum)
  332 + return {
  333 + name: isNaN(stock) ? opt.label : `${opt.label}(库存${stock}${opt.unit || ''})`,
  334 + value: opt.value
  335 + }
  336 + })
  337 + this.actionSheet.show = true
  338 + },
  339 + /**
  340 + * actionSheet选中回调
  341 + */
  342 + onActionSheetSelect(selectedItem: any) {
  343 + const fieldItem = this.actionSheet.fieldItem
  344 + const fieldCode = this.actionSheet.fieldCode
  345 + // 1. 表单保存提交值:9 关联记录存 {id,name}(提交时转成 [{id,name}]),其余存裸 value
  346 + if (fieldItem && fieldItem.fieldType === 9) {
  347 + this.inspectForm[fieldCode] = { id: selectedItem.value, name: selectedItem.name }
575 } else { 348 } else {
576 - this.inspectForm[fieldCode] = selected.value 349 + this.inspectForm[fieldCode] = selectedItem.value
577 } 350 }
578 - this.labelMap[fieldCode] = selected.name  
579 - this.actionSheetShow = false 351 + // 2. labelMap保存label,给input展示
  352 + this.labelMap[fieldCode] = selectedItem.name
  353 + this.actionSheet.show = false
  354 + // 选中后触发表单校验
580 this.$refs.inspectFormRef.validateField(fieldCode) 355 this.$refs.inspectFormRef.validateField(fieldCode)
581 }, 356 },
582 -  
583 - openLocationPicker(item) { 357 + /**
  358 + * 类型11 地理位置:微信地图选点(无需地图 AK,manifest.json 已声明 requiredPrivateInfos)
  359 + * 提交形态 {lng,lat,address}
  360 + */
  361 + openLocation(item: any) {
584 uni.chooseLocation({ 362 uni.chooseLocation({
585 success: (res) => { 363 success: (res) => {
586 this.inspectForm[item.fieldCode] = { 364 this.inspectForm[item.fieldCode] = {
587 lng: res.longitude, 365 lng: res.longitude,
588 lat: res.latitude, 366 lat: res.latitude,
589 - address: res.address 367 + address: res.address || res.name || ''
590 } 368 }
591 - this.labelMap[item.fieldCode] = res.address || `${res.latitude},${res.longitude}` 369 + this.labelMap[item.fieldCode] = res.address || res.name || `${res.latitude}, ${res.longitude}`
592 this.$refs.inspectFormRef.validateField(item.fieldCode) 370 this.$refs.inspectFormRef.validateField(item.fieldCode)
593 }, 371 },
594 fail: (err) => { 372 fail: (err) => {
595 - console.error('位置选择失败', err)  
596 - if (err.errMsg.indexOf('auth deny') > -1) {  
597 - uni.showToast({ title: '请开启位置权限', icon: 'none' }) 373 + console.error('地图选择失败', err)
  374 + if (err && err.errMsg && err.errMsg.indexOf('auth deny') > -1) {
  375 + uni.showToast({ title: '请授权位置权限', icon: 'none' })
598 } 376 }
599 } 377 }
600 }) 378 })
601 }, 379 },
602 -  
603 - // 加载动态字段 & 初始化inspectForm 380 + /**
  381 + * 加载动态字段
  382 + */
604 async loadDynamicFields(businessType: string, workTypeId: string) { 383 async loadDynamicFields(businessType: string, workTypeId: string) {
605 if (!businessType || !workTypeId) { 384 if (!businessType || !workTypeId) {
606 - uni.showToast({ title: '缺少参数', icon: 'none' }) 385 + uni.showToast({ title: '缺少业务参数', icon: 'none' })
607 return 386 return
608 } 387 }
609 -  
610 - const bizType = 3 388 + const bizType = 3
611 try { 389 try {
612 - const res = await getByType({ businessType, bizType,workTypeId }) 390 + const res = await getByType({
  391 + businessType,
  392 + workTypeId,
  393 + bizType
  394 + })
613 let fields = res.fields || [] 395 let fields = res.fields || []
614 fields.sort((a, b) => a.sort - b.sort) 396 fields.sort((a, b) => a.sort - b.sort)
615 - this.dynamicFields = fields  
616 - 397 + if (fields.length === 0) {
  398 + uni.showToast({ title: '暂无需填报项', icon: 'none' })
  399 + this.dynamicFields = []
  400 + return
  401 + }
617 for (const field of fields) { 402 for (const field of fields) {
618 - if (field.fieldType === 6) {  
619 - // multiple:0=多选(数组), multiple:1=单选(单个值null)  
620 - if (field.multiple === 0) {  
621 - this.inspectForm[field.fieldCode] = []  
622 - } else {  
623 - this.inspectForm[field.fieldCode] = null  
624 - }  
625 - } else if (field.fieldType === 3 && field.multiple === 1) { 403 + // 初值:多选为数组;9关联记录/11地理位置 为对象(未选为 null);7图片/8附件为 URL 数组;其余空字符串
  404 + if (field.fieldType === 6 || (field.fieldType === 3 && field.multiple === 1)) {
626 this.inspectForm[field.fieldCode] = [] 405 this.inspectForm[field.fieldCode] = []
627 } else if (field.fieldType === 7 || field.fieldType === 8) { 406 } else if (field.fieldType === 7 || field.fieldType === 8) {
628 this.inspectForm[field.fieldCode] = [] 407 this.inspectForm[field.fieldCode] = []
@@ -631,161 +410,387 @@ export default { @@ -631,161 +410,387 @@ export default {
631 } else { 410 } else {
632 this.inspectForm[field.fieldCode] = '' 411 this.inspectForm[field.fieldCode] = ''
633 } 412 }
  413 + // 初始化展示label为空
634 this.labelMap[field.fieldCode] = '' 414 this.labelMap[field.fieldCode] = ''
  415 + // 7图片/8附件的文件列表(驱动 up-upload 与自绘文件列表展示)
635 if (field.fieldType === 7 || field.fieldType === 8) { 416 if (field.fieldType === 7 || field.fieldType === 8) {
636 this.fileListMap[field.fieldCode] = [] 417 this.fileListMap[field.fieldCode] = []
637 } 418 }
638 - if (field.fieldType === 3 || field.fieldType === 6) { 419 + // 选项装载:静态来源(1)直接用下发的 field.options,其余来源(2字典/3物料/4数据表/5SQL/6接口)调 options 接口,
  420 + // 由后端按 optionSource 分发。类型 3/6/9 需要选项,其余类型不需要
  421 + if (field.fieldType === 3 || field.fieldType === 6 || field.fieldType === 9) {
639 if (field.optionSource === 1 && Array.isArray(field.options)) { 422 if (field.optionSource === 1 && Array.isArray(field.options)) {
640 - field.selectOptions = field.options.map(opt => ({  
641 - label: opt.label,  
642 - value: String(opt.value)  
643 - }))  
644 - } else if (field.optionSource === 5) {  
645 - const matRes = await getMaterialByCode({ businessType, bizType, fieldCode: field.fieldCode,workTypeId })  
646 - field.selectOptions = (matRes.list || []).map(mat => ({ 423 + field.selectOptions = field.options.map(opt => ({ label: opt.label, value: String(opt.value), path: opt.path }))
  424 + } else if (field.optionSource === 3) {
  425 + // 物料来源:调 materials(比 options 多返回 currentNum 库存余量/unit),供选择时显示库存
  426 + const matRes = await getMaterialByCode({
  427 + businessType,
  428 + bizType,
  429 + workTypeId,
  430 + fieldCode: field.fieldCode
  431 + })
  432 + field.selectOptions = (matRes || []).map(mat => ({
647 label: mat.materialName, 433 label: mat.materialName,
648 - value: String(mat.materialId) 434 + value: String(mat.materialId),
  435 + currentNum: mat.currentNum,
  436 + unit: mat.unit
649 })) 437 }))
650 } else { 438 } else {
651 - const optRes = await getOptionsByCode({ businessType, bizType, fieldCode: field.fieldCode,workTypeId })  
652 - field.selectOptions = (optRes.list || []).map(opt => ({  
653 - label: opt.label,  
654 - value: String(opt.value)  
655 - })) 439 + const optRes = await getOptionsByCode({
  440 + businessType,
  441 + bizType,
  442 + workTypeId,
  443 + fieldCode: field.fieldCode
  444 + })
  445 + // path 后端仅在 SQL 来源按 gN_key/gN_label 别名产出;透传后由 isGroupedField 判定是否走分层弹层
  446 + field.selectOptions = (optRes || []).map(opt => ({ label: opt.label, value: String(opt.value), path: opt.path }))
656 } 447 }
657 } 448 }
658 - // 初始化数量映射 449 + // 数量收集初始化(必须在 selectOptions 装载之后):
  450 + // 6多选+needQuantity 预建每个选项的键,避免模板 v-model 写到未定义的键上;
  451 + // 3物料为标量(领用数量),默认 0 = 本次不领用、不扣库存
659 if (field.fieldType === 6 && this.needQty(field)) { 452 if (field.fieldType === 6 && this.needQty(field)) {
660 - const qtyObj = {} 453 + const qty = {}
661 ;(field.selectOptions || []).forEach(opt => { 454 ;(field.selectOptions || []).forEach(opt => {
662 - qtyObj[opt.value] = '' 455 + qty[opt.value] = ''
663 }) 456 })
664 - this.qtyMap[field.fieldCode] = qtyObj 457 + this.qtyMap[field.fieldCode] = qty
665 } 458 }
666 - if (field.fieldType === 3 && field.optionSource === 5) { 459 + if (field.fieldType === 3 && field.optionSource === 3) {
667 this.qtyMap[field.fieldCode] = 0 460 this.qtyMap[field.fieldCode] = 0
668 } 461 }
669 - // 构建表单校验规则 462 + // 必填规则
670 if (field.required === 1) { 463 if (field.required === 1) {
671 - this.inspectFormRules[field.fieldCode] = this.buildRequiredRule(field) 464 + this.inspectFormRules[field.fieldCode] = [this.buildRequiredRule(field)]
672 } 465 }
673 } 466 }
  467 + this.dynamicFields = fields
674 this.$nextTick(() => { 468 this.$nextTick(() => {
675 this.$refs.inspectFormRef.setRules(this.inspectFormRules) 469 this.$refs.inspectFormRef.setRules(this.inspectFormRules)
676 }) 470 })
677 } catch (err) { 471 } catch (err) {
678 - console.error('加载动态表单失败', err)  
679 - uni.showToast({ title: '加载表单失败', icon: 'none' }) 472 + console.error('获取动态表单字段失败:', err)
  473 + uni.showToast({ title: '加载表单配置失败', icon: 'none' })
680 } 474 }
681 }, 475 },
682 -  
683 - needQty(item) { 476 + /**
  477 + * 删除图片
  478 + */
  479 + deleteImg(event) {
  480 + this.imagesList.splice(event.index, 1)
  481 + this.$refs.inspectFormRef.validateField('images')
  482 + uni.showToast({ title: '图片删除成功', icon: 'success' })
  483 + },
  484 + /**
  485 + * 上传图片
  486 + */
  487 + async uploadImgs(event) {
  488 + const fileList = Array.isArray(event.file) ? event.file : [event.file]
  489 + const targetImgList = this.imagesList
  490 + const filePaths = fileList.map(item => item.url)
  491 + const tempItems = fileList.map(item => ({ ...item, status: 'uploading', message: '上传中' }))
  492 + const startIndex = targetImgList.length
  493 + targetImgList.push(...tempItems)
  494 + try {
  495 + const uploadResultUrls = await uploadImages({ filePaths, ignoreError: true })
  496 + uploadResultUrls.forEach((url, index) => {
  497 + if (targetImgList[startIndex + index]) {
  498 + targetImgList.splice(startIndex + index, 1, {
  499 + ...fileList[index], status: 'success', message: '', url
  500 + })
  501 + }
  502 + })
  503 + if (uploadResultUrls.length < fileList.length) {
  504 + const failCount = fileList.length - uploadResultUrls.length
  505 + for (let i = uploadResultUrls.length; i < fileList.length; i++) {
  506 + if (targetImgList[startIndex + i]) {
  507 + targetImgList.splice(startIndex + i, 1, {
  508 + ...fileList[i], status: 'failed', message: '上传失败'
  509 + })
  510 + }
  511 + }
  512 + uni.showToast({ title: `成功上传${uploadResultUrls.length}张,失败${failCount}张`, icon: 'none' })
  513 + } else {
  514 + uni.showToast({ title: `成功上传${fileList.length}张图片`, icon: 'success' })
  515 + }
  516 + this.$refs.inspectFormRef.validateField('images')
  517 + } catch (err) {
  518 + console.error('图片上传失败:', err)
  519 + for (let i = 0; i < fileList.length; i++) {
  520 + if (targetImgList[startIndex + i]) {
  521 + targetImgList.splice(startIndex + i, 1, {
  522 + ...fileList[i], status: 'failed', message: '上传失败'
  523 + })
  524 + }
  525 + }
  526 + uni.showToast({ title: '图片上传失败,请重试', icon: 'none' })
  527 + this.$refs.inspectFormRef.validateField('images')
  528 + }
  529 + },
  530 + handleExceed() {
  531 + uni.showToast({ title: '最多只能上传3张图片', icon: 'none' })
  532 + },
  533 + getImgUrlList(imgList) {
  534 + return imgList.filter(item => item.status === 'success').map(item => item.url)
  535 + },
  536 + // ========== 动态字段:渲染与取值辅助 ==========
  537 + /**
  538 + * 类型6 多选是否要求每项填「使用数量」(ext_config.needQuantity)
  539 + * 仅记录用量,不写物料三列 ⇒ 结构性不触发扣库存(扣减一律按值行 materialId 非空判定)
  540 + */
  541 + needQty(item: any) {
684 return !!(item.extConfig && item.extConfig.needQuantity === true) 542 return !!(item.extConfig && item.extConfig.needQuantity === true)
685 }, 543 },
686 - pickCountOf(item, defaultNum) {  
687 - const max = item.extConfig?.maxCount  
688 - return max || defaultNum 544 + /**
  545 + * 有效上限:ext_config.maxCount 的「填 0 表示不限」(PC 端提示语)在提交侧照此生效
  546 + * 未配置 → 类型默认值;配 0 → Infinity(不限);配正数 → 该数
  547 + */
  548 + maxCountOf(item: any, defaultValue: number) {
  549 + const raw = item.extConfig ? item.extConfig.maxCount : undefined
  550 + if (raw === undefined || raw === null || raw === '') {
  551 + return defaultValue
  552 + }
  553 + const num = Number(raw)
  554 + if (isNaN(num)) {
  555 + return defaultValue
  556 + }
  557 + return num > 0 ? num : Infinity
  558 + },
  559 + /** 传给选择器的单次可选数量:上限为「不限」时用组件默认值 52(平台要求 count 是有限数) */
  560 + pickCountOf(item: any, defaultValue: number) {
  561 + const max = this.maxCountOf(item, defaultValue)
  562 + return max === Infinity ? 52 : max
  563 + },
  564 + /** 单文件大小上限(MB):ext_config.maxSizeMb,未配置时用默认值 */
  565 + maxSizeMbOf(item: any, defaultValue: number) {
  566 + const num = item.extConfig ? Number(item.extConfig.maxSizeMb) : NaN
  567 + return num > 0 ? num : defaultValue
689 }, 568 },
690 - fileButtonText(item) {  
691 - const exts = this.extListOf(item)  
692 - return exts.length ? `选择文件(${exts.join(',')})` : '选择文件' 569 + /** 类型8 允许的扩展名数组(ext_config.fileType,不含点) */
  570 + extensionsOf(item: any) {
  571 + const list = item.extConfig && item.extConfig.fileType
  572 + return Array.isArray(list) ? list : []
693 }, 573 },
694 - extListOf(item) {  
695 - return [] 574 + fileButtonText(item: any) {
  575 + const exts = this.extensionsOf(item)
  576 + return exts.length ? `选择文件(${exts.join('/')})` : '选择文件'
696 }, 577 },
697 - fileNameOf(url) { 578 + /** 从 URL 取末段当文件名(与 PC 端 FieldValueCell 同口径) */
  579 + fileNameOf(url: string) {
698 if (!url) return '' 580 if (!url) return ''
699 - const path = String(url).split('?')[0] 581 + const path = String(url).split('?')[0].split('#')[0]
700 const name = path.substring(path.lastIndexOf('/') + 1) 582 const name = path.substring(path.lastIndexOf('/') + 1)
701 try { 583 try {
702 - return decodeURIComponent(name)  
703 - } catch {  
704 - return name 584 + return decodeURIComponent(name) || url
  585 + } catch (err) {
  586 + return name || url
705 } 587 }
706 }, 588 },
707 -  
708 - buildRequiredRule(field) { 589 + /** 类型6 已勾选的选项(用于渲染每个已选项的数量输入行) */
  590 + checkedOptions(item: any) {
  591 + const values = this.inspectForm[item.fieldCode] || []
  592 + return (item.selectOptions || []).filter(opt => values.indexOf(opt.value) > -1)
  593 + },
  594 + /**
  595 + * 多选是否达到上限(用于禁用未选中项)。
  596 + * 不用「超限后回写 v-model 截断」:u-checkbox 的勾选态是组件内部状态(只受 checked prop 影响),
  597 + * 回写父级 v-model 撤不掉已经勾上的项,下一次点击还会按内部状态重新算一遍,等于没限住
  598 + */
  599 + isOptionLocked(item: any, opt: any) {
  600 + const max = this.maxCountOf(item, Infinity)
  601 + if (max === Infinity) {
  602 + return false
  603 + }
  604 + const values = this.inspectForm[item.fieldCode] || []
  605 + return values.length >= max && values.indexOf(opt.value) === -1
  606 + },
  607 + /** 多选的「已选 x/上限」提示,不限时返回空串 */
  608 + multiHint(item: any) {
  609 + const max = this.maxCountOf(item, Infinity)
  610 + if (max === Infinity) {
  611 + return ''
  612 + }
  613 + return `已选 ${(this.inspectForm[item.fieldCode] || []).length}/${max}`
  614 + },
  615 + onMultiChange(item: any) {
  616 + // 上限由 isOptionLocked 禁用未选项保证,这里只负责触发必填校验
  617 + this.$refs.inspectFormRef.validateField(item.fieldCode)
  618 + },
  619 + /**
  620 + * 必填规则。
  621 + * 7/8/9/11 的值是数组或对象,async-validator 的 required 按默认 string 类型判定,
  622 + * 会放过「空数组」「null」⇒ 必须用 validator 显式判空,否则未选也能提交
  623 + */
  624 + buildRequiredRule(field: any) {
709 const type = field.fieldType 625 const type = field.fieldType
710 - const action = type === 6 ? '请选择' : type === 7 || type === 8 ? '请上传' : '请填写'  
711 - return {  
712 - required: true,  
713 - trigger: 'change',  
714 - validator: (rule, value, callback) => {  
715 - let pass = false  
716 - if (type === 6) {  
717 - if (field.multiple === 0) {  
718 - // 多选:数组非空  
719 - pass = Array.isArray(value) && value.length > 0  
720 - } else {  
721 - // 单选:value不为空  
722 - pass = !!value  
723 - }  
724 - } else if (type === 7 || type === 8) {  
725 - pass = Array.isArray(value) && value.length > 0  
726 - } else {  
727 - pass = !!value 626 + if (type === 6 || type === 7 || type === 8) {
  627 + const action = type === 6 ? '请选择' : '请上传'
  628 + return {
  629 + required: true,
  630 + trigger: 'change',
  631 + validator: (rule, value, callback) => {
  632 + Array.isArray(value) && value.length > 0 ? callback() : callback(new Error(`${action}${field.fieldName}`))
728 } 633 }
729 - pass ? callback() : callback(new Error(`${action}${field.fieldName}`))  
730 } 634 }
731 } 635 }
  636 + if (type === 9 || type === 11) {
  637 + return {
  638 + required: true,
  639 + trigger: 'change',
  640 + validator: (rule, value, callback) => {
  641 + value ? callback() : callback(new Error(type === 9 ? `请选择${field.fieldName}` : `请选择${field.fieldName}的位置`))
  642 + }
  643 + }
  644 + }
  645 + // 1数值/2文本/4日期/5布尔/10链接 为字符串;3枚举/6单选为选择项
  646 + const action = type === 3 || type === 5 ? '请选择' : '请填写'
  647 + return { required: true, message: `${action}${field.fieldName}`, trigger: 'change' }
732 }, 648 },
733 -  
734 - // 图片上传  
735 - async uploadImages(event) {  
736 - const file = event.file  
737 - const res = await uploadImages(file)  
738 - this.imageList.push({  
739 - url: res.url,  
740 - status: 'success'  
741 - })  
742 - this.$refs.inspectFormRef.validateField('images')  
743 - },  
744 - deleteImage(event) {  
745 - const index = event.index  
746 - this.imageList.splice(index, 1)  
747 - this.$refs.inspectFormRef.validateField('images') 649 + /** 幂等流水号(后端按 userId+clientSeq 去重,命中则返回已存在的 recordId) */
  650 + genClientSeq() {
  651 + return `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
748 }, 652 },
749 - handleExceed() {  
750 - uni.showToast({ title: '最多上传3张图片', icon: 'none' }) 653 + // ========== 动态字段:7图片/8附件 ==========
  654 + /** 单文件是否超过 ext_config.maxSizeMb(超限即 toast 并返回 true 以便过滤掉) */
  655 + isOversize(file: any, maxSizeMb: number) {
  656 + if (!maxSizeMb || !file || !file.size) return false
  657 + const over = file.size / 1024 / 1024 > maxSizeMb
  658 + if (over) {
  659 + uni.showToast({ title: `单个文件不能超过${maxSizeMb}MB`, icon: 'none' })
  660 + }
  661 + return over
751 }, 662 },
752 -  
753 - uploadFieldFiles(item, event) {  
754 - // 附件上传逻辑 663 + /** 把已上传成功的 URL 同步进表单值(提交与校验都读 inspectForm) */
  664 + syncFileValue(item: any) {
  665 + const list = this.fileListMap[item.fieldCode] || []
  666 + this.inspectForm[item.fieldCode] = list.filter(file => file.status === 'success').map(file => file.url)
755 }, 667 },
756 - deleteFieldFile(item, event) {  
757 - const idx = event.index  
758 - this.fileListMap[item.fieldCode].splice(idx, 1)  
759 - this.syncFileValue(item)  
760 - this.$refs.inspectFormRef.validateField(item.fieldCode) 668 + /**
  669 + * 7图片 上传(u-upload 的 after-read 回调)
  670 + */
  671 + uploadFieldFiles(item: any, event: any) {
  672 + const rawFile = event && event.file
  673 + const fileList = Array.isArray(rawFile) ? rawFile : [rawFile]
  674 + return this.uploadToField(item, fileList.filter(file => file && file.url).map(file => ({
  675 + url: file.url,
  676 + name: file.name || '',
  677 + size: file.size
  678 + })))
761 }, 679 },
762 - chooseFieldFiles(item) { 680 + /**
  681 + * 8附件 选择并上传。
  682 + * 微信小程序没有通用文件选择器,只能从聊天记录里选(uni.chooseMessageFile)
  683 + */
  684 + chooseFieldFiles(item: any) {
  685 + const maxCount = this.maxCountOf(item, 5)
  686 + const remain = maxCount - (this.fileListMap[item.fieldCode] || []).length
  687 + if (remain <= 0) {
  688 + uni.showToast({ title: `最多只能上传${maxCount}个`, icon: 'none' })
  689 + return
  690 + }
  691 + const exts = this.extensionsOf(item)
  692 + const options: any = { count: Math.min(remain, this.pickCountOf(item, 5)), type: 'file' }
  693 + if (exts.length) {
  694 + options.extension = exts
  695 + }
763 uni.chooseMessageFile({ 696 uni.chooseMessageFile({
764 - count: this.pickCountOf(item,5),  
765 - type: 'file', 697 + ...options,
766 success: (res) => { 698 success: (res) => {
767 - const files = res.tempFiles  
768 - files.forEach(tempFile => {  
769 - this.fileListMap[item.fieldCode].push({  
770 - name: tempFile.name,  
771 - url: tempFile.path  
772 - })  
773 - })  
774 - this.syncFileValue(item)  
775 - this.$refs.inspectFormRef.validateField(item.fieldCode) 699 + const tempFiles = res.tempFiles || []
  700 + this.uploadToField(item, tempFiles.map(file => ({
  701 + url: file.path,
  702 + name: file.name || '',
  703 + size: file.size
  704 + })))
776 }, 705 },
777 fail: (err) => { 706 fail: (err) => {
778 - console.error('选择附件失败', err) 707 + console.error('选择附件失败:', err)
779 } 708 }
780 }) 709 })
781 }, 710 },
782 - syncFileValue(item) { 711 + /**
  712 + * 把选中的本地文件逐个上传并把 http 地址回填到 fileListMap / inspectForm。
  713 + * 7图片与8附件共用(uploadImages 是通用 uni.uploadFile,不限图片类型)
  714 + */
  715 + async uploadToField(item: any, files: any[]) {
  716 + const code = item.fieldCode
  717 + const maxCount = this.maxCountOf(item, 5)
  718 + const maxSizeMb = this.maxSizeMbOf(item, 5)
  719 + const validFiles = (files || []).filter(file => file && file.url && !this.isOversize(file, maxSizeMb))
  720 + if (!validFiles.length) {
  721 + return
  722 + }
  723 + const targetList = this.fileListMap[code] || (this.fileListMap[code] = [])
  724 + if (targetList.length + validFiles.length > maxCount) {
  725 + uni.showToast({ title: `最多只能上传${maxCount}个`, icon: 'none' })
  726 + return
  727 + }
  728 + const startIndex = targetList.length
  729 + targetList.push(...validFiles.map(file => ({
  730 + url: file.url, status: 'uploading', message: '上传中', name: file.name || ''
  731 + })))
  732 + try {
  733 + const uploadResultUrls = await uploadImages({
  734 + filePaths: validFiles.map(file => file.url),
  735 + ignoreError: true
  736 + })
  737 + uploadResultUrls.forEach((url, index) => {
  738 + if (targetList[startIndex + index]) {
  739 + targetList.splice(startIndex + index, 1, {
  740 + url, status: 'success', message: '', name: validFiles[index].name || ''
  741 + })
  742 + }
  743 + })
  744 + for (let i = uploadResultUrls.length; i < validFiles.length; i++) {
  745 + if (targetList[startIndex + i]) {
  746 + targetList.splice(startIndex + i, 1, {
  747 + url: validFiles[i].url, status: 'failed', message: '上传失败', name: validFiles[i].name || ''
  748 + })
  749 + }
  750 + }
  751 + if (uploadResultUrls.length < validFiles.length) {
  752 + uni.showToast({
  753 + title: `成功${uploadResultUrls.length}个,失败${validFiles.length - uploadResultUrls.length}个`,
  754 + icon: 'none'
  755 + })
  756 + }
  757 + } catch (err) {
  758 + console.error(`【${code}】文件上传失败:`, err)
  759 + for (let i = 0; i < validFiles.length; i++) {
  760 + if (targetList[startIndex + i]) {
  761 + targetList.splice(startIndex + i, 1, {
  762 + url: validFiles[i].url, status: 'failed', message: '上传失败', name: validFiles[i].name || ''
  763 + })
  764 + }
  765 + }
  766 + uni.showToast({ title: '上传失败,请重试', icon: 'none' })
  767 + }
  768 + this.syncFileValue(item)
  769 + this.$refs.inspectFormRef.validateField(code)
  770 + },
  771 + /**
  772 + * 删除 7图片/8附件 的某一个已选文件
  773 + */
  774 + deleteFieldFile(item: any, event: any) {
783 const list = this.fileListMap[item.fieldCode] || [] 775 const list = this.fileListMap[item.fieldCode] || []
784 - this.inspectForm[item.fieldCode] = list.map(file => file.url) 776 + const index = Number(event && event.index)
  777 + if (isNaN(index) || index < 0 || index >= list.length) {
  778 + return
  779 + }
  780 + list.splice(index, 1)
  781 + this.syncFileValue(item)
  782 + this.$refs.inspectFormRef.validateField(item.fieldCode)
785 }, 783 },
786 - 784 + // ========== 动态字段:组装提交体 ==========
  785 + /**
  786 + * 按值格式契约组装 fields[](见 docs/design/app-work-field-api.md §四)。
  787 + * 两条硬规则:
  788 + * ① 空值一律不出现在 fields[] 里(不提交 ''/'[]'/'null')
  789 + * ② 物料的数量>0 才写 materialId/materialName/quantity —— 非空 materialId 即触发扣库存,
  790 + * 因此 6多选+needQuantity(只记录用量)绝不能写这三列,只把 quantity 放进 valueText 的 JSON 里
  791 + */
787 buildDynamicFields() { 792 buildDynamicFields() {
788 - const fields = [] 793 + const fields: any[] = []
789 for (const item of this.dynamicFields) { 794 for (const item of this.dynamicFields) {
790 const code = item.fieldCode 795 const code = item.fieldCode
791 const value = this.inspectForm[code] 796 const value = this.inspectForm[code]
@@ -793,111 +798,131 @@ export default { @@ -793,111 +798,131 @@ export default {
793 if (type === 9 || type === 11) { 798 if (type === 9 || type === 11) {
794 if (!value) continue 799 if (!value) continue
795 } else if (type === 6 || type === 7 || type === 8) { 800 } else if (type === 6 || type === 7 || type === 8) {
796 - if ((!value) || (Array.isArray(value) && value.length === 0)) continue  
797 - } else if (!value) continue 801 + if (!Array.isArray(value) || value.length === 0) continue
  802 + } else if (value === '' || value === null || value === undefined) {
  803 + continue
  804 + }
798 805
799 if (type === 1) { 806 if (type === 1) {
800 fields.push({ fieldCode: code, valueNumber: Number(value) }) 807 fields.push({ fieldCode: code, valueNumber: Number(value) })
801 - } else if (type === 3 && item.optionSource === 5) { 808 + } else if (type === 3 && item.optionSource === 3) {
  809 + // 物料:value_text 存物料ID;数量>0 才落物料三列(0 表示本次不领用)
802 const qty = Number(this.qtyMap[code] || 0) 810 const qty = Number(this.qtyMap[code] || 0)
803 - const matched = (item.selectOptions || []).find(opt => opt.value === String(value))  
804 - const row = { fieldCode: code, valueText: String(value) }  
805 - if (qty > 0 && matched) {  
806 - row.materialId = matched.value  
807 - row.materialName = matched.label 811 + const matched = (item.selectOptions || []).filter(opt => opt.value === String(value))[0]
  812 + const row: any = { fieldCode: code, valueText: String(value) }
  813 + if (qty > 0) {
  814 + row.materialId = Number(value)
  815 + row.materialName = matched ? matched.label : ''
808 row.quantity = qty 816 row.quantity = qty
809 } 817 }
810 fields.push(row) 818 fields.push(row)
811 } else if (type === 3) { 819 } else if (type === 3) {
812 - if (item.multiple === 1) {  
813 - fields.push({ fieldCode: code, valueText: (value || []).join(',') })  
814 - } else {  
815 - fields.push({ fieldCode: code, valueText: String(value) })  
816 - } 820 + // 3枚举:单选取裸 value;历史「枚举+是否多选」存逗号分隔
  821 + fields.push({
  822 + fieldCode: code,
  823 + valueText: item.multiple === 1 ? (value || []).join(',') : String(value)
  824 + })
817 } else if (type === 6) { 825 } else if (type === 6) {
818 - let selected = []  
819 - if (item.multiple === 0) {  
820 - // multiple=0 多选,数组  
821 - selected = (item.selectOptions || []).filter(opt => (value || []).includes(opt.value))  
822 - } else {  
823 - // multiple=1 单选,单个值  
824 - selected = (item.selectOptions || []).filter(opt => opt.value === value)  
825 - } 826 + const selected = (item.selectOptions || []).filter(opt => (value || []).indexOf(opt.value) > -1)
826 if (this.needQty(item)) { 827 if (this.needQty(item)) {
827 - const qtyObj = this.qtyMap[code] || {} 828 + // 元素 {value,label,quantity};数量留空的不带 quantity 键(不臆测成 0),label 作快照落库
  829 + const qtyMap = this.qtyMap[code] || {}
828 fields.push({ 830 fields.push({
829 fieldCode: code, 831 fieldCode: code,
830 valueText: JSON.stringify(selected.map(opt => { 832 valueText: JSON.stringify(selected.map(opt => {
831 - const raw = qtyObj[opt.value]  
832 - const rowItem = { value: opt.value, label: opt.label } 833 + const raw = qtyMap[opt.value]
  834 + const row: any = { value: opt.value, label: opt.label }
833 if (raw !== '' && raw !== null && raw !== undefined) { 835 if (raw !== '' && raw !== null && raw !== undefined) {
834 - rowItem.quantity = Number(raw) 836 + row.quantity = Number(raw)
835 } 837 }
836 - return rowItem 838 + return row
837 })) 839 }))
838 }) 840 })
839 } else { 841 } else {
840 - if (item.multiple === 0) {  
841 - fields.push({ fieldCode: code, valueText: JSON.stringify((value || []).map(v => String(v))) })  
842 - } else {  
843 - fields.push({ fieldCode: code, valueText: JSON.stringify(String(value)) })  
844 - } 842 + fields.push({ fieldCode: code, valueText: JSON.stringify(value.map(v => String(v))) })
845 } 843 }
846 } else if (type === 7 || type === 8) { 844 } else if (type === 7 || type === 8) {
847 fields.push({ fieldCode: code, valueText: JSON.stringify(value) }) 845 fields.push({ fieldCode: code, valueText: JSON.stringify(value) })
848 } else if (type === 9) { 846 } else if (type === 9) {
849 fields.push({ fieldCode: code, valueText: JSON.stringify([{ id: value.id, name: value.name }]) }) 847 fields.push({ fieldCode: code, valueText: JSON.stringify([{ id: value.id, name: value.name }]) })
850 } else if (type === 11) { 848 } else if (type === 11) {
851 - fields.push({ fieldCode: code, valueText: JSON.stringify({ lng: value.lng, lat: value.lat, address: value.address }) }) 849 + fields.push({
  850 + fieldCode: code,
  851 + valueText: JSON.stringify({ lng: value.lng, lat: value.lat, address: value.address || '' })
  852 + })
852 } else { 853 } else {
  854 + // 2文本 / 4日期 / 5布尔 / 10链接 均为裸文本
853 fields.push({ fieldCode: code, valueText: String(value) }) 855 fields.push({ fieldCode: code, valueText: String(value) })
854 } 856 }
855 } 857 }
856 return fields 858 return fields
857 }, 859 },
858 - 860 + /**
  861 + * 提交养护记录 —— 双写两条链路:
  862 + * ① maintain-plan-commit/create:养护计划本身(描述 + 图片 + 人员),返回 commit.id
  863 + * ② work-record/submit:动态字段值,bizId 用 ① 的 commit.id(旧接口的 VO 没有动态字段,会被 Jackson 静默丢弃)
  864 + * 重试安全:commitId 一旦取到就缓存住,重试只补写 ②(② 自身按 clientSeq 幂等),避免重复创建养护记录
  865 + */
859 async submitInspect() { 866 async submitInspect() {
860 try { 867 try {
861 await this.$refs.inspectFormRef.validate() 868 await this.$refs.inspectFormRef.validate()
862 - uni.showLoading({ title: '提交中' }) 869 + const dynamicValues = this.buildDynamicFields()
863 if (!this.clientSeq) { 870 if (!this.clientSeq) {
864 - this.clientSeq = Date.now().toString() + '-' + Math.random().toString(16).slice(2,10) 871 + this.clientSeq = this.genClientSeq()
865 } 872 }
  873 + uni.showLoading({ title: '提交中...' })
  874 + // ① 养护计划本体:仅首次提交时写入
866 if (!this.commitId) { 875 if (!this.commitId) {
867 const submitData = { 876 const submitData = {
868 - planNo: this.paramsOptions.planNo, 877 + planNo: this.paramsOptins.planNo,
869 imgHost: "1", 878 imgHost: "1",
870 - beginImg: this.imageList.map(img => img.url), 879 + beginImg: this.getImgUrlList(this.imagesList),
871 commonUserList: [], 880 commonUserList: [],
872 - remark: this.inspectForm.remark.trim() 881 + remark: this.inspectForm.remark.trim(),
  882 + }
  883 + const commitRes: any = await maintainCreate(submitData)
  884 + // 兼容后端返回裸 id 或 {id:...} 两种形态
  885 + this.commitId = commitRes && typeof commitRes === 'object' ? commitRes.id : commitRes
  886 + }
  887 + // ② 动态字段值:无动态字段时不产生空的字段值记录
  888 + if (dynamicValues.length > 0) {
  889 + if (!this.commitId) {
  890 + console.warn('未取到养护计划的 commit.id,跳过动态字段提交')
  891 + } else {
  892 + await workRecordSubmit({
  893 + businessType: this.paramsOptins.businessType,
  894 + bizType: 3,
  895 + bizId: this.commitId,
  896 + workTypeId: Number(this.paramsOptins.planTypeId),
  897 + clientSeq: this.clientSeq,
  898 + remark: this.inspectForm.remark.trim(),
  899 + fields: dynamicValues
  900 + })
873 } 901 }
874 - const createRes = await maintainCreate(submitData)  
875 - this.commitId = createRes.id  
876 } 902 }
877 - const dynamicValues = this.buildDynamicFields()  
878 - await workRecordSubmit({  
879 - businessType: this.paramsOptions.businessType,  
880 - planTypeId: this.paramsOptions.planTypeId,  
881 - bizType:3,  
882 - recordId: this.commitId,  
883 - clientSeq: this.clientSeq,  
884 - remark: this.inspectForm.remark.trim(),  
885 - fields: dynamicValues  
886 - })  
887 uni.hideLoading() 903 uni.hideLoading()
888 - uni.showToast({ title: '提交成功' })  
889 - setTimeout(()=>{  
890 - uni.reLaunch({url:'/pages/index/index'})  
891 - },1000)  
892 - } catch (err) { 904 + uni.showToast({ title: '提交成功', icon: 'success', duration: 1000 })
  905 + setTimeout(() => {
  906 + uni.reLaunch({ url: '/pages-sub/daily/maintain-manage/index' })
  907 + }, 1000)
  908 + } catch (error) {
893 uni.hideLoading() 909 uni.hideLoading()
894 - console.error('提交失败', err)  
895 - if (Array.isArray(err)) return  
896 - const msg = err?.msg || '提交失败,请重试' 910 + // 表单校验失败时 error 是字段数组(uview-plus 已提示),不重复弹
  911 + if (Array.isArray(error)) {
  912 + return
  913 + }
  914 + console.error('养护记录提交失败:', error)
  915 + // request.js 已把后端 msg 弹过一次(如「库存不足」),此处不要用通用文案盖掉真实原因
  916 + const backendMsg = error && error.msg
897 if (this.commitId) { 917 if (this.commitId) {
898 - uni.showToast({ title: `已创建工单,动态字段提交失败:${msg}`, icon: 'none' })  
899 - } else {  
900 - uni.showToast({ title: msg, icon: 'none' }) 918 + // 养护计划已落库,失败只发生在耗材字段。必须说清「已提交」,否则用户会以为整单没提交而重复填
  919 + uni.showToast({
  920 + title: backendMsg ? `养护记录已提交:${backendMsg}` : '养护记录已提交,耗材数据保存失败,请重试',
  921 + icon: 'none',
  922 + duration: 2500
  923 + })
  924 + } else if (!backendMsg) {
  925 + uni.showToast({ title: '提交失败,请重试', icon: 'none', duration: 2000 })
901 } 926 }
902 } 927 }
903 } 928 }
@@ -905,13 +930,8 @@ export default { @@ -905,13 +930,8 @@ export default {
905 } 930 }
906 </script> 931 </script>
907 932
908 -  
909 -  
910 -  
911 <style lang="scss" scoped> 933 <style lang="scss" scoped>
912 .page-container { 934 .page-container {
913 - min-height: 100vh;  
914 - background-color: #f5f5f5;  
915 padding-bottom: 120rpx; 935 padding-bottom: 120rpx;
916 } 936 }
917 .inspect-form-content { 937 .inspect-form-content {
@@ -922,21 +942,24 @@ export default { @@ -922,21 +942,24 @@ export default {
922 .date-input-wrap { 942 .date-input-wrap {
923 width: 100%; 943 width: 100%;
924 } 944 }
925 -.fixed-bottom-wrap { 945 +.fixed-bottom-btn-wrap {
926 position: fixed; 946 position: fixed;
927 bottom: 0; 947 bottom: 0;
928 left: 0; 948 left: 0;
929 width: 100%; 949 width: 100%;
930 - background: #fff; 950 + padding: 20rpx;
  951 + box-sizing: border-box;
  952 + background-color: #fff;
931 } 953 }
  954 +// 放大u-number-box输入框
932 .large-number-box { 955 .large-number-box {
933 :deep(.u-number-box__input) { 956 :deep(.u-number-box__input) {
934 width: 264rpx !important; 957 width: 264rpx !important;
935 height: 72rpx; 958 height: 72rpx;
936 font-size: 32rpx; 959 font-size: 32rpx;
937 } 960 }
938 - :deep(.u-number-box__minus),  
939 - :deep(.u-number-box__plus) { 961 + :deep(.u-number-box__btn) {
  962 + width: 64rpx;
940 height: 72rpx; 963 height: 72rpx;
941 } 964 }
942 } 965 }
@@ -944,11 +967,12 @@ export default { @@ -944,11 +967,12 @@ export default {
944 display: flex; 967 display: flex;
945 align-items: center; 968 align-items: center;
946 gap: 16rpx; 969 gap: 16rpx;
  970 + .unit-text {
  971 + font-size: 28rpx;
  972 + color: #333;
  973 + }
947 } 974 }
948 -.unit-text {  
949 - font-size: 28rpx;  
950 - color: #333;  
951 -} 975 +// 6多选:复选框组 + needQuantity 时每个已选项一行数量输入
952 .multi-wrap { 976 .multi-wrap {
953 width: 100%; 977 width: 100%;
954 .multi-hint { 978 .multi-hint {
@@ -963,6 +987,7 @@ export default { @@ -963,6 +987,7 @@ export default {
963 border-left: 4rpx solid #f0f0f0; 987 border-left: 4rpx solid #f0f0f0;
964 } 988 }
965 } 989 }
  990 +// 数量行(6多选的每个已选项 / 3物料的领用数量共用)
966 .qty-row { 991 .qty-row {
967 display: flex; 992 display: flex;
968 align-items: center; 993 align-items: center;
@@ -981,86 +1006,28 @@ export default { @@ -981,86 +1006,28 @@ export default {
981 padding-top: 12rpx; 1006 padding-top: 12rpx;
982 border-top: 1rpx solid #f0f0f0; 1007 border-top: 1rpx solid #f0f0f0;
983 } 1008 }
  1009 +// 8附件:自绘文件列表 + 选择按钮(微信无通用文件选择器,走 chooseMessageFile)
984 .file-wrap { 1010 .file-wrap {
985 width: 100%; 1011 width: 100%;
986 .file-row { 1012 .file-row {
987 display: flex; 1013 display: flex;
988 align-items: center; 1014 align-items: center;
989 justify-content: space-between; 1015 justify-content: space-between;
990 - gap:16rpx;  
991 - padding:8rpx 0; 1016 + gap: 16rpx;
  1017 + padding: 8rpx 0;
992 .file-name { 1018 .file-name {
993 - flex:1;  
994 - min-width:0;  
995 - font-size:28rpx;  
996 - color:#333;  
997 - overflow:hidden;  
998 - white-space:nowrap;  
999 - text-overflow:ellipsis;  
1000 - }  
1001 - .file-del {  
1002 - font-size:26rpx;  
1003 - color:#f56c6c;  
1004 - }  
1005 - }  
1006 -}  
1007 -  
1008 -// type6弹窗样式  
1009 -.popup-header {  
1010 - display: flex;  
1011 - justify-content: space-between;  
1012 - align-items: center;  
1013 - padding: 30rpx;  
1014 - .popup-title {  
1015 - font-size: 34rpx;  
1016 - font-weight: bold;  
1017 - }  
1018 -}  
1019 -.popup-body {  
1020 - display: flex;  
1021 - flex: 1;  
1022 - height: calc(100vh - 220rpx);  
1023 - .left-sidebar {  
1024 - width: 220rpx;  
1025 - background: #f6f7f8;  
1026 - overflow-y: auto;  
1027 - .sidebar-item {  
1028 - padding: 24rpx 16rpx; 1019 + flex: 1;
  1020 + min-width: 0;
1029 font-size: 28rpx; 1021 font-size: 28rpx;
1030 color: #333; 1022 color: #333;
1031 - &.active {  
1032 - background: #fff;  
1033 - color: #007aff;  
1034 - }  
1035 - }  
1036 - }  
1037 - .right-content {  
1038 - flex: 1;  
1039 - background: #fff;  
1040 - padding: 20rpx;  
1041 - overflow-y: auto;  
1042 - .list-item {  
1043 - display: flex;  
1044 - align-items: center;  
1045 - gap:20rpx;  
1046 - padding:20rpx 0;  
1047 - border-bottom:1rpx solid #eee;  
1048 - .row-label {  
1049 - flex:1;  
1050 - font-size:28rpx;  
1051 - } 1023 + overflow: hidden;
  1024 + white-space: nowrap;
  1025 + text-overflow: ellipsis;
1052 } 1026 }
1053 - .tip-text {  
1054 - margin-top: 20rpx; 1027 + .file-del {
1055 font-size: 26rpx; 1028 font-size: 26rpx;
1056 - color:#999; 1029 + color: #ff4d4f;
1057 } 1030 }
1058 } 1031 }
1059 } 1032 }
1060 -.popup-footer {  
1061 - padding:24rpx;  
1062 -}  
1063 </style> 1033 </style>
1064 -  
1065 -  
1066 -