Commit ecfadea2be4adc0f5657b4ecfc5b2f5eead054f1

Authored by 王彪总
1 parent 629df8b3

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

components/grouped-picker/grouped-picker.vue
1 1 <template>
2   - <!-- 分层选项选择器:顶部 tab → 左侧竖排侧栏 → 右侧列表(第 3 层做分组标题)
  2 + <!-- 分层选项选择器(样式按墨刀原型):顶部 tab → 左侧竖排侧栏 → 右侧列表(第 3 层起做吸顶分组)
  3 + 层级映射按原型两张屏反推:侧栏永远是「最后一层」,tab 只在有第 2 层时才出现。
  4 + path 只有 1 层 → 只有侧栏(原型「植物」屏:乔木/灌木/竹类… 纯侧栏,没有 tab);
  5 + path 有 2 层 → tab + 侧栏 + 平铺列表(原型「工具」屏:手工工具/电动工具/机械工具 → 剪切工具… → 太平剪);
  6 + 配到 g3 → 再多一层吸顶分组标题(物料 类型→类别→明细→物料 折出来的形态)。
  7 + 选择控件按原型:多选+数量时右侧是步进器(未选一个「+」,已选「− n +」,减到 1 再减即取消),
  8 + 不放勾选框;按钮是小白底圆角方框 + 浅灰描边(加减同款,不是圆形、不是灰底)。
3 9 行的勾选态由本组件的 draft 数据派生,不用 u-checkbox:
4 10 u-checkbox 的勾选态是组件内部状态(u-checkbox.vue:199-221 只在 init 时从父组派生),
5 11 而 u-checkbox-group 是把每个子实例的内部 isChecked 回读成数组(u-checkbox-group.vue:95-112),
6 12 子实例卸载后仍留在 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">
  13 + <up-popup :show="show" mode="bottom" :round="16" :safe-area-inset-bottom="true" :closeable="false"
  14 + @close="handleClose">
9 15 <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 16 <!-- 搜索:命中时跳过分组、对全量过滤,避免「切在某个 tab 里搜不到其它 tab 的项」 -->
17 17 <view class="gp-search">
18   - <up-input v-model="keyword" :placeholder="`搜索${title || ''}`" clearable border="surround"
19   - shape="circle"></up-input>
  18 + <up-input v-model="keyword" :placeholder="`请输入${title || ''}名称`" clearable border="surround"
  19 + shape="square"></up-input>
20 20 </view>
21 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>
  22 + <!-- 第 1 层:顶部 tab。原型是 3 个 tab 均分,所以 ≤4 个时不滚动(均分),
  23 + 真实物料有 6 个 material_type,均分会把长名字挤掉,这时才转成横向滚动 -->
  24 + <up-tabs v-if="!isSearching && tabList.length > 1" :list="tabList" :scrollable="tabScrollable"
  25 + v-model:current="tabIdx" @change="onTabChange" :lineWidth="30" :lineHeight="4"
  26 + activeColor="#2979ff" lineColor="#2979ff" inactiveColor="#606266"></up-tabs>
25 27  
26 28 <view class="gp-body">
27   - <!-- 第 2 层:左侧竖排侧栏 -->
  29 + <!-- 最后一层:左侧竖排侧栏(1 层字段时它就是唯一的一层,此时上面没有 tab) -->
28 30 <scroll-view v-if="!isSearching && sides.length > 0" scroll-y class="gp-side">
29 31 <view v-for="(side, idx) in sides" :key="side.key" class="gp-side-item"
30 32 :class="{ 'is-active': idx === sideIdx }" @click="switchSide(idx)">
... ... @@ -33,23 +35,42 @@
33 35 </view>
34 36 </scroll-view>
35 37  
36   - <!-- 第 3 层及更深:右侧列表,按最深可用层做分组标题 -->
  38 + <!-- 叶子列表:第 3 层起才按更深层做分组标题,1/2 层数据就是一条平铺列表 -->
37 39 <scroll-view scroll-y class="gp-list" :key="listKey">
38 40 <view v-for="group in visibleGroups" :key="group.key" class="gp-group">
39   - <view class="gp-group-title">{{ group.label }}</view>
  41 + <!-- 只有第 3 层起才有分组标题;1/2 层的字段这里是空标签,退化成一条平铺列表 -->
  42 + <view v-if="group.label" class="gp-group-title">{{ group.label }}</view>
40 43 <view v-for="row in group.rows" :key="row.value" class="gp-row"
41 44 :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 45 <view class="gp-row-main">
46 46 <text class="gp-row-label">{{ row.label }}</text>
47 47 <!-- 搜索态下补层级面包屑,否则不知道命中的项属于哪个分组 -->
48 48 <text v-if="breadcrumb(row)" class="gp-row-crumb">{{ breadcrumb(row) }}</text>
49 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>
  50 + <!-- 多选 + 数量(原型形态):右侧步进器表达选中态 —— 未选只有一个「+」,
  51 + 点了就选中并置 1;已选是「− n +」,减到 1 再减即取消选择 -->
  52 + <view v-if="needQuantity" class="gp-qty" @click.stop>
  53 + <view v-if="!isSelected(row)" class="gp-qty-btn is-plus"
  54 + :class="{ 'is-disabled': isLocked(row) }" @click="onPlus(row)">
  55 + <up-icon name="plus" size="12" color="#606266"></up-icon>
  56 + </view>
  57 + <template v-else>
  58 + <view class="gp-qty-btn is-minus" @click="onMinus(row)">
  59 + <up-icon name="minus" size="12" color="#606266"></up-icon>
  60 + </view>
  61 + <text class="gp-qty-num">{{ qtyOf(row) }}</text>
  62 + <view class="gp-qty-btn is-plus" @click="onPlus(row)">
  63 + <up-icon name="plus" size="12" color="#606266"></up-icon>
  64 + </view>
  65 + </template>
  66 + </view>
  67 + <!-- 多选无数值:原型没覆盖这种,保留自绘勾选框 -->
  68 + <view v-else-if="multiple" class="gp-check" :class="{ 'is-checked': isSelected(row) }">
  69 + <text v-if="isSelected(row)" class="gp-check-mark">✓</text>
  70 + </view>
  71 + <!-- 单选:原型没覆盖,右侧给一个选中标记 -->
  72 + <view v-else class="gp-radio">
  73 + <up-icon v-if="isSelected(row)" name="checkmark" size="16" color="#2979ff"></up-icon>
53 74 </view>
54 75 </view>
55 76 </view>
... ... @@ -58,7 +79,7 @@
58 79 </view>
59 80  
60 81 <view class="gp-actions">
61   - <view class="gp-action-item">
  82 + <view class="gp-action-item" :class="{ 'is-cancel': multiple }">
62 83 <up-button type="default" text="取消" @click="handleClose"></up-button>
63 84 </view>
64 85 <!-- 单选点行即提交,无需确定;多选才给确定按钮 -->
... ... @@ -86,8 +107,7 @@ const props = defineProps({
86 107 // 单选传字符串、多选传数组
87 108 value: { type: [String, Array], default: '' },
88 109 // { 选项值: 数量 }
89   - quantities: { type: Object, default: () => ({}) },
90   - unit: { type: String, default: '' }
  110 + quantities: { type: Object, default: () => ({}) }
91 111 })
92 112  
93 113 const emit = defineEmits(['update:show', 'confirm'])
... ... @@ -99,7 +119,7 @@ const tabIdx = ref(0)
99 119 const sideIdx = ref(0)
100 120 const draftSelected = ref([])
101 121 const draftValue = ref('')
102   -// 数量表用 reactive 而非 ref:模板里要 v-model 到动态键(draftQty[row.value]),
  122 +// 数量表用 reactive 而非 ref:模板里要按动态键读写(draftQty[row.value]),
103 123 // reactive 直接写属性即可,不依赖编译器把 ref 改写成 .value[key](本仓无法本地构建,不赌这个)
104 124 const draftQty = reactive({})
105 125  
... ... @@ -109,14 +129,18 @@ const rows = computed(() =&gt;
109 129 )
110 130 // 层数按「行里 path 的最大长度」定,兼容同一个字段里长短不一的行
111 131 const depth = computed(() => rows.value.reduce((m, r) => Math.max(m, (r.path || []).length), 0))
112   -// 层级→用途映射:>=3 层才有侧栏;2 层只用 tab;1 层直接平铺(带该层名分组)
  132 +// 层级→用途映射(按墨刀原型两张屏反推):**侧栏永远吃最后一层**,tab 只在有第 2 层时才出现。
  133 +// 1 层 → 只有侧栏(原型「植物」屏:乔木/灌木/竹类… 没有 tab);
  134 +// 2 层 → tab + 侧栏 + 平铺列表(原型「工具」屏:手工工具/电动工具/机械工具 → 剪切工具… → 太平剪);
  135 +// 3 层 → tab + 侧栏 + 吸顶分组标题(物料 类型→类别→明细→物料 折出来的形态)。
113 136 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   -})
  137 +const sideLevel = computed(() => (depth.value >= 2 ? 1 : depth.value === 1 ? 0 : -1))
  138 +// 从第 3 层(下标 2)开始才是分组标题
  139 +const GROUP_START = 2
  140 +// 没有更深的层级就不分组、也不显示分组标题
  141 +const hasGroupTitle = computed(() => depth.value > GROUP_START)
  142 +// tab 多了就横向滚动:原型 3 个 tab 均分铺满,6 个 material_type 均分会挤掉长名字
  143 +const tabScrollable = computed(() => tabList.value.length > 4)
120 144  
121 145 const nodeAt = (row, i) => {
122 146 const p = row.path
... ... @@ -133,12 +157,12 @@ const labelAt = (row, i) =&gt; {
133 157 // 分组键用 \u0001 连接(层级值里不会出现该字符),分组名用 ' / ' 连接更深层
134 158 const groupKeyOf = (row) => {
135 159 const parts = []
136   - for (let i = groupStart.value; i < (row.path || []).length; i++) parts.push(keyAt(row, i))
  160 + for (let i = GROUP_START; i < (row.path || []).length; i++) parts.push(keyAt(row, i))
137 161 return parts.join('\u0001') || '__none__'
138 162 }
139 163 const groupLabelOf = (row) => {
140 164 const parts = []
141   - for (let i = groupStart.value; i < (row.path || []).length; i++) parts.push(labelAt(row, i))
  165 + for (let i = GROUP_START; i < (row.path || []).length; i++) parts.push(labelAt(row, i))
142 166 return parts.join(' / ') || '其他'
143 167 }
144 168  
... ... @@ -169,7 +193,8 @@ const sides = computed(() =&gt; {
169 193 if (!seen.has(k)) seen.set(k, { key: k, label: labelAt(r, sideLevel.value), count: 0 })
170 194 }
171 195 const list = Array.from(seen.values())
172   - // 已选数量角标:已选项的勾选框在别的 tab/侧栏时是卸载状态,只能靠角标让用户知道选过什么
  196 + // 已选数量角标:已选项不在当前 tab/侧栏时整行是卸载状态,只能靠角标让用户知道选过什么
  197 + //(原型没有角标,可不带它选中的项就完全看不见了,所以保留)
173 198 for (const side of list) {
174 199 side.count = draftSelected.value.filter(v => {
175 200 const row = rows.value.find(r => String(r.value) === v)
... ... @@ -200,6 +225,11 @@ const visibleGroups = computed(() =&gt; {
200 225 ? [{ key: '__search__', label: `搜索结果(${searchedRows.value.length})`, rows: searchedRows.value }]
201 226 : []
202 227 }
  228 + // 1/2 层数据(原型形态:侧栏刚好是最外层/最后一层)没有第 3 层可用,
  229 + // 整段平铺、不出分组标题
  230 + if (!hasGroupTitle.value) {
  231 + return rowsInSide.value.length ? [{ key: '__all__', label: '', rows: rowsInSide.value }] : []
  232 + }
203 233 const out = []
204 234 const seen = new Map()
205 235 for (const r of rowsInSide.value) {
... ... @@ -231,15 +261,48 @@ const isLocked = (row) =&gt; {
231 261 if (max === Infinity || max === undefined || max === null) return false
232 262 return draftSelected.value.length >= max && !isSelected(row)
233 263 }
234   -const hintText = computed(() => {
235   - const max = props.maxCount
  264 +// ========== 数量(原型形态:选中态与数量都由这一个步进器表达) ==========
  265 +const qtyOf = (row) => {
  266 + const n = Number(draftQty[String(row.value)])
  267 + return !isNaN(n) && n >= 1 ? n : 1
  268 +}
  269 +const deselect = (v) => {
  270 + const i = draftSelected.value.indexOf(v)
  271 + if (i > -1) draftSelected.value.splice(i, 1)
  272 + delete draftQty[v]
  273 +}
  274 +// 「+」:未选先选中并置 1,已选则 +1
  275 +const onPlus = (row) => {
  276 + if (isLocked(row)) {
  277 + uni.showToast({ title: `最多选择 ${props.maxCount} 项`, icon: 'none' })
  278 + return
  279 + }
  280 + const v = String(row.value)
  281 + if (!isSelected(row)) {
  282 + draftSelected.value.push(v)
  283 + draftQty[v] = 1
  284 + return
  285 + }
  286 + draftQty[v] = qtyOf(row) + 1
  287 +}
  288 +// 「−」:减到 1 再减就是取消选择 —— 没有勾选框后,这是唯一的取消入口
  289 +const onMinus = (row) => {
  290 + const v = String(row.value)
  291 + const cur = qtyOf(row)
  292 + if (cur <= 1) {
  293 + deselect(v)
  294 + return
  295 + }
  296 + draftQty[v] = cur - 1
  297 +}
  298 +
  299 +// 已选数放在确定按钮上(原型顶部没有标题条,不另占一行)
  300 +const confirmText = computed(() => {
236 301 const n = draftSelected.value.length
237   - if (max === Infinity || max === undefined || max === null) return n ? `已选 ${n}` : ''
238   - return `已选 ${n}/${max}`
  302 + const max = props.maxCount
  303 + if (max === Infinity || max === undefined || max === null) return n ? `确定(${n})` : '确定'
  304 + return `确定(${n}/${max})`
239 305 })
240   -const confirmText = computed(() =>
241   - draftSelected.value.length ? `确定(${draftSelected.value.length})` : '确定'
242   -)
243 306  
244 307 // ========== 交互 ==========
245 308 const onTabChange = () => {
... ... @@ -253,16 +316,18 @@ const switchSide = (idx) =&gt; {
253 316 const onRowClick = (row) => {
254 317 const v = String(row.value)
255 318 if (props.multiple) {
  319 + // 整行点击也能选(不必非要点中那个「+」),选中就默认数量 1。
  320 + // 带数量的字段点整行「取消」要禁用:取消统一走「−」,否则误点一下就把加好的数量清了
  321 + if (isSelected(row)) {
  322 + if (!props.needQuantity) deselect(v)
  323 + return
  324 + }
256 325 if (isLocked(row)) {
257 326 uni.showToast({ title: `最多选择 ${props.maxCount} 项`, icon: 'none' })
258 327 return
259 328 }
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   - }
  329 + draftSelected.value.push(v)
  330 + if (props.needQuantity && !draftQty[v]) draftQty[v] = 1
266 331 return
267 332 }
268 333 // 单选:点行即确认并关闭,与底部 actionSheet 的即时选中语义一致
... ... @@ -284,12 +349,8 @@ const handleConfirm = () =&gt; {
284 349 }
285 350 const quantities = {}
286 351 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   - })
  352 + // 选中行的数量恒为 ≥1 的整数(步进器保证),不再存在「已选但数量为 0」的行
  353 + sel.forEach(r => { quantities[String(r.value)] = qtyOf(r) })
293 354 }
294 355 emit('confirm', {
295 356 values: props.multiple ? draftSelected.value.slice() : draftValue.value,
... ... @@ -342,26 +403,8 @@ watch(
342 403 flex-direction: column;
343 404 }
344 405  
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 406 .gp-search {
364   - padding: 20rpx 30rpx 10rpx;
  407 + padding: 24rpx 24rpx 16rpx;
365 408 }
366 409  
367 410 .gp-body {
... ... @@ -372,32 +415,35 @@ watch(
372 415  
373 416 /* 侧栏 */
374 417 .gp-side {
375   - width: 200rpx;
  418 + width: 180rpx;
376 419 height: 100%;
377 420 background: #f7f8fa;
378 421 }
379 422  
380 423 .gp-side-item {
381 424 position: relative;
  425 + box-sizing: border-box;
382 426 display: flex;
383 427 align-items: center;
384 428 justify-content: center;
385   - min-height: 88rpx;
  429 + min-height: 96rpx;
386 430 padding: 0 12rpx;
387   - font-size: 26rpx;
  431 + font-size: 28rpx;
388 432 color: #606266;
389 433 text-align: center;
390 434  
  435 + /* 原型:选中项是一张白色圆角卡片铺满侧栏宽度,文字仍是深色(不变蓝、不加左侧竖条) */
391 436 &.is-active {
  437 + margin-right: 8rpx;
  438 + border-radius: 12rpx;
392 439 background: #ffffff;
393   - color: #2979ff;
  440 + color: #303133;
394 441 font-weight: 600;
395   - border-left: 6rpx solid #2979ff;
396 442 }
397 443 }
398 444  
399 445 .gp-side-text {
400   - font-size: 26rpx;
  446 + font-size: 28rpx;
401 447 }
402 448  
403 449 .gp-side-badge {
... ... @@ -433,11 +479,12 @@ watch(
433 479 }
434 480  
435 481 .gp-row {
  482 + box-sizing: border-box;
436 483 display: flex;
437 484 align-items: center;
438   - padding: 22rpx 24rpx;
439   - border-bottom: 1rpx solid #f2f3f5;
440   -
  485 + min-height: 100rpx;
  486 + padding: 0 24rpx;
  487 + /* 原型行之间是纯留白、没有分隔线 */
441 488 &.is-locked {
442 489 opacity: 0.4;
443 490 }
... ... @@ -484,17 +531,45 @@ watch(
484 531 color: #909399;
485 532 }
486 533  
487   -.gp-row-qty {
  534 +/* 数量步进器(原型形态):未选只有一个「+」,选中后是「− n +」 */
  535 +.gp-qty {
488 536 flex-shrink: 0;
489 537 display: flex;
490 538 align-items: center;
491 539 margin-left: 12rpx;
492 540 }
493 541  
494   -.gp-row-unit {
495   - margin-left: 8rpx;
496   - font-size: 24rpx;
497   - color: #909399;
  542 +.gp-qty-btn {
  543 + box-sizing: border-box;
  544 + width: 44rpx;
  545 + height: 44rpx;
  546 + /* 原型:小圆角方框 + 浅灰描边 + 白底,「+」「−」同款 */
  547 + border: 1rpx solid #dcdfe6;
  548 + border-radius: 8rpx;
  549 + background: #ffffff;
  550 + display: flex;
  551 + align-items: center;
  552 + justify-content: center;
  553 +
  554 + &.is-disabled {
  555 + opacity: 0.4;
  556 + }
  557 +}
  558 +
  559 +.gp-qty-num {
  560 + min-width: 72rpx;
  561 + font-size: 28rpx;
  562 + color: #303133;
  563 + text-align: center;
  564 +}
  565 +
  566 +/* 单选(原型未覆盖):右侧只留一个选中标记,同样不放勾选框 */
  567 +.gp-radio {
  568 + flex-shrink: 0;
  569 + width: 40rpx;
  570 + display: flex;
  571 + align-items: center;
  572 + justify-content: center;
498 573 }
499 574  
500 575 .gp-empty {
... ... @@ -514,4 +589,9 @@ watch(
514 589 flex: 1;
515 590 padding: 20rpx;
516 591 }
  592 +
  593 +/* 有确定按钮时把取消收窄:两边等分的话「确定(3/10)」会超出按钮宽度被裁掉(真机已复现) */
  594 +.gp-action-item.is-cancel {
  595 + flex: 0 0 200rpx;
  596 +}
517 597 </style>
... ...
pages-sub/daily/maintain-manage/add-record.vue
... ... @@ -132,7 +132,7 @@
132 132 <grouped-picker :show="groupPicker.show" :title="groupPicker.title" :options="groupPicker.options"
133 133 :multiple="groupPicker.multiple" :max-count="groupPicker.maxCount"
134 134 :need-quantity="groupPicker.needQuantity" :value="groupPicker.value"
135   - :quantities="groupPicker.quantities" :unit="groupPicker.unit" @update:show="groupPicker.show = $event"
  135 + :quantities="groupPicker.quantities" @update:show="groupPicker.show = $event"
136 136 @confirm="onGroupedConfirm"></grouped-picker>
137 137 </view>
138 138 </template>
... ... @@ -205,6 +205,7 @@ export default {
205 205 needQuantity: false,
206 206 value: '' as any,
207 207 quantities: {} as any,
  208 + // 数量单位不再传进弹层(原型列表行右侧只有步进器,没有单位),页面行内仍显示 item.unit
208 209 unit: ''
209 210 }
210 211 }
... ... @@ -299,7 +300,6 @@ export default {
299 300 this.groupPicker.needQuantity = item.fieldType === 6 && this.needQty(item)
300 301 this.groupPicker.value = multiple ? (this.inspectForm[code] || []).slice() : (this.inspectForm[code] || '')
301 302 this.groupPicker.quantities = Object.assign({}, this.qtyMap[code] || {})
302   - this.groupPicker.unit = item.unit || ''
303 303 this.groupPicker.show = true
304 304 },
305 305 /**
... ...