Commit 50ad810ab4abab01ded96cf1317c605658cf85e4

Authored by 王彪总
1 parent d4747408

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

common/config/global.js
... ... @@ -9,6 +9,9 @@ export default {
9 9 timeout: 10000,
10 10 tokenKey: 'jcss_token'
11 11 },
  12 + // 多租户:后端开启 urbanops.tenant.enable=true 后,未登录请求必须携带 tenant-id 请求头
  13 + // 取值 = system_tenant.id(集团租户号,与 sql/mysql/多租户-03-租户套餐与账号迁移.sql 的 @tenant_id 保持一致)
  14 + tenantId: '1',
12 15 router: {
13 16 loginPath: '/pages/login/index',
14 17 tabBarList: [
... ...
common/utils/request.js
... ... @@ -22,6 +22,10 @@ const request = (options) => {
22 22  
23 23 if (token) {
24 24 opts.header['Authorization'] = `Bearer ${token}`;
  25 + } else if (globalConfig.tenantId) {
  26 + // 多租户:未登录时后端无法从 token 推导租户,必须显式携带 tenant-id(登录、刷新令牌等接口)
  27 + // 已登录请求由后端按 token 取租户,故不硬编码此头,避免将来多租户下跨租户 403
  28 + opts.header['tenant-id'] = String(globalConfig.tenantId);
25 29 }
26 30  
27 31 opts.url = globalConfig.api.baseUrl + opts.url;
... ...
components/grouped-picker/grouped-picker.vue 0 → 100644
  1 +<template>
  2 + <!-- 分层选项选择器:顶部 tab → 左侧竖排侧栏 → 右侧列表(第 3 层做分组标题)
  3 + 行的勾选态由本组件的 draft 数据派生,不用 u-checkbox:
  4 + u-checkbox 的勾选态是组件内部状态(u-checkbox.vue:199-221 只在 init 时从父组派生),
  5 + 而 u-checkbox-group 是把每个子实例的内部 isChecked 回读成数组(u-checkbox-group.vue:95-112),
  6 + 子实例卸载后仍留在 children 里 —— 本弹层「切 tab 就换一批行」正好会命中这个坑(会读出幽灵勾选/重复值)。 -->
  7 + <up-popup :show="show" mode="bottom" :round="16" :safe-area-inset-bottom="true" :closeable="true"
  8 + close-icon-color="#999999" @close="handleClose">
  9 + <view class="gp-wrap">
  10 + <!-- 标题 + 多选已选数(已选项可能落在别的 tab,行不可见,必须靠这里和侧栏角标提示) -->
  11 + <view class="gp-header">
  12 + <text class="gp-title">{{ title || '请选择' }}</text>
  13 + <text v-if="multiple && hintText" class="gp-hint">{{ hintText }}</text>
  14 + </view>
  15 +
  16 + <!-- 搜索:命中时跳过分组、对全量过滤,避免「切在某个 tab 里搜不到其它 tab 的项」 -->
  17 + <view class="gp-search">
  18 + <up-input v-model="keyword" :placeholder="`搜索${title || ''}`" clearable border="surround"
  19 + shape="circle"></up-input>
  20 + </view>
  21 +
  22 + <!-- 第 1 层:顶部 tab -->
  23 + <up-tabs v-if="!isSearching && tabList.length > 1" :list="tabList" :scrollable="true"
  24 + v-model:current="tabIdx" @change="onTabChange" :lineWidth="30"></up-tabs>
  25 +
  26 + <view class="gp-body">
  27 + <!-- 第 2 层:左侧竖排侧栏 -->
  28 + <scroll-view v-if="!isSearching && sides.length > 0" scroll-y class="gp-side">
  29 + <view v-for="(side, idx) in sides" :key="side.key" class="gp-side-item"
  30 + :class="{ 'is-active': idx === sideIdx }" @click="switchSide(idx)">
  31 + <text class="gp-side-text">{{ side.label }}</text>
  32 + <text v-if="side.count > 0" class="gp-side-badge">{{ side.count }}</text>
  33 + </view>
  34 + </scroll-view>
  35 +
  36 + <!-- 第 3 层及更深:右侧列表,按最深可用层做分组标题 -->
  37 + <scroll-view scroll-y class="gp-list" :key="listKey">
  38 + <view v-for="group in visibleGroups" :key="group.key" class="gp-group">
  39 + <view class="gp-group-title">{{ group.label }}</view>
  40 + <view v-for="row in group.rows" :key="row.value" class="gp-row"
  41 + :class="{ 'is-selected': isSelected(row), 'is-locked': isLocked(row) }" @click="onRowClick(row)">
  42 + <view class="gp-check" :class="{ 'is-checked': isSelected(row) }">
  43 + <text v-if="isSelected(row)" class="gp-check-mark">✓</text>
  44 + </view>
  45 + <view class="gp-row-main">
  46 + <text class="gp-row-label">{{ row.label }}</text>
  47 + <!-- 搜索态下补层级面包屑,否则不知道命中的项属于哪个分组 -->
  48 + <text v-if="breadcrumb(row)" class="gp-row-crumb">{{ breadcrumb(row) }}</text>
  49 + </view>
  50 + <view v-if="needQuantity && isSelected(row)" class="gp-row-qty" @click.stop>
  51 + <u-number-box v-model="draftQty[row.value]" :min="0" :step="1"></u-number-box>
  52 + <text v-if="unit" class="gp-row-unit">{{ unit }}</text>
  53 + </view>
  54 + </view>
  55 + </view>
  56 + <view v-if="visibleGroups.length === 0" class="gp-empty">暂无数据</view>
  57 + </scroll-view>
  58 + </view>
  59 +
  60 + <view class="gp-actions">
  61 + <view class="gp-action-item">
  62 + <up-button type="default" text="取消" @click="handleClose"></up-button>
  63 + </view>
  64 + <!-- 单选点行即提交,无需确定;多选才给确定按钮 -->
  65 + <view v-if="multiple" class="gp-action-item">
  66 + <up-button type="primary" :text="confirmText" @click="handleConfirm"></up-button>
  67 + </view>
  68 + </view>
  69 + </view>
  70 + </up-popup>
  71 +</template>
  72 +
  73 +<script setup>
  74 +import { ref, reactive, computed, watch, defineProps, defineEmits } from 'vue'
  75 +
  76 +// ========== Props ==========
  77 +const props = defineProps({
  78 + show: { type: Boolean, default: false },
  79 + title: { type: String, default: '' },
  80 + // 全量选项(后端扁平行):[{ label, value, path: [{ key, label }] }]
  81 + options: { type: Array, default: () => [] },
  82 + multiple: { type: Boolean, default: false },
  83 + // Infinity 表示不限(页面用 maxCountOf(item, Infinity) 传入)
  84 + maxCount: { type: Number, default: Infinity },
  85 + needQuantity: { type: Boolean, default: false },
  86 + // 单选传字符串、多选传数组
  87 + value: { type: [String, Array], default: '' },
  88 + // { 选项值: 数量 }
  89 + quantities: { type: Object, default: () => ({}) },
  90 + unit: { type: String, default: '' }
  91 +})
  92 +
  93 +const emit = defineEmits(['update:show', 'confirm'])
  94 +
  95 +// ========== 内部状态 ==========
  96 +// draft:弹层内的编辑副本。取消直接丢弃,确定才一次性回传,避免半选态污染页面表单
  97 +const keyword = ref('')
  98 +const tabIdx = ref(0)
  99 +const sideIdx = ref(0)
  100 +const draftSelected = ref([])
  101 +const draftValue = ref('')
  102 +// 数量表用 reactive 而非 ref:模板里要 v-model 到动态键(draftQty[row.value]),
  103 +// reactive 直接写属性即可,不依赖编译器把 ref 改写成 .value[key](本仓无法本地构建,不赌这个)
  104 +const draftQty = reactive({})
  105 +
  106 +// ========== 组树 ==========
  107 +const rows = computed(() =>
  108 + (props.options || []).filter(o => o && o.value !== undefined && o.value !== null)
  109 +)
  110 +// 层数按「行里 path 的最大长度」定,兼容同一个字段里长短不一的行
  111 +const depth = computed(() => rows.value.reduce((m, r) => Math.max(m, (r.path || []).length), 0))
  112 +// 层级→用途映射:>=3 层才有侧栏;2 层只用 tab;1 层直接平铺(带该层名分组)
  113 +const tabLevel = computed(() => (depth.value >= 2 ? 0 : -1))
  114 +const sideLevel = computed(() => (depth.value >= 3 ? 1 : -1))
  115 +const groupStart = computed(() => {
  116 + if (depth.value >= 3) return 2
  117 + if (depth.value === 2) return 1
  118 + return 0
  119 +})
  120 +
  121 +const nodeAt = (row, i) => {
  122 + const p = row.path
  123 + return p && p[i] ? p[i] : null
  124 +}
  125 +const keyAt = (row, i) => {
  126 + const n = nodeAt(row, i)
  127 + return n ? String(n.key || n.label || '') : ''
  128 +}
  129 +const labelAt = (row, i) => {
  130 + const n = nodeAt(row, i)
  131 + return n ? String(n.label || n.key || '') : ''
  132 +}
  133 +// 分组键用 \u0001 连接(层级值里不会出现该字符),分组名用 ' / ' 连接更深层
  134 +const groupKeyOf = (row) => {
  135 + const parts = []
  136 + for (let i = groupStart.value; i < (row.path || []).length; i++) parts.push(keyAt(row, i))
  137 + return parts.join('\u0001') || '__none__'
  138 +}
  139 +const groupLabelOf = (row) => {
  140 + const parts = []
  141 + for (let i = groupStart.value; i < (row.path || []).length; i++) parts.push(labelAt(row, i))
  142 + return parts.join(' / ') || '其他'
  143 +}
  144 +
  145 +const tabs = computed(() => {
  146 + if (tabLevel.value < 0) return []
  147 + const seen = new Map()
  148 + for (const r of rows.value) {
  149 + const k = keyAt(r, tabLevel.value)
  150 + if (!seen.has(k)) seen.set(k, { key: k, label: labelAt(r, tabLevel.value) })
  151 + }
  152 + return Array.from(seen.values())
  153 +})
  154 +const tabList = computed(() => tabs.value.map(t => ({ name: t.label })))
  155 +const activeTab = computed(() => tabs.value[tabIdx.value] || null)
  156 +
  157 +const rowsInTab = computed(() => {
  158 + if (tabLevel.value < 0) return rows.value
  159 + const t = activeTab.value
  160 + if (!t) return []
  161 + return rows.value.filter(r => keyAt(r, tabLevel.value) === t.key)
  162 +})
  163 +
  164 +const sides = computed(() => {
  165 + if (sideLevel.value < 0) return []
  166 + const seen = new Map()
  167 + for (const r of rowsInTab.value) {
  168 + const k = keyAt(r, sideLevel.value)
  169 + if (!seen.has(k)) seen.set(k, { key: k, label: labelAt(r, sideLevel.value), count: 0 })
  170 + }
  171 + const list = Array.from(seen.values())
  172 + // 已选数量角标:已选项的勾选框在别的 tab/侧栏时是卸载状态,只能靠角标让用户知道选过什么
  173 + for (const side of list) {
  174 + side.count = draftSelected.value.filter(v => {
  175 + const row = rows.value.find(r => String(r.value) === v)
  176 + return row ? keyAt(row, sideLevel.value) === side.key : false
  177 + }).length
  178 + }
  179 + return list
  180 +})
  181 +const activeSide = computed(() => sides.value[sideIdx.value] || null)
  182 +
  183 +const rowsInSide = computed(() => {
  184 + if (sideLevel.value < 0) return rowsInTab.value
  185 + const s = activeSide.value
  186 + if (!s) return []
  187 + return rowsInTab.value.filter(r => keyAt(r, sideLevel.value) === s.key)
  188 +})
  189 +
  190 +const isSearching = computed(() => keyword.value.trim() !== '')
  191 +const searchedRows = computed(() => {
  192 + const kw = keyword.value.trim().toLowerCase()
  193 + if (!kw) return []
  194 + return rows.value.filter(r => String(r.label || '').toLowerCase().indexOf(kw) > -1)
  195 +})
  196 +
  197 +const visibleGroups = computed(() => {
  198 + if (isSearching.value) {
  199 + return searchedRows.value.length
  200 + ? [{ key: '__search__', label: `搜索结果(${searchedRows.value.length})`, rows: searchedRows.value }]
  201 + : []
  202 + }
  203 + const out = []
  204 + const seen = new Map()
  205 + for (const r of rowsInSide.value) {
  206 + const k = groupKeyOf(r)
  207 + if (!seen.has(k)) {
  208 + const g = { key: k, label: groupLabelOf(r), rows: [] }
  209 + seen.set(k, g)
  210 + out.push(g)
  211 + }
  212 + seen.get(k).rows.push(r)
  213 + }
  214 + return out
  215 +})
  216 +
  217 +// 切 tab/侧栏时换 key 让 scroll-view 重建,把滚动位置带回顶部
  218 +const listKey = computed(() => `${tabIdx.value}|${sideIdx.value}`)
  219 +const breadcrumb = (row) =>
  220 + isSearching.value ? (row.path || []).map(n => n.label || n.key).join(' / ') : ''
  221 +
  222 +// ========== 选中态 ==========
  223 +const isSelected = (row) => {
  224 + const v = String(row.value)
  225 + return props.multiple ? draftSelected.value.indexOf(v) > -1 : draftValue.value === v
  226 +}
  227 +// 到上限后禁用未选中项(同页面 isOptionLocked 的口径:不回写截断,只挡新增)
  228 +const isLocked = (row) => {
  229 + if (!props.multiple) return false
  230 + const max = props.maxCount
  231 + if (max === Infinity || max === undefined || max === null) return false
  232 + return draftSelected.value.length >= max && !isSelected(row)
  233 +}
  234 +const hintText = computed(() => {
  235 + const max = props.maxCount
  236 + const n = draftSelected.value.length
  237 + if (max === Infinity || max === undefined || max === null) return n ? `已选 ${n}` : ''
  238 + return `已选 ${n}/${max}`
  239 +})
  240 +const confirmText = computed(() =>
  241 + draftSelected.value.length ? `确定(${draftSelected.value.length})` : '确定'
  242 +)
  243 +
  244 +// ========== 交互 ==========
  245 +const onTabChange = () => {
  246 + // v-model:current 已写好 tabIdx,这里只把侧栏带回第一项
  247 + sideIdx.value = 0
  248 +}
  249 +const switchSide = (idx) => {
  250 + sideIdx.value = idx
  251 +}
  252 +
  253 +const onRowClick = (row) => {
  254 + const v = String(row.value)
  255 + if (props.multiple) {
  256 + if (isLocked(row)) {
  257 + uni.showToast({ title: `最多选择 ${props.maxCount} 项`, icon: 'none' })
  258 + return
  259 + }
  260 + const i = draftSelected.value.indexOf(v)
  261 + if (i > -1) {
  262 + draftSelected.value.splice(i, 1)
  263 + } else {
  264 + draftSelected.value.push(v)
  265 + }
  266 + return
  267 + }
  268 + // 单选:点行即确认并关闭,与底部 actionSheet 的即时选中语义一致
  269 + draftValue.value = v
  270 + handleConfirm()
  271 +}
  272 +
  273 +const handleConfirm = () => {
  274 + const sel = props.multiple
  275 + ? rows.value.filter(r => draftSelected.value.indexOf(String(r.value)) > -1)
  276 + : rows.value.filter(r => String(r.value) === draftValue.value)
  277 + const labels = sel.map(r => r.label)
  278 + let labelText = ''
  279 + if (props.multiple) {
  280 + const joined = labels.join('、')
  281 + labelText = joined.length > 30 ? `已选 ${labels.length} 项` : joined
  282 + } else {
  283 + labelText = labels[0] || ''
  284 + }
  285 + const quantities = {}
  286 + if (props.needQuantity) {
  287 + sel.forEach(r => {
  288 + const raw = draftQty[String(r.value)]
  289 + if (raw !== '' && raw !== null && raw !== undefined) {
  290 + quantities[String(r.value)] = raw
  291 + }
  292 + })
  293 + }
  294 + emit('confirm', {
  295 + values: props.multiple ? draftSelected.value.slice() : draftValue.value,
  296 + labelText,
  297 + quantities
  298 + })
  299 + emit('update:show', false)
  300 +}
  301 +
  302 +const handleClose = () => {
  303 + emit('update:show', false)
  304 +}
  305 +
  306 +// 每次打开用页面当前值重置 draft,并定位到第一个已选项所在的分区
  307 +const resetPosition = () => {
  308 + tabIdx.value = 0
  309 + sideIdx.value = 0
  310 + const sel = props.multiple ? draftSelected.value : draftValue.value ? [draftValue.value] : []
  311 + if (!sel.length) return
  312 + const first = rows.value.find(r => sel.indexOf(String(r.value)) > -1)
  313 + if (!first) return
  314 + if (tabLevel.value >= 0) {
  315 + const i = tabs.value.findIndex(t => t.key === keyAt(first, tabLevel.value))
  316 + if (i > -1) tabIdx.value = i
  317 + }
  318 + if (sideLevel.value >= 0) {
  319 + const i = sides.value.findIndex(s => s.key === keyAt(first, sideLevel.value))
  320 + if (i > -1) sideIdx.value = i
  321 + }
  322 +}
  323 +
  324 +watch(
  325 + () => props.show,
  326 + (val) => {
  327 + if (!val) return
  328 + keyword.value = ''
  329 + draftSelected.value = props.multiple && Array.isArray(props.value) ? props.value.map(v => String(v)) : []
  330 + draftValue.value = !props.multiple && props.value !== null && props.value !== undefined ? String(props.value) : ''
  331 + // 先清空再灌入:reactive 对象上直接 Object.assign 会把上次打开残留的键留下
  332 + Object.keys(draftQty).forEach(k => delete draftQty[k])
  333 + Object.assign(draftQty, props.quantities || {})
  334 + resetPosition()
  335 + }
  336 +)
  337 +</script>
  338 +
  339 +<style scoped lang="scss">
  340 +.gp-wrap {
  341 + display: flex;
  342 + flex-direction: column;
  343 +}
  344 +
  345 +.gp-header {
  346 + display: flex;
  347 + align-items: baseline;
  348 + justify-content: space-between;
  349 + padding: 30rpx 30rpx 0;
  350 +}
  351 +
  352 +.gp-title {
  353 + font-size: 32rpx;
  354 + font-weight: 600;
  355 + color: #303133;
  356 +}
  357 +
  358 +.gp-hint {
  359 + font-size: 24rpx;
  360 + color: #909399;
  361 +}
  362 +
  363 +.gp-search {
  364 + padding: 20rpx 30rpx 10rpx;
  365 +}
  366 +
  367 +.gp-body {
  368 + display: flex;
  369 + height: 700rpx;
  370 + border-top: 1rpx solid #f2f3f5;
  371 +}
  372 +
  373 +/* 侧栏 */
  374 +.gp-side {
  375 + width: 200rpx;
  376 + height: 100%;
  377 + background: #f7f8fa;
  378 +}
  379 +
  380 +.gp-side-item {
  381 + position: relative;
  382 + display: flex;
  383 + align-items: center;
  384 + justify-content: center;
  385 + min-height: 88rpx;
  386 + padding: 0 12rpx;
  387 + font-size: 26rpx;
  388 + color: #606266;
  389 + text-align: center;
  390 +
  391 + &.is-active {
  392 + background: #ffffff;
  393 + color: #2979ff;
  394 + font-weight: 600;
  395 + border-left: 6rpx solid #2979ff;
  396 + }
  397 +}
  398 +
  399 +.gp-side-text {
  400 + font-size: 26rpx;
  401 +}
  402 +
  403 +.gp-side-badge {
  404 + position: absolute;
  405 + top: 8rpx;
  406 + right: 8rpx;
  407 + min-width: 28rpx;
  408 + height: 28rpx;
  409 + padding: 0 6rpx;
  410 + border-radius: 14rpx;
  411 + background: #2979ff;
  412 + color: #ffffff;
  413 + font-size: 20rpx;
  414 + line-height: 28rpx;
  415 + text-align: center;
  416 +}
  417 +
  418 +/* 右侧列表 */
  419 +.gp-list {
  420 + flex: 1;
  421 + height: 100%;
  422 +}
  423 +
  424 +.gp-group-title {
  425 + /* 吸顶:小程序 webview 下生效;skyline 不支持时会静默退化为普通标题,不影响可用性 */
  426 + position: sticky;
  427 + top: 0;
  428 + z-index: 2;
  429 + padding: 14rpx 24rpx;
  430 + background: #f2f3f5;
  431 + font-size: 24rpx;
  432 + color: #909399;
  433 +}
  434 +
  435 +.gp-row {
  436 + display: flex;
  437 + align-items: center;
  438 + padding: 22rpx 24rpx;
  439 + border-bottom: 1rpx solid #f2f3f5;
  440 +
  441 + &.is-locked {
  442 + opacity: 0.4;
  443 + }
  444 +}
  445 +
  446 +.gp-check {
  447 + flex-shrink: 0;
  448 + width: 36rpx;
  449 + height: 36rpx;
  450 + margin-right: 18rpx;
  451 + border: 2rpx solid #c8c9cc;
  452 + border-radius: 6rpx;
  453 + display: flex;
  454 + align-items: center;
  455 + justify-content: center;
  456 +
  457 + &.is-checked {
  458 + background: #2979ff;
  459 + border-color: #2979ff;
  460 + }
  461 +}
  462 +
  463 +.gp-check-mark {
  464 + color: #ffffff;
  465 + font-size: 24rpx;
  466 + line-height: 1;
  467 +}
  468 +
  469 +.gp-row-main {
  470 + flex: 1;
  471 + min-width: 0;
  472 + display: flex;
  473 + flex-direction: column;
  474 +}
  475 +
  476 +.gp-row-label {
  477 + font-size: 28rpx;
  478 + color: #303133;
  479 +}
  480 +
  481 +.gp-row-crumb {
  482 + margin-top: 4rpx;
  483 + font-size: 22rpx;
  484 + color: #909399;
  485 +}
  486 +
  487 +.gp-row-qty {
  488 + flex-shrink: 0;
  489 + display: flex;
  490 + align-items: center;
  491 + margin-left: 12rpx;
  492 +}
  493 +
  494 +.gp-row-unit {
  495 + margin-left: 8rpx;
  496 + font-size: 24rpx;
  497 + color: #909399;
  498 +}
  499 +
  500 +.gp-empty {
  501 + padding: 60rpx 0;
  502 + font-size: 26rpx;
  503 + color: #c0c4cc;
  504 + text-align: center;
  505 +}
  506 +
  507 +.gp-actions {
  508 + display: flex;
  509 + align-items: center;
  510 + border-top: 1rpx solid #f2f3f5;
  511 +}
  512 +
  513 +.gp-action-item {
  514 + flex: 1;
  515 + padding: 20rpx;
  516 +}
  517 +</style>
... ...
pages-sub/daily/maintain-manage/add-record.vue
... ... @@ -17,6 +17,14 @@
17 17 <!-- type 2 文本 -->
18 18 <up-input v-else-if="item.fieldType === 2" v-model="inspectForm[item.fieldCode]"
19 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">
  24 + <template #right>
  25 + <up-icon name="arrow-right" size="16"></up-icon>
  26 + </template>
  27 + </up-input>
20 28 <!-- type3 枚举:多选 multiple=1 使用复选框组(name 必须是 value,提交按逗号拼接 value,与值格式契约一致) -->
21 29 <u-checkbox-group v-else-if="item.fieldType === 3 && item.multiple === 1"
22 30 v-model="inspectForm[item.fieldCode]">
... ... @@ -120,6 +128,12 @@
120 128 <!-- ActionSheet 底部选择弹窗 -->
121 129 <up-action-sheet :show="actionSheet.show" :actions="actionSheet.options" @select="onActionSheetSelect"
122 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>
123 137 </view>
124 138 </template>
125 139  
... ... @@ -178,6 +192,20 @@ export default {
178 192 fieldCode: '', // 当前操作的字段
179 193 options: [] as any[],
180 194 fieldItem: null // 保存当前点击的field对象,用于后面查找label
  195 + },
  196 + // 分层选项选择器配置(选项带 path 的 3/6 字段用,其余字段仍走上面的 actionSheet / 内联复选框)
  197 + groupPicker: {
  198 + show: false,
  199 + fieldCode: '',
  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: ''
181 209 }
182 210 }
183 211 },
... ... @@ -224,10 +252,14 @@ export default {
224 252 },
225 253 /**
226 254 * 点击表单项时按类型决定弹出哪个选择器:
227   - * 3 枚举(单选) / 9 关联记录 → 底部 actionSheet;11 地理位置 → 微信地图选点
  255 + * 分层选项(带 path 的 3/6) → 分组弹层;3 枚举(单选) / 9 关联记录 → 底部 actionSheet;11 地理位置 → 微信地图选点
228 256 * 其余类型(多选、图片、附件、可输入的链接)不在这里处理,由控件自身响应
229 257 */
230 258 openPickerIfNeeded(item: any) {
  259 + // 分层选项优先:它的只读入口是靠本方法打开弹层的(模板里 3/6 的原有分支已被 isGroupedField 挡在前面)
  260 + if (this.isGroupedField(item)) {
  261 + return this.openGroupedPicker(item)
  262 + }
231 263 // 条件与模板互补:模板用 multiple === 1 渲染复选框组,这里就必须用 !== 1 才打开弹窗,
232 264 // 否则 multiple 为 NULL/0 的存量单选枚举会既不渲染复选框、又点不开弹窗
233 265 if (item.fieldType === 3 && item.multiple !== 1) {
... ... @@ -241,6 +273,51 @@ export default {
241 273 }
242 274 },
243 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
  304 + },
  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 + /**
244 321 * 打开底部选择弹窗 actionSheet
245 322 */
246 323 openActionSheet(item: any) {
... ... @@ -343,7 +420,7 @@ export default {
343 420 // 由后端按 optionSource 分发。类型 3/6/9 需要选项,其余类型不需要
344 421 if (field.fieldType === 3 || field.fieldType === 6 || field.fieldType === 9) {
345 422 if (field.optionSource === 1 && Array.isArray(field.options)) {
346   - field.selectOptions = field.options.map(opt => ({ label: opt.label, value: String(opt.value) }))
  423 + field.selectOptions = field.options.map(opt => ({ label: opt.label, value: String(opt.value), path: opt.path }))
347 424 } else if (field.optionSource === 3) {
348 425 // 物料来源:调 materials(比 options 多返回 currentNum 库存余量/unit),供选择时显示库存
349 426 const matRes = await getMaterialsByCode({
... ... @@ -365,7 +442,8 @@ export default {
365 442 workTypeId,
366 443 fieldCode: field.fieldCode
367 444 })
368   - field.selectOptions = (optRes || []).map(opt => ({ label: opt.label, value: String(opt.value) }))
  445 + // path 后端仅在 SQL 来源按 gN_key/gN_label 别名产出;透传后由 isGroupedField 判定是否走分层弹层
  446 + field.selectOptions = (optRes || []).map(opt => ({ label: opt.label, value: String(opt.value), path: opt.path }))
369 447 }
370 448 }
371 449 // 数量收集初始化(必须在 selectOptions 装载之后):
... ...