Commit ecfadea2be4adc0f5657b4ecfc5b2f5eead054f1

Authored by 王彪总
1 parent 629df8b3

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

components/grouped-picker/grouped-picker.vue
1 <template> 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 行的勾选态由本组件的 draft 数据派生,不用 u-checkbox: 9 行的勾选态由本组件的 draft 数据派生,不用 u-checkbox:
4 u-checkbox 的勾选态是组件内部状态(u-checkbox.vue:199-221 只在 init 时从父组派生), 10 u-checkbox 的勾选态是组件内部状态(u-checkbox.vue:199-221 只在 init 时从父组派生),
5 而 u-checkbox-group 是把每个子实例的内部 isChecked 回读成数组(u-checkbox-group.vue:95-112), 11 而 u-checkbox-group 是把每个子实例的内部 isChecked 回读成数组(u-checkbox-group.vue:95-112),
6 子实例卸载后仍留在 children 里 —— 本弹层「切 tab 就换一批行」正好会命中这个坑(会读出幽灵勾选/重复值)。 --> 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 <view class="gp-wrap"> 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 <!-- 搜索:命中时跳过分组、对全量过滤,避免「切在某个 tab 里搜不到其它 tab 的项」 --> 16 <!-- 搜索:命中时跳过分组、对全量过滤,避免「切在某个 tab 里搜不到其它 tab 的项」 -->
17 <view class="gp-search"> 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 </view> 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 <view class="gp-body"> 28 <view class="gp-body">
27 - <!-- 第 2 层:左侧竖排侧栏 --> 29 + <!-- 最后一层:左侧竖排侧栏(1 层字段时它就是唯一的一层,此时上面没有 tab) -->
28 <scroll-view v-if="!isSearching && sides.length > 0" scroll-y class="gp-side"> 30 <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" 31 <view v-for="(side, idx) in sides" :key="side.key" class="gp-side-item"
30 :class="{ 'is-active': idx === sideIdx }" @click="switchSide(idx)"> 32 :class="{ 'is-active': idx === sideIdx }" @click="switchSide(idx)">
@@ -33,23 +35,42 @@ @@ -33,23 +35,42 @@
33 </view> 35 </view>
34 </scroll-view> 36 </scroll-view>
35 37
36 - <!-- 第 3 层及更深:右侧列表,按最深可用层做分组标题 --> 38 + <!-- 叶子列表:第 3 层起才按更深层做分组标题,1/2 层数据就是一条平铺列表 -->
37 <scroll-view scroll-y class="gp-list" :key="listKey"> 39 <scroll-view scroll-y class="gp-list" :key="listKey">
38 <view v-for="group in visibleGroups" :key="group.key" class="gp-group"> 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 <view v-for="row in group.rows" :key="row.value" class="gp-row" 43 <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)"> 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 <view class="gp-row-main"> 45 <view class="gp-row-main">
46 <text class="gp-row-label">{{ row.label }}</text> 46 <text class="gp-row-label">{{ row.label }}</text>
47 <!-- 搜索态下补层级面包屑,否则不知道命中的项属于哪个分组 --> 47 <!-- 搜索态下补层级面包屑,否则不知道命中的项属于哪个分组 -->
48 <text v-if="breadcrumb(row)" class="gp-row-crumb">{{ breadcrumb(row) }}</text> 48 <text v-if="breadcrumb(row)" class="gp-row-crumb">{{ breadcrumb(row) }}</text>
49 </view> 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 </view> 74 </view>
54 </view> 75 </view>
55 </view> 76 </view>
@@ -58,7 +79,7 @@ @@ -58,7 +79,7 @@
58 </view> 79 </view>
59 80
60 <view class="gp-actions"> 81 <view class="gp-actions">
61 - <view class="gp-action-item"> 82 + <view class="gp-action-item" :class="{ 'is-cancel': multiple }">
62 <up-button type="default" text="取消" @click="handleClose"></up-button> 83 <up-button type="default" text="取消" @click="handleClose"></up-button>
63 </view> 84 </view>
64 <!-- 单选点行即提交,无需确定;多选才给确定按钮 --> 85 <!-- 单选点行即提交,无需确定;多选才给确定按钮 -->
@@ -86,8 +107,7 @@ const props = defineProps({ @@ -86,8 +107,7 @@ const props = defineProps({
86 // 单选传字符串、多选传数组 107 // 单选传字符串、多选传数组
87 value: { type: [String, Array], default: '' }, 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 const emit = defineEmits(['update:show', 'confirm']) 113 const emit = defineEmits(['update:show', 'confirm'])
@@ -99,7 +119,7 @@ const tabIdx = ref(0) @@ -99,7 +119,7 @@ const tabIdx = ref(0)
99 const sideIdx = ref(0) 119 const sideIdx = ref(0)
100 const draftSelected = ref([]) 120 const draftSelected = ref([])
101 const draftValue = ref('') 121 const draftValue = ref('')
102 -// 数量表用 reactive 而非 ref:模板里要 v-model 到动态键(draftQty[row.value]), 122 +// 数量表用 reactive 而非 ref:模板里要按动态键读写(draftQty[row.value]),
103 // reactive 直接写属性即可,不依赖编译器把 ref 改写成 .value[key](本仓无法本地构建,不赌这个) 123 // reactive 直接写属性即可,不依赖编译器把 ref 改写成 .value[key](本仓无法本地构建,不赌这个)
104 const draftQty = reactive({}) 124 const draftQty = reactive({})
105 125
@@ -109,14 +129,18 @@ const rows = computed(() =&gt; @@ -109,14 +129,18 @@ const rows = computed(() =&gt;
109 ) 129 )
110 // 层数按「行里 path 的最大长度」定,兼容同一个字段里长短不一的行 130 // 层数按「行里 path 的最大长度」定,兼容同一个字段里长短不一的行
111 const depth = computed(() => rows.value.reduce((m, r) => Math.max(m, (r.path || []).length), 0)) 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 const tabLevel = computed(() => (depth.value >= 2 ? 0 : -1)) 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 const nodeAt = (row, i) => { 145 const nodeAt = (row, i) => {
122 const p = row.path 146 const p = row.path
@@ -133,12 +157,12 @@ const labelAt = (row, i) =&gt; { @@ -133,12 +157,12 @@ const labelAt = (row, i) =&gt; {
133 // 分组键用 \u0001 连接(层级值里不会出现该字符),分组名用 ' / ' 连接更深层 157 // 分组键用 \u0001 连接(层级值里不会出现该字符),分组名用 ' / ' 连接更深层
134 const groupKeyOf = (row) => { 158 const groupKeyOf = (row) => {
135 const parts = [] 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 return parts.join('\u0001') || '__none__' 161 return parts.join('\u0001') || '__none__'
138 } 162 }
139 const groupLabelOf = (row) => { 163 const groupLabelOf = (row) => {
140 const parts = [] 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 return parts.join(' / ') || '其他' 166 return parts.join(' / ') || '其他'
143 } 167 }
144 168
@@ -169,7 +193,8 @@ const sides = computed(() =&gt; { @@ -169,7 +193,8 @@ const sides = computed(() =&gt; {
169 if (!seen.has(k)) seen.set(k, { key: k, label: labelAt(r, sideLevel.value), count: 0 }) 193 if (!seen.has(k)) seen.set(k, { key: k, label: labelAt(r, sideLevel.value), count: 0 })
170 } 194 }
171 const list = Array.from(seen.values()) 195 const list = Array.from(seen.values())
172 - // 已选数量角标:已选项的勾选框在别的 tab/侧栏时是卸载状态,只能靠角标让用户知道选过什么 196 + // 已选数量角标:已选项不在当前 tab/侧栏时整行是卸载状态,只能靠角标让用户知道选过什么
  197 + //(原型没有角标,可不带它选中的项就完全看不见了,所以保留)
173 for (const side of list) { 198 for (const side of list) {
174 side.count = draftSelected.value.filter(v => { 199 side.count = draftSelected.value.filter(v => {
175 const row = rows.value.find(r => String(r.value) === v) 200 const row = rows.value.find(r => String(r.value) === v)
@@ -200,6 +225,11 @@ const visibleGroups = computed(() =&gt; { @@ -200,6 +225,11 @@ const visibleGroups = computed(() =&gt; {
200 ? [{ key: '__search__', label: `搜索结果(${searchedRows.value.length})`, rows: searchedRows.value }] 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 const out = [] 233 const out = []
204 const seen = new Map() 234 const seen = new Map()
205 for (const r of rowsInSide.value) { 235 for (const r of rowsInSide.value) {
@@ -231,15 +261,48 @@ const isLocked = (row) =&gt; { @@ -231,15 +261,48 @@ const isLocked = (row) =&gt; {
231 if (max === Infinity || max === undefined || max === null) return false 261 if (max === Infinity || max === undefined || max === null) return false
232 return draftSelected.value.length >= max && !isSelected(row) 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 const n = draftSelected.value.length 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 const onTabChange = () => { 308 const onTabChange = () => {
@@ -253,16 +316,18 @@ const switchSide = (idx) =&gt; { @@ -253,16 +316,18 @@ const switchSide = (idx) =&gt; {
253 const onRowClick = (row) => { 316 const onRowClick = (row) => {
254 const v = String(row.value) 317 const v = String(row.value)
255 if (props.multiple) { 318 if (props.multiple) {
  319 + // 整行点击也能选(不必非要点中那个「+」),选中就默认数量 1。
  320 + // 带数量的字段点整行「取消」要禁用:取消统一走「−」,否则误点一下就把加好的数量清了
  321 + if (isSelected(row)) {
  322 + if (!props.needQuantity) deselect(v)
  323 + return
  324 + }
256 if (isLocked(row)) { 325 if (isLocked(row)) {
257 uni.showToast({ title: `最多选择 ${props.maxCount} 项`, icon: 'none' }) 326 uni.showToast({ title: `最多选择 ${props.maxCount} 项`, icon: 'none' })
258 return 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 return 331 return
267 } 332 }
268 // 单选:点行即确认并关闭,与底部 actionSheet 的即时选中语义一致 333 // 单选:点行即确认并关闭,与底部 actionSheet 的即时选中语义一致
@@ -284,12 +349,8 @@ const handleConfirm = () =&gt; { @@ -284,12 +349,8 @@ const handleConfirm = () =&gt; {
284 } 349 }
285 const quantities = {} 350 const quantities = {}
286 if (props.needQuantity) { 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 emit('confirm', { 355 emit('confirm', {
295 values: props.multiple ? draftSelected.value.slice() : draftValue.value, 356 values: props.multiple ? draftSelected.value.slice() : draftValue.value,
@@ -342,26 +403,8 @@ watch( @@ -342,26 +403,8 @@ watch(
342 flex-direction: column; 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 .gp-search { 406 .gp-search {
364 - padding: 20rpx 30rpx 10rpx; 407 + padding: 24rpx 24rpx 16rpx;
365 } 408 }
366 409
367 .gp-body { 410 .gp-body {
@@ -372,32 +415,35 @@ watch( @@ -372,32 +415,35 @@ watch(
372 415
373 /* 侧栏 */ 416 /* 侧栏 */
374 .gp-side { 417 .gp-side {
375 - width: 200rpx; 418 + width: 180rpx;
376 height: 100%; 419 height: 100%;
377 background: #f7f8fa; 420 background: #f7f8fa;
378 } 421 }
379 422
380 .gp-side-item { 423 .gp-side-item {
381 position: relative; 424 position: relative;
  425 + box-sizing: border-box;
382 display: flex; 426 display: flex;
383 align-items: center; 427 align-items: center;
384 justify-content: center; 428 justify-content: center;
385 - min-height: 88rpx; 429 + min-height: 96rpx;
386 padding: 0 12rpx; 430 padding: 0 12rpx;
387 - font-size: 26rpx; 431 + font-size: 28rpx;
388 color: #606266; 432 color: #606266;
389 text-align: center; 433 text-align: center;
390 434
  435 + /* 原型:选中项是一张白色圆角卡片铺满侧栏宽度,文字仍是深色(不变蓝、不加左侧竖条) */
391 &.is-active { 436 &.is-active {
  437 + margin-right: 8rpx;
  438 + border-radius: 12rpx;
392 background: #ffffff; 439 background: #ffffff;
393 - color: #2979ff; 440 + color: #303133;
394 font-weight: 600; 441 font-weight: 600;
395 - border-left: 6rpx solid #2979ff;  
396 } 442 }
397 } 443 }
398 444
399 .gp-side-text { 445 .gp-side-text {
400 - font-size: 26rpx; 446 + font-size: 28rpx;
401 } 447 }
402 448
403 .gp-side-badge { 449 .gp-side-badge {
@@ -433,11 +479,12 @@ watch( @@ -433,11 +479,12 @@ watch(
433 } 479 }
434 480
435 .gp-row { 481 .gp-row {
  482 + box-sizing: border-box;
436 display: flex; 483 display: flex;
437 align-items: center; 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 &.is-locked { 488 &.is-locked {
442 opacity: 0.4; 489 opacity: 0.4;
443 } 490 }
@@ -484,17 +531,45 @@ watch( @@ -484,17 +531,45 @@ watch(
484 color: #909399; 531 color: #909399;
485 } 532 }
486 533
487 -.gp-row-qty { 534 +/* 数量步进器(原型形态):未选只有一个「+」,选中后是「− n +」 */
  535 +.gp-qty {
488 flex-shrink: 0; 536 flex-shrink: 0;
489 display: flex; 537 display: flex;
490 align-items: center; 538 align-items: center;
491 margin-left: 12rpx; 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 .gp-empty { 575 .gp-empty {
@@ -514,4 +589,9 @@ watch( @@ -514,4 +589,9 @@ watch(
514 flex: 1; 589 flex: 1;
515 padding: 20rpx; 590 padding: 20rpx;
516 } 591 }
  592 +
  593 +/* 有确定按钮时把取消收窄:两边等分的话「确定(3/10)」会超出按钮宽度被裁掉(真机已复现) */
  594 +.gp-action-item.is-cancel {
  595 + flex: 0 0 200rpx;
  596 +}
517 </style> 597 </style>
pages-sub/daily/maintain-manage/add-record.vue
@@ -132,7 +132,7 @@ @@ -132,7 +132,7 @@
132 <grouped-picker :show="groupPicker.show" :title="groupPicker.title" :options="groupPicker.options" 132 <grouped-picker :show="groupPicker.show" :title="groupPicker.title" :options="groupPicker.options"
133 :multiple="groupPicker.multiple" :max-count="groupPicker.maxCount" 133 :multiple="groupPicker.multiple" :max-count="groupPicker.maxCount"
134 :need-quantity="groupPicker.needQuantity" :value="groupPicker.value" 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 @confirm="onGroupedConfirm"></grouped-picker> 136 @confirm="onGroupedConfirm"></grouped-picker>
137 </view> 137 </view>
138 </template> 138 </template>
@@ -205,6 +205,7 @@ export default { @@ -205,6 +205,7 @@ export default {
205 needQuantity: false, 205 needQuantity: false,
206 value: '' as any, 206 value: '' as any,
207 quantities: {} as any, 207 quantities: {} as any,
  208 + // 数量单位不再传进弹层(原型列表行右侧只有步进器,没有单位),页面行内仍显示 item.unit
208 unit: '' 209 unit: ''
209 } 210 }
210 } 211 }
@@ -299,7 +300,6 @@ export default { @@ -299,7 +300,6 @@ export default {
299 this.groupPicker.needQuantity = item.fieldType === 6 && this.needQty(item) 300 this.groupPicker.needQuantity = item.fieldType === 6 && this.needQty(item)
300 this.groupPicker.value = multiple ? (this.inspectForm[code] || []).slice() : (this.inspectForm[code] || '') 301 this.groupPicker.value = multiple ? (this.inspectForm[code] || []).slice() : (this.inspectForm[code] || '')
301 this.groupPicker.quantities = Object.assign({}, this.qtyMap[code] || {}) 302 this.groupPicker.quantities = Object.assign({}, this.qtyMap[code] || {})
302 - this.groupPicker.unit = item.unit || ''  
303 this.groupPicker.show = true 303 this.groupPicker.show = true
304 }, 304 },
305 /** 305 /**