Commit dd60ac144bffb7fd015b2fcf24b385100c2e85b0

Authored by 王彪总
1 parent 6853427f

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

api/common.js
... ... @@ -86,6 +86,15 @@ export const workRecordSubmit = (data) => {
86 86 return post('/app-api/app/garden/work-record/submit', data);
87 87 };
88 88  
  89 +/**
  90 + * 按来源业务回显已填报的动态字段(工单详情「填报明细」)。
  91 + * @param {Object} params {bizType, bizId}
  92 + * @returns {Promise} List<ValueItem>:fieldCode/fieldName/fieldType/unit/valueText/text/materialId/materialName/quantity
  93 + */
  94 +export const getWorkRecordValues = (params) => {
  95 + return get('/app-api/app/garden/work-record/biz-values', params);
  96 +};
  97 +
89 98  
90 99  
91 100  
... ...
api/work-order-manage/work-order-manage.js
... ... @@ -127,5 +127,7 @@ export const getTaskActions = (taskId) =&gt; {
127 127 * @returns {Promise}
128 128 */
129 129 export const executeTaskAction = (data) => {
130   - return post('/app-api/workorder/task/action', data);
  130 + // 或签节点候选人较多(如养护组长 47 人)时,后端完成任务需逐个取消其余实例,耗时可能超过默认 10 秒
  131 + // 这里单独放宽超时,避免请求已在后端生效却被小程序判定为「网络失败」
  132 + return post('/app-api/workorder/task/action', data, { timeout: 120000 });
131 133 };
... ...
components/work-field-form/work-field-form.vue 0 → 100644
  1 +<template>
  2 + <view class="work-field-form">
  3 + <up-form label-position="left" :model="inspectForm" ref="inspectFormRef" :label-width="labelWidth">
  4 + <!-- ========== 动态表单区域 ========== -->
  5 + <template v-for="item in dynamicFields" :key="item.fieldCode">
  6 + <up-form-item :label="item.fieldName" :prop="item.fieldCode" class="form-item"
  7 + @click="openPickerIfNeeded(item)">
  8 +
  9 + <!-- type 1 数值:u-number-box,最多2位小数,min=0,max取item.length -->
  10 + <view v-if="item.fieldType === 1" class="number-box-wrap">
  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)" @blur="valChange(item)"
  14 + class="large-number-box">
  15 + </u-number-box>
  16 +
  17 + <text class="unit-text" v-if="item.unit">{{ item.unit }}</text>
  18 + </view>
  19 + <!-- type 2 文本 -->
  20 + <up-input v-else-if="item.fieldType === 2" v-model="inspectForm[item.fieldCode]"
  21 + :maxlength="item.length || 50" :placeholder="`请输入${item.fieldName}`"></up-input>
  22 + <!-- 分层选项(3枚举/6多选,且选项带 path):统一只读入口,点开分组弹层选择。
  23 + 必须放在下面的 3/6 分支之前——v-else-if 短路,放后面就永远进不来 -->
  24 + <up-input v-else-if="isGroupedField(item)" v-model="labelMap[item.fieldCode]" readonly
  25 + disabled-color="#ffffff" :placeholder="`请选择${item.fieldName}`" border="none">
  26 + <template #right>
  27 + <up-icon name="arrow-right" size="16"></up-icon>
  28 + </template>
  29 + </up-input>
  30 + <!-- type3 枚举:多选 multiple=1 使用复选框组(name 必须是 value,提交按逗号拼接 value,与值格式契约一致) -->
  31 + <u-checkbox-group v-else-if="item.fieldType === 3 && item.multiple === 1"
  32 + v-model="inspectForm[item.fieldCode]">
  33 + <u-checkbox v-for="opt in item.selectOptions" :key="opt.value" :label="opt.label" :name="opt.value">
  34 + </u-checkbox>
  35 + </u-checkbox-group>
  36 + <!-- type3 枚举:单选 用up-action-sheet 底部选择 -->
  37 + <up-input v-else-if="item.fieldType === 3" v-model="labelMap[item.fieldCode]" readonly
  38 + disabled-color="#ffffff" :placeholder="`请选择${item.fieldName}`" border="none">
  39 + <template #right>
  40 + <up-icon name="arrow-right" size="16"></up-icon>
  41 + </template>
  42 + </up-input>
  43 + <!-- type4 日期时间 -->
  44 + <view v-else-if="item.fieldType === 4" @click.stop="openDatePicker(item)" class="date-input-wrap">
  45 + <up-input v-model="inspectForm[item.fieldCode]" disabled placeholder="点击选择时间"></up-input>
  46 + </view>
  47 + <!-- type5 布尔单选 是/否 -->
  48 + <up-radio-group v-else-if="item.fieldType === 5" v-model="inspectForm[item.fieldCode]">
  49 + <up-radio label="是" name="是"></up-radio>
  50 + <up-radio label="否" name="否"></up-radio>
  51 + </up-radio-group>
  52 + <!-- type6 多选:复选框组;选项来源可为静态/字典/物料/数据表/SQL/接口 -->
  53 + <view v-else-if="item.fieldType === 6" class="multi-wrap">
  54 + <u-checkbox-group v-model="inspectForm[item.fieldCode]" @change="onMultiChange(item)">
  55 + <u-checkbox v-for="opt in item.selectOptions" :key="opt.value" :label="opt.label" :name="opt.value"
  56 + :disabled="isOptionLocked(item, opt)">
  57 + </u-checkbox>
  58 + </u-checkbox-group>
  59 + <!-- 有限上限时提示已选数。上限靠「禁用未选中项」实现 -->
  60 + <text v-if="multiHint(item)" class="multi-hint">{{ multiHint(item) }}</text>
  61 + <!-- needQuantity:已选项各配一个数量输入,值按 {value,label,quantity} 提交。此形态仅记录用量、不扣库存 -->
  62 + <view v-if="needQty(item)" class="qty-list">
  63 + <view v-for="opt in checkedOptions(item)" :key="opt.value" class="qty-row">
  64 + <text class="qty-label">{{ opt.label }}</text>
  65 + <u-number-box v-model="qtyMap[item.fieldCode][opt.value]" :min="0" :step="1"></u-number-box>
  66 + </view>
  67 + </view>
  68 + </view>
  69 + <!-- type7 图片:多张上传,提交 JSON 数组(URL) -->
  70 + <up-upload v-else-if="item.fieldType === 7" :file-list="fileListMap[item.fieldCode] || []"
  71 + @after-read="(event) => uploadFieldFiles(item, event)" @delete="(event) => deleteFieldFile(item, event)"
  72 + multiple width="70" height="70" :sizeType="['compressed']" :max-count="pickCountOf(item, 5)"
  73 + upload-text="选择图片" del-color="#ff4d4f" class="upload-wrap"></up-upload>
  74 +
  75 + <!-- type8 附件:微信只能从聊天记录选文件(无通用文件选择器),提交 JSON 数组(URL) -->
  76 + <view v-else-if="item.fieldType === 8" class="file-wrap">
  77 + <view v-for="(file, idx) in fileListMap[item.fieldCode] || []" :key="idx" class="file-row">
  78 + <text class="file-name">{{ file.name || fileNameOf(file.url) }}</text>
  79 + <text class="file-del" @click.stop="deleteFieldFile(item, { index: idx })">删除</text>
  80 + </view>
  81 + <up-button size="small" type="primary" plain :text="fileButtonText(item)"
  82 + @click.stop="chooseFieldFiles(item)"></up-button>
  83 + </view>
  84 + <!-- type9 关联记录:底部选择,提交 JSON 数组 [{id,name}] -->
  85 + <up-input v-else-if="item.fieldType === 9" 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 + <!-- type10 链接:URL 输入,提交裸 URL -->
  92 + <up-input v-else-if="item.fieldType === 10" v-model.trim="inspectForm[item.fieldCode]"
  93 + :placeholder="`请输入${item.fieldName}(http/https 开头)`"></up-input>
  94 + <!-- type11 地理位置:uni.chooseLocation 选点,提交 JSON 对象 {lng,lat,address} -->
  95 + <up-input v-else-if="item.fieldType === 11" v-model="labelMap[item.fieldCode]" readonly
  96 + disabled-color="#ffffff" :placeholder="`点击选择${item.fieldName}`" border="none">
  97 + <template #right>
  98 + <up-icon name="arrow-right" size="16"></up-icon>
  99 + </template>
  100 + </up-input>
  101 + <!-- 物料(fieldType=3 + optionSource=3):额外收领用数量;数量>0 才提交 materialId/materialName/quantity 触发扣库存。
  102 + 必须放在 v-if/v-else-if 链末尾——插在中间会打断链导致编译报错 -->
  103 + <view v-if="item.fieldType === 3 && item.optionSource === 3" class="qty-row qty-row-material">
  104 + <text class="qty-label">领用数量</text>
  105 + <u-number-box v-model="qtyMap[item.fieldCode]" :min="0" :step="1"></u-number-box>
  106 + </view>
  107 + </up-form-item>
  108 + </template>
  109 + <!-- ========== 动态表单结束 ========== -->
  110 + </up-form>
  111 +
  112 + <!-- uview-plus 日期时间选择器(全局复用) -->
  113 + <up-datetime-picker :show="datePickerShow" v-model="datePickerValue" mode="datetime" close-on-click-overlay
  114 + @confirm="onDateConfirm" @cancel="onDateClose" @close="onDateClose"></up-datetime-picker>
  115 + <!-- ActionSheet 底部选择弹窗 -->
  116 + <up-action-sheet :show="actionSheet.show" :actions="actionSheet.options" @select="onActionSheetSelect"
  117 + @close="actionSheet.show = false" cancel-text="取消"></up-action-sheet>
  118 + <!-- 分层选项选择器(选项带 path 的动态字段) -->
  119 + <grouped-picker :show="groupPicker.show" :title="groupPicker.title" :options="groupPicker.options"
  120 + :multiple="groupPicker.multiple" :max-count="groupPicker.maxCount" :need-quantity="groupPicker.needQuantity"
  121 + :value="groupPicker.value" :container-width="groupPicker.containerWidth" :quantities="groupPicker.quantities"
  122 + @update:show="groupPicker.show = $event" @confirm="onGroupedConfirm"></grouped-picker>
  123 + </view>
  124 +</template>
  125 +
  126 +<script>
  127 +import { uploadImages } from '@/common/utils/upload'
  128 +import { getByType, getOptionsByCode, getMaterialByCode } from '@/api/common'
  129 +
  130 +/**
  131 + * 动态字段表单(业务类型字段配置驱动)
  132 + *
  133 + * 数据来源:数据中心 / 业务类型字段配置(按 businessType + bizType + workTypeId 定位分类与版本)。
  134 + * 使用方式:
  135 + * <work-field-form ref="workFieldFormRef" :business-type="bizLine" :biz-type="1" :work-type-id="workTypeId"
  136 + * @loaded="onFieldsLoaded" />
  137 + * 父页面通过 ref 调用 validate() / getValues() 完成校验与提交体组装。
  138 + *
  139 + * 说明:businessType / bizType / workTypeId 任一变化都会重新加载;bizType 1=工单 2=道路 3=养护。
  140 + */
  141 +export default {
  142 + name: 'WorkFieldForm',
  143 + props: {
  144 + // 业务类型(园林养护数据口径):yl园林 / hj环卫 / sz市政 / zx / wy
  145 + businessType: { type: String, default: '' },
  146 + // 来源:1工单 / 2道路 / 3养护
  147 + bizType: { type: Number, default: 1 },
  148 + // 作业类型(dict: maintain_type)
  149 + workTypeId: { type: [String, Number], default: '' },
  150 + // 与父表单对齐的标签宽度
  151 + labelWidth: { type: String, default: '170rpx' }
  152 + },
  153 + data() {
  154 + return {
  155 + inspectForm: {},
  156 + // 用来存放展示文本label的映射对象
  157 + labelMap: {},
  158 + // 类型7图片/8附件的每字段文件列表(fieldCode -> u-upload 兼容数组 {url,status,name})
  159 + fileListMap: {},
  160 + // 数量收集:类型6+needQuantity 为 {选项值: 数量};类型3物料为标量(领用数量)
  161 + qtyMap: {},
  162 + // 动态字段列表
  163 + dynamicFields: [],
  164 + // 必填规则(随字段配置动态生成)
  165 + inspectFormRules: {},
  166 + // 日期时间选择器
  167 + datePickerShow: false,
  168 + datePickerValue: Number(new Date()),
  169 + currentDateFieldCode: '',
  170 + // actionSheet配置
  171 + actionSheet: {
  172 + show: false,
  173 + fieldCode: '',
  174 + options: [],
  175 + fieldItem: null
  176 + },
  177 + // 分层选项选择器配置(选项带 path 的 3/6 字段用,其余字段仍走上面的 actionSheet / 内联复选框)
  178 + groupPicker: {
  179 + show: false,
  180 + fieldCode: '',
  181 + fieldItem: null,
  182 + title: '',
  183 + options: [],
  184 + multiple: false,
  185 + maxCount: Infinity,
  186 + needQuantity: false,
  187 + value: '',
  188 + quantities: {},
  189 + containerWidth: 0
  190 + }
  191 + }
  192 + },
  193 + watch: {
  194 + businessType() {
  195 + this.reload()
  196 + },
  197 + workTypeId() {
  198 + this.reload()
  199 + }
  200 + },
  201 + mounted() {
  202 + this.reload()
  203 + },
  204 + methods: {
  205 + /**
  206 + * 重新加载字段配置(作业类型切换时复用同一实例)
  207 + */
  208 + reload() {
  209 + if (!this.businessType || !this.workTypeId) {
  210 + this.dynamicFields = []
  211 + this.$emit('loaded', 0)
  212 + return
  213 + }
  214 + this.resetForm()
  215 + this.loadDynamicFields(this.businessType, this.workTypeId)
  216 + },
  217 + resetForm() {
  218 + this.inspectForm = {}
  219 + this.labelMap = {}
  220 + this.fileListMap = {}
  221 + this.qtyMap = {}
  222 + this.dynamicFields = []
  223 + this.inspectFormRules = {}
  224 + },
  225 + // ========== 对外方法 ==========
  226 + /** 表单校验,失败时 reject 字段数组(与 uview-plus 一致) */
  227 + validate() {
  228 + const formRef = this.$refs.inspectFormRef
  229 + if (!formRef) {
  230 + return Promise.resolve()
  231 + }
  232 + return formRef.validate()
  233 + },
  234 + /** 组装 fields[] 提交体(空值不出现;物料数量>0 才带物料三列) */
  235 + getValues() {
  236 + return this.buildDynamicFields()
  237 + },
  238 + /** 当前是否有可填字段 */
  239 + hasFields() {
  240 + return this.dynamicFields.length > 0
  241 + },
  242 + // number-box数值变更回调
  243 + valChange(item) {
  244 + this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(item.fieldCode)
  245 + },
  246 + // ========== 日期时间相关方法 ==========
  247 + openDatePicker(field) {
  248 + this.currentDateFieldCode = field.fieldCode
  249 + const val = this.inspectForm[field.fieldCode]
  250 + this.datePickerValue = val ? new Date(val).getTime() : Number(new Date())
  251 + this.datePickerShow = true
  252 + uni.hideKeyboard()
  253 + },
  254 + onDateConfirm(e) {
  255 + const ts = e.value
  256 + const d = new Date(ts)
  257 + const y = d.getFullYear()
  258 + const m = String(d.getMonth() + 1).padStart(2, '0')
  259 + const day = String(d.getDate()).padStart(2, '0')
  260 + const h = String(d.getHours()).padStart(2, '0')
  261 + const mi = String(d.getMinutes()).padStart(2, '0')
  262 + const s = String(d.getSeconds()).padStart(2, '0')
  263 + this.inspectForm[this.currentDateFieldCode] = `${y}-${m}-${day} ${h}:${mi}:${s}`
  264 + this.datePickerShow = false
  265 + this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(this.currentDateFieldCode)
  266 + },
  267 + onDateClose() {
  268 + this.datePickerShow = false
  269 + },
  270 + /**
  271 + * 点击表单项时按类型决定弹出哪个选择器
  272 + */
  273 + openPickerIfNeeded(item) {
  274 + if (this.isGroupedField(item)) {
  275 + return this.openGroupedPicker(item)
  276 + }
  277 + // 模板用 multiple === 1 渲染复选框组,这里就必须用 !== 1 才打开弹窗
  278 + if (item.fieldType === 3 && item.multiple !== 1) {
  279 + return this.openActionSheet(item)
  280 + }
  281 + if (item.fieldType === 9) {
  282 + return this.openActionSheet(item)
  283 + }
  284 + if (item.fieldType === 11) {
  285 + return this.openLocation(item)
  286 + }
  287 + },
  288 + /** 是否为「分层选项」字段:3枚举/6多选,且选项带了后端下发的 path */
  289 + isGroupedField(item) {
  290 + if (!item || (item.fieldType !== 3 && item.fieldType !== 6)) {
  291 + return false
  292 + }
  293 + return (item.selectOptions || []).some((opt) => Array.isArray(opt.path) && opt.path.length > 0)
  294 + },
  295 + /** 打开分层选项弹层 */
  296 + openGroupedPicker(item) {
  297 + const code = item.fieldCode
  298 + const multiple = item.fieldType === 6 || item.multiple === 1
  299 + this.groupPicker.fieldCode = code
  300 + this.groupPicker.fieldItem = item
  301 + this.groupPicker.title = item.fieldName
  302 + this.groupPicker.options = item.selectOptions || []
  303 + this.groupPicker.multiple = multiple
  304 + this.groupPicker.maxCount = this.maxCountOf(item, Infinity)
  305 + // 只有 6 多选需要每项数量;3 枚举没有这层语义
  306 + this.groupPicker.needQuantity = item.fieldType === 6 && this.needQty(item)
  307 + this.groupPicker.value = multiple ? (this.inspectForm[code] || []).slice() : (this.inspectForm[code] || '')
  308 + this.groupPicker.quantities = Object.assign({}, this.qtyMap[code] || {})
  309 + // 打开弹窗瞬间获取父容器宽度
  310 + const query = uni.createSelectorQuery().in(this)
  311 + query.select('.work-field-form').boundingClientRect((rect) => {
  312 + if (rect && rect.width) {
  313 + this.groupPicker.containerWidth = rect.width
  314 + }
  315 + this.groupPicker.show = true
  316 + }).exec()
  317 + },
  318 + /** 分层弹层确定回调 */
  319 + onGroupedConfirm(payload) {
  320 + const code = this.groupPicker.fieldCode
  321 + this.inspectForm[code] = payload.values
  322 + this.labelMap[code] = payload.labelText
  323 + if (this.groupPicker.needQuantity) {
  324 + // 合并而不是替换:保留 loadDynamicFields 预建的选项键
  325 + this.qtyMap[code] = Object.assign({}, this.qtyMap[code] || {}, payload.quantities || {})
  326 + }
  327 + this.groupPicker.show = false
  328 + this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(code)
  329 + },
  330 + /** 打开底部选择弹窗 actionSheet */
  331 + openActionSheet(item) {
  332 + this.actionSheet.fieldCode = item.fieldCode
  333 + this.actionSheet.fieldItem = item
  334 + // 物料带 currentNum 时在展示名后附库存余量(只影响展示名,提交的 materialName 仍取干净的 opt.label)
  335 + this.actionSheet.options = (item.selectOptions || []).map((opt) => {
  336 + const stock = opt.currentNum === null || opt.currentNum === undefined || opt.currentNum === ''
  337 + ? NaN
  338 + : Number(opt.currentNum)
  339 + return {
  340 + name: isNaN(stock) ? opt.label : `${opt.label}(库存${stock}${opt.unit || ''})`,
  341 + value: opt.value
  342 + }
  343 + })
  344 + this.actionSheet.show = true
  345 + },
  346 + /** actionSheet选中回调 */
  347 + onActionSheetSelect(selectedItem) {
  348 + const fieldItem = this.actionSheet.fieldItem
  349 + const fieldCode = this.actionSheet.fieldCode
  350 + // 9 关联记录存 {id,name}(提交时转成 [{id,name}]),其余存裸 value
  351 + if (fieldItem && fieldItem.fieldType === 9) {
  352 + this.inspectForm[fieldCode] = { id: selectedItem.value, name: selectedItem.name }
  353 + } else {
  354 + this.inspectForm[fieldCode] = selectedItem.value
  355 + }
  356 + this.labelMap[fieldCode] = selectedItem.name
  357 + this.actionSheet.show = false
  358 + this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(fieldCode)
  359 + },
  360 + /** 类型11 地理位置:微信地图选点 */
  361 + openLocation(item) {
  362 + uni.chooseLocation({
  363 + success: (res) => {
  364 + this.inspectForm[item.fieldCode] = {
  365 + lng: res.longitude,
  366 + lat: res.latitude,
  367 + address: res.address || res.name || ''
  368 + }
  369 + this.labelMap[item.fieldCode] = res.address || res.name || `${res.latitude}, ${res.longitude}`
  370 + this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(item.fieldCode)
  371 + },
  372 + fail: (err) => {
  373 + console.error('地图选择失败', err)
  374 + if (err && err.errMsg && err.errMsg.indexOf('auth deny') > -1) {
  375 + uni.showToast({ title: '请授权位置权限', icon: 'none' })
  376 + }
  377 + }
  378 + })
  379 + },
  380 + /**
  381 + * 加载动态字段
  382 + */
  383 + async loadDynamicFields(businessType, workTypeId) {
  384 + try {
  385 + const res = await getByType({
  386 + businessType,
  387 + workTypeId,
  388 + bizType: this.bizType
  389 + })
  390 + let fields = res.fields || []
  391 + fields.sort((a, b) => a.sort - b.sort)
  392 + if (fields.length === 0) {
  393 + this.dynamicFields = []
  394 + this.$emit('loaded', 0)
  395 + return
  396 + }
  397 + for (const field of fields) {
  398 + // 初值:多选为数组;9关联记录/11地理位置 为对象(未选为 null);7图片/8附件为 URL 数组;其余空字符串
  399 + if (field.fieldType === 6 || (field.fieldType === 3 && field.multiple === 1)) {
  400 + this.inspectForm[field.fieldCode] = []
  401 + } else if (field.fieldType === 7 || field.fieldType === 8) {
  402 + this.inspectForm[field.fieldCode] = []
  403 + } else if (field.fieldType === 9 || field.fieldType === 11) {
  404 + this.inspectForm[field.fieldCode] = null
  405 + } else {
  406 + this.inspectForm[field.fieldCode] = ''
  407 + }
  408 + this.labelMap[field.fieldCode] = ''
  409 + if (field.fieldType === 7 || field.fieldType === 8) {
  410 + this.fileListMap[field.fieldCode] = []
  411 + }
  412 + // 选项装载:静态来源(1)直接用下发的 field.options,其余来源(2字典/3物料/4数据表/5SQL/6接口)调 options 接口
  413 + if (field.fieldType === 3 || field.fieldType === 6 || field.fieldType === 9) {
  414 + if (field.optionSource === 1 && Array.isArray(field.options)) {
  415 + field.selectOptions = field.options.map((opt) => ({ label: opt.label, value: String(opt.value), path: opt.path }))
  416 + } else if (field.optionSource === 3) {
  417 + const matRes = await getMaterialByCode({
  418 + businessType,
  419 + bizType: this.bizType,
  420 + workTypeId,
  421 + fieldCode: field.fieldCode
  422 + })
  423 + field.selectOptions = (matRes || []).map((mat) => ({
  424 + label: mat.materialName,
  425 + value: String(mat.materialId),
  426 + currentNum: mat.currentNum,
  427 + unit: mat.unit
  428 + }))
  429 + } else {
  430 + const optRes = await getOptionsByCode({
  431 + businessType,
  432 + bizType: this.bizType,
  433 + workTypeId,
  434 + fieldCode: field.fieldCode
  435 + })
  436 + field.selectOptions = (optRes || []).map((opt) => ({ label: opt.label, value: String(opt.value), path: opt.path }))
  437 + }
  438 + }
  439 + // 数量收集初始化(必须在 selectOptions 装载之后)
  440 + if (field.fieldType === 6 && this.needQty(field)) {
  441 + const qty = {}
  442 + ;(field.selectOptions || []).forEach((opt) => {
  443 + qty[opt.value] = ''
  444 + })
  445 + this.qtyMap[field.fieldCode] = qty
  446 + }
  447 + if (field.fieldType === 3 && field.optionSource === 3) {
  448 + this.qtyMap[field.fieldCode] = 0
  449 + }
  450 + // 必填规则
  451 + if (field.required === 1) {
  452 + this.inspectFormRules[field.fieldCode] = [this.buildRequiredRule(field)]
  453 + }
  454 + }
  455 + this.dynamicFields = fields
  456 + this.$nextTick(() => {
  457 + this.$refs.inspectFormRef && this.$refs.inspectFormRef.setRules(this.inspectFormRules)
  458 + })
  459 + this.$emit('loaded', fields.length)
  460 + } catch (err) {
  461 + console.error('获取动态表单字段失败:', err)
  462 + uni.showToast({ title: '加载表单配置失败', icon: 'none' })
  463 + this.$emit('loaded', 0)
  464 + }
  465 + },
  466 + // ========== 动态字段:渲染与取值辅助 ==========
  467 + /** 类型6 多选是否要求每项填「使用数量」(ext_config.needQuantity) */
  468 + needQty(item) {
  469 + return !!(item.extConfig && item.extConfig.needQuantity === true)
  470 + },
  471 + /** 有效上限:ext_config.maxCount(0 表示不限) */
  472 + maxCountOf(item, defaultValue) {
  473 + const raw = item.extConfig ? item.extConfig.maxCount : undefined
  474 + if (raw === undefined || raw === null || raw === '') {
  475 + return defaultValue
  476 + }
  477 + const num = Number(raw)
  478 + if (isNaN(num)) {
  479 + return defaultValue
  480 + }
  481 + return num > 0 ? num : Infinity
  482 + },
  483 + /** 传给选择器的单次可选数量:上限为「不限」时用组件默认值 52 */
  484 + pickCountOf(item, defaultValue) {
  485 + const max = this.maxCountOf(item, defaultValue)
  486 + return max === Infinity ? 52 : max
  487 + },
  488 + /** 单文件大小上限(MB):ext_config.maxSizeMb */
  489 + maxSizeMbOf(item, defaultValue) {
  490 + const num = item.extConfig ? Number(item.extConfig.maxSizeMb) : NaN
  491 + return num > 0 ? num : defaultValue
  492 + },
  493 + /** 类型8 允许的扩展名数组(ext_config.fileType,不含点) */
  494 + extensionsOf(item) {
  495 + const list = item.extConfig && item.extConfig.fileType
  496 + return Array.isArray(list) ? list : []
  497 + },
  498 + fileButtonText(item) {
  499 + const exts = this.extensionsOf(item)
  500 + return exts.length ? `选择文件(${exts.join('/')})` : '选择文件'
  501 + },
  502 + /** 从 URL 取末段当文件名 */
  503 + fileNameOf(url) {
  504 + if (!url) return ''
  505 + const path = String(url).split('?')[0].split('#')[0]
  506 + const name = path.substring(path.lastIndexOf('/') + 1)
  507 + try {
  508 + return decodeURIComponent(name) || url
  509 + } catch (err) {
  510 + return name || url
  511 + }
  512 + },
  513 + /** 类型6 已勾选的选项 */
  514 + checkedOptions(item) {
  515 + const values = this.inspectForm[item.fieldCode] || []
  516 + return (item.selectOptions || []).filter((opt) => values.indexOf(opt.value) > -1)
  517 + },
  518 + /** 多选是否达到上限(用于禁用未选中项) */
  519 + isOptionLocked(item, opt) {
  520 + const max = this.maxCountOf(item, Infinity)
  521 + if (max === Infinity) {
  522 + return false
  523 + }
  524 + const values = this.inspectForm[item.fieldCode] || []
  525 + return values.length >= max && values.indexOf(opt.value) === -1
  526 + },
  527 + /** 多选的「已选 x/上限」提示,不限时返回空串 */
  528 + multiHint(item) {
  529 + const max = this.maxCountOf(item, Infinity)
  530 + if (max === Infinity) {
  531 + return ''
  532 + }
  533 + return `已选 ${(this.inspectForm[item.fieldCode] || []).length}/${max}`
  534 + },
  535 + onMultiChange(item) {
  536 + this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(item.fieldCode)
  537 + },
  538 + /** 必填规则:7/8/9/11 的值是数组或对象,必须用 validator 显式判空 */
  539 + buildRequiredRule(field) {
  540 + const type = field.fieldType
  541 + if (type === 6 || type === 7 || type === 8) {
  542 + const action = type === 6 ? '请选择' : '请上传'
  543 + return {
  544 + required: true,
  545 + trigger: ['change', 'blur'],
  546 + validator: (rule, value, callback) => {
  547 + Array.isArray(value) && value.length > 0 ? callback() : callback(new Error(`${action}${field.fieldName}`))
  548 + }
  549 + }
  550 + }
  551 + if (type === 9 || type === 11) {
  552 + return {
  553 + required: true,
  554 + trigger: ['change', 'blur'],
  555 + validator: (rule, value, callback) => {
  556 + value ? callback() : callback(new Error(type === 9 ? `请选择${field.fieldName}` : `请选择${field.fieldName}的位置`))
  557 + }
  558 + }
  559 + }
  560 + if (type === 1) {
  561 + return {
  562 + required: true,
  563 + trigger: ['change', 'blur'],
  564 + validator: (rule, value, callback) => {
  565 + // 空字符串、null、undefined 才是空;数字 0 合法
  566 + if (value === '' || value === null || value === undefined) {
  567 + callback(new Error(`请填写${field.fieldName}`))
  568 + } else {
  569 + callback()
  570 + }
  571 + }
  572 + }
  573 + }
  574 + // 2文本 /4日期 /5布尔 /10链接
  575 + const action = type === 3 || type === 5 ? '请选择' : '请填写'
  576 + return {
  577 + required: true,
  578 + message: `${action}${field.fieldName}`,
  579 + trigger: ['change', 'blur']
  580 + }
  581 + },
  582 + // ========== 动态字段:7图片/8附件 ==========
  583 + /** 单文件是否超过 ext_config.maxSizeMb */
  584 + isOversize(file, maxSizeMb) {
  585 + if (!maxSizeMb || !file || !file.size) return false
  586 + const over = file.size / 1024 / 1024 > maxSizeMb
  587 + if (over) {
  588 + uni.showToast({ title: `单个文件不能超过${maxSizeMb}MB`, icon: 'none' })
  589 + }
  590 + return over
  591 + },
  592 + /** 把已上传成功的 URL 同步进表单值(提交与校验都读 inspectForm) */
  593 + syncFileValue(item) {
  594 + const list = this.fileListMap[item.fieldCode] || []
  595 + this.inspectForm[item.fieldCode] = list.filter((file) => file.status === 'success').map((file) => file.url)
  596 + },
  597 + /** 7图片 上传(u-upload 的 after-read 回调) */
  598 + uploadFieldFiles(item, event) {
  599 + const rawFile = event && event.file
  600 + const fileList = Array.isArray(rawFile) ? rawFile : [rawFile]
  601 + this.uploadToField(item, fileList.filter((file) => file && file.url).map((file) => ({
  602 + url: file.url,
  603 + name: file.name || '',
  604 + size: file.size
  605 + })))
  606 + },
  607 + /**
  608 + * 8附件 选择并上传。
  609 + * 微信小程序没有通用文件选择器,只能从聊天记录里选(uni.chooseMessageFile)
  610 + */
  611 + chooseFieldFiles(item) {
  612 + const maxCount = this.maxCountOf(item, 5)
  613 + const remain = maxCount - (this.fileListMap[item.fieldCode] || []).length
  614 + if (remain <= 0) {
  615 + uni.showToast({ title: `最多只能上传${maxCount}个`, icon: 'none' })
  616 + return
  617 + }
  618 + const exts = this.extensionsOf(item)
  619 + const options = { count: Math.min(remain, this.pickCountOf(item, 5)), type: 'file' }
  620 + if (exts.length) {
  621 + options.extension = exts
  622 + }
  623 + uni.chooseMessageFile({
  624 + ...options,
  625 + success: (res) => {
  626 + const tempFiles = res.tempFiles || []
  627 + this.uploadToField(item, tempFiles.map((file) => ({
  628 + url: file.path,
  629 + name: file.name || '',
  630 + size: file.size
  631 + })))
  632 + },
  633 + fail: (err) => {
  634 + console.error('选择附件失败:', err)
  635 + }
  636 + })
  637 + },
  638 + /** 把选中的本地文件逐个上传并把 http 地址回填到 fileListMap / inspectForm */
  639 + async uploadToField(item, files) {
  640 + const code = item.fieldCode
  641 + const maxCount = this.maxCountOf(item, 5)
  642 + const maxSizeMb = this.maxSizeMbOf(item, 5)
  643 + const validFiles = (files || []).filter((file) => file && file.url && !this.isOversize(file, maxSizeMb))
  644 + if (!validFiles.length) {
  645 + return
  646 + }
  647 + const targetList = this.fileListMap[code] || (this.fileListMap[code] = [])
  648 + if (targetList.length + validFiles.length > maxCount) {
  649 + uni.showToast({ title: `最多只能上传${maxCount}个`, icon: 'none' })
  650 + return
  651 + }
  652 + const startIndex = targetList.length
  653 + targetList.push(...validFiles.map((file) => ({
  654 + url: file.url, status: 'uploading', message: '上传中', name: file.name || ''
  655 + })))
  656 + try {
  657 + const uploadResultUrls = await uploadImages({
  658 + filePaths: validFiles.map((file) => file.url),
  659 + ignoreError: true
  660 + })
  661 + uploadResultUrls.forEach((url, index) => {
  662 + if (targetList[startIndex + index]) {
  663 + targetList.splice(startIndex + index, 1, {
  664 + url, status: 'success', message: '', name: validFiles[index].name || ''
  665 + })
  666 + }
  667 + })
  668 + for (let i = uploadResultUrls.length; i < validFiles.length; i++) {
  669 + if (targetList[startIndex + i]) {
  670 + targetList.splice(startIndex + i, 1, {
  671 + url: validFiles[i].url, status: 'failed', message: '上传失败', name: validFiles[i].name || ''
  672 + })
  673 + }
  674 + }
  675 + if (uploadResultUrls.length < validFiles.length) {
  676 + uni.showToast({
  677 + title: `成功${uploadResultUrls.length}个,失败${validFiles.length - uploadResultUrls.length}个`,
  678 + icon: 'none'
  679 + })
  680 + }
  681 + } catch (err) {
  682 + console.error(`【${code}】文件上传失败:`, err)
  683 + for (let i = 0; i < validFiles.length; i++) {
  684 + if (targetList[startIndex + i]) {
  685 + targetList.splice(startIndex + i, 1, {
  686 + url: validFiles[i].url, status: 'failed', message: '上传失败', name: validFiles[i].name || ''
  687 + })
  688 + }
  689 + }
  690 + uni.showToast({ title: '上传失败,请重试', icon: 'none' })
  691 + }
  692 + this.syncFileValue(item)
  693 + this.$refs.inspectFormRef && this.$refs.inspectFormRef.validateField(code)
  694 + },
  695 + /** 删除 7图片/8附件 的某一个已选文件 */
  696 + deleteFieldFile(item, event) {
  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 && this.$refs.inspectFormRef.validateField(item.fieldCode)
  705 + },
  706 + // ========== 动态字段:组装提交体 ==========
  707 + /**
  708 + * 按值格式契约组装 fields[]。
  709 + * ① 空值一律不出现在 fields[] 里(不提交 ''/'[]'/'null')
  710 + * ② 物料的数量>0 才写 materialId/materialName/quantity —— 非空 materialId 即触发扣库存
  711 + */
  712 + buildDynamicFields() {
  713 + const fields = []
  714 + for (const item of this.dynamicFields) {
  715 + const code = item.fieldCode
  716 + const value = this.inspectForm[code]
  717 + const type = item.fieldType
  718 + if (type === 9 || type === 11) {
  719 + if (!value) continue
  720 + } else if (type === 6 || type === 7 || type === 8) {
  721 + if (!Array.isArray(value) || value.length === 0) continue
  722 + } else if (value === '' || value === null || value === undefined) {
  723 + continue
  724 + }
  725 +
  726 + if (type === 1) {
  727 + fields.push({ fieldCode: code, valueNumber: Number(value) })
  728 + } else if (type === 3 && item.optionSource === 3) {
  729 + // 物料:value_text 存物料ID;数量>0 才落物料三列(0 表示本次不领用)
  730 + const qty = Number(this.qtyMap[code] || 0)
  731 + const matched = (item.selectOptions || []).filter((opt) => opt.value === String(value))[0]
  732 + const row = { fieldCode: code, valueText: String(value) }
  733 + if (qty > 0) {
  734 + row.materialId = Number(value)
  735 + row.materialName = matched ? matched.label : ''
  736 + row.quantity = qty
  737 + }
  738 + fields.push(row)
  739 + } else if (type === 3) {
  740 + // 3枚举:单选取裸 value;历史「枚举+是否多选」存逗号分隔
  741 + fields.push({
  742 + fieldCode: code,
  743 + valueText: item.multiple === 1
  744 + ? (Array.isArray(value) ? value.join(',') : String(value || ''))
  745 + : 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 键,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 = { 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 +</script>
  785 +
  786 +<style lang="scss" scoped>
  787 +.work-field-form {
  788 + width: 100%;
  789 +}
  790 +
  791 +.date-input-wrap {
  792 + width: 100%;
  793 +}
  794 +
  795 +// 放大u-number-box输入框
  796 +.large-number-box {
  797 + :deep(.u-number-box__input) {
  798 + width: 264rpx !important;
  799 + height: 72rpx;
  800 + font-size: 32rpx;
  801 + }
  802 +
  803 + :deep(.u-number-box__btn) {
  804 + width: 64rpx;
  805 + height: 72rpx;
  806 + }
  807 +}
  808 +
  809 +.number-box-wrap {
  810 + display: flex;
  811 + align-items: center;
  812 + gap: 16rpx;
  813 +
  814 + .unit-text {
  815 + font-size: 28rpx;
  816 + color: #333;
  817 + }
  818 +}
  819 +
  820 +// 6多选:复选框组 + needQuantity 时每个已选项一行数量输入
  821 +.multi-wrap {
  822 + width: 100%;
  823 +
  824 + .multi-hint {
  825 + display: block;
  826 + margin-top: 8rpx;
  827 + font-size: 24rpx;
  828 + color: #909399;
  829 + }
  830 +
  831 + .qty-list {
  832 + margin-top: 16rpx;
  833 + padding-left: 20rpx;
  834 + border-left: 4rpx solid #f0f0f0;
  835 + }
  836 +}
  837 +
  838 +// 数量行(6多选的每个已选项 / 3物料的领用数量共用)
  839 +.qty-row {
  840 + display: flex;
  841 + align-items: center;
  842 + justify-content: space-between;
  843 + gap: 16rpx;
  844 + padding: 8rpx 0;
  845 +
  846 + .qty-label {
  847 + flex: 1;
  848 + min-width: 0;
  849 + font-size: 28rpx;
  850 + color: #333;
  851 + }
  852 +}
  853 +
  854 +.qty-row-material {
  855 + margin-top: 12rpx;
  856 + padding-top: 12rpx;
  857 + border-top: 1rpx solid #f0f0f0;
  858 +}
  859 +
  860 +// 8附件:自绘文件列表 + 选择按钮
  861 +.file-wrap {
  862 + width: 100%;
  863 +
  864 + .file-row {
  865 + display: flex;
  866 + align-items: center;
  867 + justify-content: space-between;
  868 + gap: 16rpx;
  869 + padding: 8rpx 0;
  870 +
  871 + .file-name {
  872 + flex: 1;
  873 + min-width: 0;
  874 + font-size: 28rpx;
  875 + color: #333;
  876 + overflow: hidden;
  877 + white-space: nowrap;
  878 + text-overflow: ellipsis;
  879 + }
  880 +
  881 + .file-del {
  882 + font-size: 26rpx;
  883 + color: #ff4d4f;
  884 + }
  885 + }
  886 +}
  887 +</style>
... ...
pages-sub/problem/work-order-manage/add-maintain-order.vue
... ... @@ -20,7 +20,27 @@
20 20 ></up-input>
21 21 </up-form-item>
22 22  
23   - <!-- 2. 共同处理人(支持多选) -->
  23 + <!-- 2. 作业类型(决定加载哪套动态字段配置:数据中心/业务类型字段配置 配置归属=工单) -->
  24 + <up-form-item
  25 + label="作业类型"
  26 + prop="workTypeName"
  27 + required
  28 + border-bottom
  29 + @click="openWorkTypeSheet(); hideKeyboard()"
  30 + >
  31 + <up-input
  32 + v-model="workOrderForm.workTypeName"
  33 + disabled
  34 + disabled-color="#ffffff"
  35 + placeholder="请选择作业类型"
  36 + border="none"
  37 + ></up-input>
  38 + <template #right>
  39 + <up-icon name="arrow-right" size="16"></up-icon>
  40 + </template>
  41 + </up-form-item>
  42 +
  43 + <!-- 3. 共同处理人(支持多选) -->
24 44 <up-form-item
25 45 label="共同处理人"
26 46 prop="coProcessorName"
... ... @@ -55,6 +75,21 @@
55 75 ></up-textarea>
56 76 </up-form-item>
57 77  
  78 + <!-- 4. 动态字段:按「业务类型(busiLine) + 作业类型」加载数据中心配置的工单填报项 -->
  79 + <view class="dynamic-field-block" v-if="workOrderForm.workTypeId">
  80 + <view class="dynamic-field-title">作业填报</view>
  81 + <work-field-form
  82 + ref="workFieldFormRef"
  83 + :business-type="workOrderForm.businessType"
  84 + :biz-type="1"
  85 + :work-type-id="workOrderForm.workTypeId"
  86 + @loaded="onDynamicFieldsLoaded"
  87 + ></work-field-form>
  88 + <view class="dynamic-field-empty" v-if="dynamicFieldCount === 0">
  89 + <text>该作业类型暂无需填报项</text>
  90 + </view>
  91 + </view>
  92 +
58 93 <!-- Tabs标签栏(图片切换:开始、进行中、结束、人员、材料)- 改造后 -->
59 94 <view class="tabs-wrap " style="margin-top: 30rpx;">
60 95 <up-tabs
... ... @@ -168,6 +203,16 @@
168 203 @close="handleActionSheetClose"
169 204 @select="handleActionSheetSelect"
170 205 ></up-action-sheet>
  206 +
  207 + <!-- 作业类型选择弹窗(字典 maintain_type) -->
  208 + <up-action-sheet
  209 + :show="showWorkTypeSheet"
  210 + :actions="workTypeOptions"
  211 + title="请选择作业类型"
  212 + cancel-text="取消"
  213 + @close="showWorkTypeSheet = false"
  214 + @select="onWorkTypeSelect"
  215 + ></up-action-sheet>
171 216 </view>
172 217 </template>
173 218  
... ... @@ -176,6 +221,7 @@ import { ref, reactive, watch } from &#39;vue&#39;
176 221 import { onLoad, onReady, onShow } from '@dcloudio/uni-app';
177 222 import { useUploadImgs } from '@/common/utils/useUploadImgs'
178 223 import { getYlWorkersPage, universalApproval } from '@/api/work-order-manage/work-order-manage'
  224 +import { workRecordSubmit } from '@/api/common'
179 225 import { nextStepMap } from '@/common/utils/common'
180 226 import { useUserStore } from '@/pinia/user';
181 227 const userStore = useUserStore();
... ... @@ -183,6 +229,20 @@ const userStore = useUserStore();
183 229 // 表单Ref
184 230 const workOrderFormRef = ref(null)
185 231  
  232 +// 作业填报动态表单Ref(components/work-field-form)
  233 +const workFieldFormRef = ref(null)
  234 +
  235 +// 作业类型选择弹窗
  236 +const showWorkTypeSheet = ref(false)
  237 +// 作业类型选项:字典 maintain_type(打开弹窗时取,避免字典异步加载导致首次为空)
  238 +const workTypeOptions = ref([])
  239 +
  240 +// 当前作业类型下的动态字段数量(0 时给「无需填报」提示)
  241 +const dynamicFieldCount = ref(0)
  242 +
  243 +// 动态字段提交幂等流水号:同一页面生命周期内保持不变,网络重试只落一条记录
  244 +let workRecordClientSeq = ''
  245 +
186 246 // 页面状态
187 247 const showActionSheet = ref(false)
188 248 const currentActionSheetData = reactive({
... ... @@ -207,6 +267,9 @@ const coProcessorList = ref([])
207 267 // 工单表单数据(修改:新增coProcessorName数组,coProcessorNameStr用于页面展示)
208 268 const workOrderForm = reactive({
209 269 busiLine:'',
  270 + businessType:'', // 业务类型(由 busiLine 首段得出,用于加载动态字段配置)
  271 + workTypeId:'', // 作业类型ID(字典 maintain_type,用于加载动态字段配置)
  272 + workTypeName:'', // 作业类型名称(页面展示)
210 273 workerDataId:'',
211 274 taskId:'', // 任务id
212 275 orderNo: '', // 工单编号
... ... @@ -288,6 +351,9 @@ const workOrderFormRules = reactive({
288 351 reason: [
289 352 { type: 'string', required: true, message: '请输入处理情况描述', trigger: ['change', 'blur'] },
290 353 { type: 'string', max: 200, message: '处理情况描述最多200字', trigger: ['change', 'blur'] }
  354 + ],
  355 + workTypeName: [
  356 + { type: 'string', required: true, message: '请选择作业类型', trigger: ['change'] }
291 357 ]
292 358 })
293 359  
... ... @@ -353,6 +419,13 @@ onLoad((options) =&gt; {
353 419 workOrderForm.orderNo = orderItem.orderNo || '';
354 420 workOrderForm.reason = orderItem.handleResult || ''; // 处理情况回显
355 421 workOrderForm.busiLine = orderItem.busiLine
  422 + // 业务线可能是逗号分隔多值(如 "yl,hj"),字段配置按单值定位,取首段
  423 + workOrderForm.businessType = String(orderItem.busiLine || '').split(',')[0].trim()
  424 + // 来源数据若已带作业类型则回显(工单主表当前无该字段,通常为空,由用户手选)
  425 + if (orderItem.workTypeId) {
  426 + workOrderForm.workTypeId = orderItem.workTypeId
  427 + workOrderForm.workTypeName = uni.$dict.getDictLabel('maintain_type', orderItem.workTypeId) || ''
  428 + }
356 429 // 共同处理人(如有数据则回显,转换为数组格式)
357 430 if (orderItem.coHandlers && orderItem.coHandlersName) {
358 431 // 处理ID:如果是字符串(逗号分隔),转为数组;本身是数组则直接赋值
... ... @@ -620,6 +693,44 @@ const hideKeyboard = () =&gt; {
620 693 uni.hideKeyboard()
621 694 }
622 695  
  696 +// 打开作业类型选择弹窗
  697 +const openWorkTypeSheet = () => {
  698 + // up-action-sheet 用 name 展示,value 作为回传值
  699 + workTypeOptions.value = uni.$dict.getDictSimpleList('maintain_type')
  700 + .map(item => ({ name: item.label, value: item.value }))
  701 + if (workTypeOptions.value.length === 0) {
  702 + uni.showToast({
  703 + title: '暂无作业类型字典数据',
  704 + icon: 'none',
  705 + duration: 2000
  706 + })
  707 + return
  708 + }
  709 + showWorkTypeSheet.value = true
  710 +}
  711 +
  712 +// 选择作业类型:驱动动态字段按「业务类型 + 作业类型」重新加载
  713 +const onWorkTypeSelect = (e) => {
  714 + showWorkTypeSheet.value = false
  715 + if (!e || e.value === undefined || e.value === null || e.value === '') {
  716 + return
  717 + }
  718 + workOrderForm.workTypeId = e.value
  719 + workOrderForm.workTypeName = e.name
  720 + dynamicFieldCount.value = 0
  721 + if (workOrderFormRef.value) {
  722 + workOrderFormRef.value.validateField('workTypeName')
  723 + }
  724 +}
  725 +
  726 +// 动态字段加载完成(count=0 时页面提示「暂无需填报项」)
  727 +const onDynamicFieldsLoaded = (count) => {
  728 + dynamicFieldCount.value = count || 0
  729 +}
  730 +
  731 +// 动态字段提交幂等流水号(后端按 userId+clientSeq 去重)
  732 +const genClientSeq = () => `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
  733 +
623 734 // 提交工单(修改:将coProcessorName数组一并传入提交参数)
624 735 const submitWorkOrder = async () => {
625 736 try {
... ... @@ -687,6 +798,23 @@ const submitWorkOrder = async () =&gt; {
687 798 // ========== 原有表单校验 ==========
688 799 await workOrderFormRef.value.validate()
689 800  
  801 + // ========== 作业类型 + 动态字段校验 ==========
  802 + if (!workOrderForm.workTypeId) {
  803 + uni.showToast({
  804 + title: '请选择作业类型',
  805 + icon: 'none',
  806 + duration: 2000
  807 + });
  808 + console.warn('submitWorkOrder校验失败:作业类型为空');
  809 + return;
  810 + }
  811 + let dynamicValues = []
  812 + if (workFieldFormRef.value) {
  813 + // 校验失败时 reject 字段数组(uview-plus 已提示),交给外层 catch 忽略
  814 + await workFieldFormRef.value.validate()
  815 + dynamicValues = workFieldFormRef.value.getValues()
  816 + }
  817 +
690 818 // ========== 校验核心提交参数 ==========
691 819 if (!workOrderForm.taskId) {
692 820 uni.showToast({
... ... @@ -748,6 +876,24 @@ const submitWorkOrder = async () =&gt; {
748 876 }
749 877  
750 878 uni.showLoading({ title: '提交中...' })
  879 +
  880 + // ========== 先落作业填报(动态字段),再走审批流转 ==========
  881 + // 值按「业务类型 + 作业类型」的字段配置落库;同一工单重复实施时后端按 (bizType,bizId) 覆盖原记录并红冲旧物料
  882 + if (dynamicValues.length > 0) {
  883 + if (!workRecordClientSeq) {
  884 + workRecordClientSeq = genClientSeq()
  885 + }
  886 + await workRecordSubmit({
  887 + businessType: workOrderForm.businessType,
  888 + bizType: 1,
  889 + bizId: Number(workOrderForm.workerDataId),
  890 + workTypeId: Number(workOrderForm.workTypeId),
  891 + clientSeq: workRecordClientSeq,
  892 + remark: workOrderForm.reason.trim(),
  893 + fields: dynamicValues
  894 + })
  895 + }
  896 +
751 897 const res = await universalApproval(submitData)
752 898  
753 899 // 校验接口返回结果
... ... @@ -779,6 +925,9 @@ const submitWorkOrder = async () =&gt; {
779 925 } catch (error) {
780 926 uni.hideLoading()
781 927  
  928 + // 本次提交失败后重置流水号:下次重试允许按最新填报值覆盖原记录(后端按 bizType+bizId 覆盖,不会重复建单)
  929 + workRecordClientSeq = ''
  930 +
782 931 // 区分校验失败和接口失败,仅接口失败时提示(校验失败已有内置提示)
783 932 if (!Array.isArray(error)) {
784 933 uni.showToast({
... ... @@ -824,5 +973,25 @@ const submitWorkOrder = async () =&gt; {
824 973 font-weight: bold;
825 974 }
826 975  
  976 +// 作业填报(动态字段)区块
  977 +.dynamic-field-block {
  978 + margin-top: 10rpx;
  979 +
  980 + .dynamic-field-title {
  981 + padding: 20rpx 0 10rpx;
  982 + font-size: 30rpx;
  983 + font-weight: 600;
  984 + color: #303133;
  985 + border-bottom: 1rpx solid #f0f0f0;
  986 + }
  987 +
  988 + .dynamic-field-empty {
  989 + padding: 30rpx 0;
  990 + text-align: center;
  991 + font-size: 26rpx;
  992 + color: #999;
  993 + }
  994 +}
  995 +
827 996  
828 997 </style>
829 998 \ No newline at end of file
... ...
pages-sub/problem/work-order-manage/order-detail.vue
... ... @@ -177,6 +177,42 @@
177 177 <text v-else class="empty-img-text">养护员未上传图片</text>
178 178 </view>
179 179 </view>
  180 +
  181 + <!-- 作业填报明细(工单实施时按「业务类型+作业类型」动态字段配置填报) -->
  182 + <view class="work-field-block" v-if="workFieldValues.length > 0">
  183 + <view class="work-field-title">作业填报</view>
  184 + <up-cell-group :border="false">
  185 + <up-cell
  186 + v-for="item in workFieldValues"
  187 + :key="item.fieldCode"
  188 + :title="item.fieldName"
  189 + align="middle"
  190 + :border="false"
  191 + >
  192 + <template #value>
  193 + <!-- 7图片:value_text 为 URL JSON 数组 -->
  194 + <view v-if="item.fieldType === 7 && parseFieldUrls(item).length > 0" class="work-field-imgs">
  195 + <up-album
  196 + :urls="parseFieldUrls(item)"
  197 + :singleSize="60"
  198 + :multipleSize="60"
  199 + :preview-full-image="true"
  200 + ></up-album>
  201 + </view>
  202 + <!-- 8附件:展示文件名 -->
  203 + <view v-else-if="item.fieldType === 8 && parseFieldUrls(item).length > 0" class="work-field-files">
  204 + <text
  205 + v-for="(url, idx) in parseFieldUrls(item)"
  206 + :key="idx"
  207 + class="work-field-file up-line-1"
  208 + >{{ fileNameOf(url) }}</text>
  209 + </view>
  210 + <!-- 其余类型:后端已按配置把编码翻成可读文案 -->
  211 + <view v-else class="common-text-color work-field-value">{{ fieldDisplayText(item) }}</view>
  212 + </template>
  213 + </up-cell>
  214 + </up-cell-group>
  215 + </view>
180 216 </view>
181 217  
182 218 <!-- 2. 流程节点Tab -->
... ... @@ -381,6 +417,7 @@ import {
381 417 universalApproval,
382 418 getTaskActions
383 419 } from '@/api/work-order-manage/work-order-manage';
  420 +import {getWorkRecordValues} from '@/api/common'
384 421 import {nextStepMap, buzStatusMap, calculateFormatTimeDiff, getActionButtonType} from '@/common/utils/common'
385 422 // 引入图片上传组合式函数
386 423 import {useUploadImgs} from '@/common/utils/useUploadImgs'
... ... @@ -469,6 +506,9 @@ const orderDetail = ref&lt;any&gt;({
469 506 // ========== 新增:声明eventChannel变量 ==========
470 507 const eventChannel = ref(null);
471 508  
  509 +// 作业填报明细(工单实施时按「业务类型+作业类型」动态字段配置落库的值)
  510 +const workFieldValues = ref<any[]>([]);
  511 +
472 512 /**
473 513 * 当前激活的图片列表(无需函数调用,直接访问)
474 514 */
... ... @@ -538,6 +578,8 @@ const DetailQuery = async (taskIdStr: string) =&gt; {
538 578 configuredActions.value = await actionPromise;
539 579 orderDetail.value = res;
540 580 currentImgList.value = orderDetail.value.startImgs
  581 + // 并行无依赖:填报明细单独拉,失败不影响详情展示
  582 + loadWorkFieldValues(res)
541 583 }
542 584 } catch (error) {
543 585 console.error('获取工单详情失败:', error);
... ... @@ -547,6 +589,46 @@ const DetailQuery = async (taskIdStr: string) =&gt; {
547 589 }
548 590 };
549 591  
  592 +/**
  593 + * 加载作业填报明细(动态字段)
  594 + * bizType=1 工单,bizId=workorder_main_info.id
  595 + */
  596 +const loadWorkFieldValues = async (detail: any) => {
  597 + workFieldValues.value = []
  598 + if (!detail || !detail.id) return
  599 + try {
  600 + const res = await getWorkRecordValues({bizType: 1, bizId: detail.id})
  601 + workFieldValues.value = Array.isArray(res) ? res : []
  602 + } catch (error) {
  603 + console.error('获取填报明细失败:', error)
  604 + }
  605 +}
  606 +
  607 +// 7图片/8附件:value_text 存 URL 的 JSON 数组
  608 +const parseFieldUrls = (item: any) => {
  609 + try {
  610 + const arr = JSON.parse(item.valueText || '[]')
  611 + return Array.isArray(arr) ? arr : []
  612 + } catch (e) {
  613 + return []
  614 + }
  615 +}
  616 +
  617 +// 附件展示末段文件名
  618 +const fileNameOf = (url: string) => {
  619 + if (!url) return ''
  620 + const path = String(url).split('?')[0].split('#')[0]
  621 + const name = path.substring(path.lastIndexOf('/') + 1)
  622 + try {
  623 + return decodeURIComponent(name) || url
  624 + } catch (e) {
  625 + return name || url
  626 + }
  627 +}
  628 +
  629 +// 其余类型直接展示后端生成的可读文案(item.text)
  630 +const fieldDisplayText = (item: any) => item.text || item.valueText || '--'
  631 +
550 632 // 页面加载参数
551 633 const taskId = ref('')
552 634 const activeTab = ref('')
... ... @@ -1241,4 +1323,39 @@ onShow(() =&gt; {
1241 1323 .main-content.pb-rows-3 {
1242 1324 padding-bottom: 390rpx;
1243 1325 }
  1326 +
  1327 +// 作业填报明细
  1328 +.work-field-block {
  1329 + margin-top: 20rpx;
  1330 +
  1331 + .work-field-title {
  1332 + padding: 16rpx 30rpx;
  1333 + font-size: 30rpx;
  1334 + font-weight: 600;
  1335 + color: #303133;
  1336 + background: #fff;
  1337 + }
  1338 +
  1339 + .work-field-value {
  1340 + max-width: 420rpx;
  1341 + text-align: right;
  1342 + word-break: break-all;
  1343 + }
  1344 +
  1345 + .work-field-imgs {
  1346 + width: 100%;
  1347 + }
  1348 +
  1349 + .work-field-files {
  1350 + display: flex;
  1351 + flex-direction: column;
  1352 + align-items: flex-end;
  1353 +
  1354 + .work-field-file {
  1355 + max-width: 420rpx;
  1356 + font-size: 26rpx;
  1357 + color: #3c9cff;
  1358 + }
  1359 + }
  1360 +}
1244 1361 </style>
1245 1362 \ No newline at end of file
... ...