Commit 50ad810ab4abab01ded96cf1317c605658cf85e4
1 parent
d4747408
业务线 小程序不用写死,从后端接口获取
Showing
4 changed files
with
605 additions
and
3 deletions
common/config/global.js
| @@ -9,6 +9,9 @@ export default { | @@ -9,6 +9,9 @@ export default { | ||
| 9 | timeout: 10000, | 9 | timeout: 10000, |
| 10 | tokenKey: 'jcss_token' | 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 | router: { | 15 | router: { |
| 13 | loginPath: '/pages/login/index', | 16 | loginPath: '/pages/login/index', |
| 14 | tabBarList: [ | 17 | tabBarList: [ |
common/utils/request.js
| @@ -22,6 +22,10 @@ const request = (options) => { | @@ -22,6 +22,10 @@ const request = (options) => { | ||
| 22 | 22 | ||
| 23 | if (token) { | 23 | if (token) { |
| 24 | opts.header['Authorization'] = `Bearer ${token}`; | 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 | opts.url = globalConfig.api.baseUrl + opts.url; | 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,6 +17,14 @@ | ||
| 17 | <!-- type 2 文本 --> | 17 | <!-- type 2 文本 --> |
| 18 | <up-input v-else-if="item.fieldType === 2" v-model="inspectForm[item.fieldCode]" | 18 | <up-input v-else-if="item.fieldType === 2" v-model="inspectForm[item.fieldCode]" |
| 19 | :maxlength="item.length || 50" :placeholder="`请输入${item.fieldName}`"></up-input> | 19 | :maxlength="item.length || 50" :placeholder="`请输入${item.fieldName}`"></up-input> |
| 20 | + <!-- 分层选项(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 | <!-- type3 枚举:多选 multiple=1 使用复选框组(name 必须是 value,提交按逗号拼接 value,与值格式契约一致) --> | 28 | <!-- type3 枚举:多选 multiple=1 使用复选框组(name 必须是 value,提交按逗号拼接 value,与值格式契约一致) --> |
| 21 | <u-checkbox-group v-else-if="item.fieldType === 3 && item.multiple === 1" | 29 | <u-checkbox-group v-else-if="item.fieldType === 3 && item.multiple === 1" |
| 22 | v-model="inspectForm[item.fieldCode]"> | 30 | v-model="inspectForm[item.fieldCode]"> |
| @@ -120,6 +128,12 @@ | @@ -120,6 +128,12 @@ | ||
| 120 | <!-- ActionSheet 底部选择弹窗 --> | 128 | <!-- ActionSheet 底部选择弹窗 --> |
| 121 | <up-action-sheet :show="actionSheet.show" :actions="actionSheet.options" @select="onActionSheetSelect" | 129 | <up-action-sheet :show="actionSheet.show" :actions="actionSheet.options" @select="onActionSheetSelect" |
| 122 | @close="actionSheet.show = false" cancel-text="取消"></up-action-sheet> | 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 | </view> | 137 | </view> |
| 124 | </template> | 138 | </template> |
| 125 | 139 | ||
| @@ -178,6 +192,20 @@ export default { | @@ -178,6 +192,20 @@ export default { | ||
| 178 | fieldCode: '', // 当前操作的字段 | 192 | fieldCode: '', // 当前操作的字段 |
| 179 | options: [] as any[], | 193 | options: [] as any[], |
| 180 | fieldItem: null // 保存当前点击的field对象,用于后面查找label | 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,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 | openPickerIfNeeded(item: any) { | 258 | openPickerIfNeeded(item: any) { |
| 259 | + // 分层选项优先:它的只读入口是靠本方法打开弹层的(模板里 3/6 的原有分支已被 isGroupedField 挡在前面) | ||
| 260 | + if (this.isGroupedField(item)) { | ||
| 261 | + return this.openGroupedPicker(item) | ||
| 262 | + } | ||
| 231 | // 条件与模板互补:模板用 multiple === 1 渲染复选框组,这里就必须用 !== 1 才打开弹窗, | 263 | // 条件与模板互补:模板用 multiple === 1 渲染复选框组,这里就必须用 !== 1 才打开弹窗, |
| 232 | // 否则 multiple 为 NULL/0 的存量单选枚举会既不渲染复选框、又点不开弹窗 | 264 | // 否则 multiple 为 NULL/0 的存量单选枚举会既不渲染复选框、又点不开弹窗 |
| 233 | if (item.fieldType === 3 && item.multiple !== 1) { | 265 | if (item.fieldType === 3 && item.multiple !== 1) { |
| @@ -241,6 +273,51 @@ export default { | @@ -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 | * 打开底部选择弹窗 actionSheet | 321 | * 打开底部选择弹窗 actionSheet |
| 245 | */ | 322 | */ |
| 246 | openActionSheet(item: any) { | 323 | openActionSheet(item: any) { |
| @@ -343,7 +420,7 @@ export default { | @@ -343,7 +420,7 @@ export default { | ||
| 343 | // 由后端按 optionSource 分发。类型 3/6/9 需要选项,其余类型不需要 | 420 | // 由后端按 optionSource 分发。类型 3/6/9 需要选项,其余类型不需要 |
| 344 | if (field.fieldType === 3 || field.fieldType === 6 || field.fieldType === 9) { | 421 | if (field.fieldType === 3 || field.fieldType === 6 || field.fieldType === 9) { |
| 345 | if (field.optionSource === 1 && Array.isArray(field.options)) { | 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 | } else if (field.optionSource === 3) { | 424 | } else if (field.optionSource === 3) { |
| 348 | // 物料来源:调 materials(比 options 多返回 currentNum 库存余量/unit),供选择时显示库存 | 425 | // 物料来源:调 materials(比 options 多返回 currentNum 库存余量/unit),供选择时显示库存 |
| 349 | const matRes = await getMaterialsByCode({ | 426 | const matRes = await getMaterialsByCode({ |
| @@ -365,7 +442,8 @@ export default { | @@ -365,7 +442,8 @@ export default { | ||
| 365 | workTypeId, | 442 | workTypeId, |
| 366 | fieldCode: field.fieldCode | 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 | // 数量收集初始化(必须在 selectOptions 装载之后): | 449 | // 数量收集初始化(必须在 selectOptions 装载之后): |