fix: CitySelect 集合地/解散地下拉显示 JSON 字符串 BUG (任务 20260420)
这个提交包含在:
父节点
42ba23bba6
当前提交
dcd70ed61b
@ -0,0 +1,254 @@
|
|||||||
|
# fix(前端): CitySelect 集合地/解散地下拉显示 JSON 字符串而非城市名
|
||||||
|
|
||||||
|
- **类型**: BUG(前端)
|
||||||
|
- **优先级**: P1(核心产品编辑功能体验严重异常)
|
||||||
|
- **影响端**: 管理后台(hl-ui)
|
||||||
|
- **后端是否需改动**: ❌ 不需要。DB / API 数据完全正确,本次修复仅前端
|
||||||
|
- **是否影响保存**: ❌ 不影响。保存数据完全正确,仅显示层 BUG,前端可放心修
|
||||||
|
- **任务文件**: `D:/work2/HL/docs/tasks/20260420_修复_行程开始结束下拉显示JSON.md`
|
||||||
|
- **报 BUG 时间**: 2026-04-20(wx 截图反馈)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现象描述
|
||||||
|
|
||||||
|
### 触发路径
|
||||||
|
管理后台 → **产品编辑** → **行程编排** → **任意 DAY**(截图为 DAY 3「莫日格勒河之旅」)
|
||||||
|
|
||||||
|
页面 URL 示例:`http://192.168.100.160:9527/product/edit?id=2045018534152478721`
|
||||||
|
|
||||||
|
### 字段名说明(重要:截图标签 ≠ 代码字段名 ≠ 设计文档名)
|
||||||
|
|
||||||
|
| 截图上显示 | 设计文档 / 业务术语 | 前端 v-model | 后端字段 | DB 列 |
|
||||||
|
|-----------|---------------------|-------------|---------|-------|
|
||||||
|
| **开始** | 集合地 | `gatherPlace` | `gatherPlace` | `gather_place` |
|
||||||
|
| **结束** | 解散地 | `dismissalPlace` | `dismissalPlace` | `dismissal_place` |
|
||||||
|
|
||||||
|
> 即截图所说的「开始 / 结束」就是「集合地 / 解散地」就是 `gatherPlace / dismissalPlace`。三套名字完全等价。
|
||||||
|
|
||||||
|
### 错误现象
|
||||||
|
两个下拉框(CitySelect 实例)的**已选中显示文本**直接渲染成了 JSON 字符串:
|
||||||
|
|
||||||
|
```
|
||||||
|
{"name":"黑山头镇","level"...
|
||||||
|
{"name":"海拉尔区","level"...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 期望现象
|
||||||
|
应该只显示城市名,例如:
|
||||||
|
- 「黑山头镇」或 fullPath「内蒙古自治区/呼伦贝尔市/额尔古纳市/黑山头镇」
|
||||||
|
- 「海拉尔区」或 fullPath「内蒙古自治区/呼伦贝尔市/海拉尔区」
|
||||||
|
|
||||||
|
### 对照参考(同 DAY 内显示正常的元素)
|
||||||
|
- 同一 DAY 的 **「途经路线」** 显示正常:黑山头镇 → 白桦林景区 → 莫日格勒河 → 海拉尔区
|
||||||
|
- 这部分走的是 `<span>{{ pt.name }}</span>` 直接渲染(`RouteTagBar.vue` 行 70-82),**不经过 n-select**,所以不会触发本 BUG。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、根因分析(前端 BUG,已通过三尺定位确认)
|
||||||
|
|
||||||
|
### 三尺数据验证
|
||||||
|
1. **DB 真值**(`hl_product_service.product_itinerary_day` 表)✅ 数据合法:
|
||||||
|
```json
|
||||||
|
{"name":"黑山头镇","level":"street","adcode":"150784","fullPath":"内蒙古自治区/呼伦贝尔市/额尔古纳市/黑山头镇","levelName":"镇"}
|
||||||
|
```
|
||||||
|
2. **API 响应**(GET `/admin/product/item/{id}`)✅ 原样返回 dict(`JsonObjectTypeHandler` 自动反序列化为 `LinkedHashMap`),契约一致。
|
||||||
|
3. **前端渲染**(`CitySelect.vue` + `n-select` `tag` 模式)❌ **BUG 在此**
|
||||||
|
|
||||||
|
### 直接 BUG 文件
|
||||||
|
**文件**:`D:/work2/hl-ui/src/components/CitySelect.vue`
|
||||||
|
|
||||||
|
**关键代码段**:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// 行 58-64:n-select 的 value 永远是 stringify 字符串
|
||||||
|
const currentValue = computed(() => {
|
||||||
|
if (!props.value) return null
|
||||||
|
if (props.nameOnly) return props.value || null
|
||||||
|
return JSON.stringify(props.value) // → '{"name":"黑山头镇","level":...}'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 行 143-161:watch immediate,仅当 options 为空时注入 1 项
|
||||||
|
watch(() => props.value, (newVal) => {
|
||||||
|
if (newVal && options.value.length === 0) {
|
||||||
|
options.value = [{
|
||||||
|
label: formatLabel(newVal),
|
||||||
|
value: JSON.stringify(newVal),
|
||||||
|
tip: newVal,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
**模板**(行 164-180):
|
||||||
|
```html
|
||||||
|
<n-select
|
||||||
|
:value="currentValue" <!-- '{"name":"黑山头镇",...}' -->
|
||||||
|
:options="options"
|
||||||
|
filterable remote tag <!-- ⚠️ 关键:tag 模式 -->
|
||||||
|
:filter="() => true"
|
||||||
|
...
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 根因机制(n-select tag 模式 + watch 时序竞态)
|
||||||
|
1. n-select 启用 `tag` 模式后,若传入的 `value`(已是 stringify 后的 JSON 串)在初次渲染时**尚未在 `options` 中匹配到**,会把 value 字符串本身**当作"用户已创建的标签"**渲染出来,并**缓存该标签**。
|
||||||
|
2. `watch` 虽然 `immediate: true` 注入 option,但因 `props.value` 在 setup 阶段可能为 null(store 异步加载详情数据)→ watch 跳过 → options 为空。
|
||||||
|
3. 后续 store 注入数据触发 watch 再补 option 时,**n-select 已经把"未匹配 value"渲染成 stringify 标签了**,并不会回头重新匹配 label。
|
||||||
|
|
||||||
|
### 直接证据链
|
||||||
|
- DAY 3 `dismissalPlace.name` 是「海拉尔区」→ 截图显示 `{"name":"海拉尔区","level"...`
|
||||||
|
- 截图字符串 = `JSON.stringify(props.value)` 的前缀,与代码逻辑完全吻合
|
||||||
|
- 同 DAY 的 `routePoints` 走 span 渲染,正常显示城市名
|
||||||
|
|
||||||
|
### 同组件其他用法是否受影响
|
||||||
|
- **`searchForm.cityName`**(restaurant 搜索栏):使用 `nameOnly` 模式,**不受影响**
|
||||||
|
- **`RouteTagBar.vue` 中的 gather/dismissal**:完整 dict 模式,**受影响**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、修复方案(前端 AI 三选一)
|
||||||
|
|
||||||
|
### 方案 A(⭐ 推荐,根治 + 改动最小)
|
||||||
|
|
||||||
|
**操作**:删 `D:/work2/hl-ui/src/components/CitySelect.vue` **行 176** 的 `tag` 属性。
|
||||||
|
|
||||||
|
```diff
|
||||||
|
<n-select
|
||||||
|
:value="currentValue"
|
||||||
|
:options="options"
|
||||||
|
...
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
- tag
|
||||||
|
:filter="() => true"
|
||||||
|
@search="handleSearch"
|
||||||
|
@update:value="handleChange"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- n-select 进入"严格枚举"模式,找不到 option 时显示 placeholder 而不是把 value 当 tag 渲染。
|
||||||
|
- watch 行 143-161 保留(保证刷新页面时 options 至少有当前 value 一项),覆盖回显场景。
|
||||||
|
|
||||||
|
**风险点**:
|
||||||
|
- 原 `tag` 用于让用户在搜索结果之外手动输入新城市;但本组件 `searchDistrict` 已通过 `?keywords=` 远程搜(高德 API 覆盖全国行政区划),业务上**不需要 tag**。
|
||||||
|
- 如担心兜底,走方案 B 或 C。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 方案 B(最小侵入,保留 tag 能力)
|
||||||
|
|
||||||
|
把 watch 改为同步 `watchEffect`,并保证 options 在每次 `props.value` 变化时**强制覆盖**(去掉 `length === 0` 短路条件):
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { watchEffect } from 'vue'
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
const v = props.value
|
||||||
|
if (!v) return
|
||||||
|
if (props.nameOnly) {
|
||||||
|
options.value = [{ label: v, value: v }]
|
||||||
|
} else {
|
||||||
|
const stringified = JSON.stringify(v)
|
||||||
|
const exists = options.value.some(o => o.value === stringified)
|
||||||
|
if (!exists) {
|
||||||
|
options.value = [
|
||||||
|
{ label: formatLabel(v), value: stringified, tip: v },
|
||||||
|
...options.value
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- 保证 `props.value` 一变化(包括 store 异步注入),option 立即对齐,n-select 能匹配到 label,不走 tag 字符串渲染分支。
|
||||||
|
|
||||||
|
**风险点**:
|
||||||
|
- 仍依赖 n-select 内部"是否已渲染过 tag"的状态机。在某些极端时序下(首帧 value 已注入但 options 尚未更新到 DOM)仍可能有 1 帧闪烁。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 方案 C(兼容性最好,不动任何业务逻辑)
|
||||||
|
|
||||||
|
n-select 增加 `:render-tag` 自定义渲染函数,强制把 tag 显示成 `props.value.fullPath || props.value.name`,与 options.label 一致。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<n-select
|
||||||
|
...
|
||||||
|
:render-tag="renderTag"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { h } from 'vue'
|
||||||
|
|
||||||
|
function renderTag({ option }) {
|
||||||
|
// option.tip 是 watch 注入时存的原始 dict
|
||||||
|
const dict = option.tip || (typeof option.value === 'string' ? safeParse(option.value) : null)
|
||||||
|
if (!dict) return option.label || option.value
|
||||||
|
return h('span', null, dict.fullPath || dict.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParse(s) {
|
||||||
|
try { return JSON.parse(s) } catch { return null }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**效果**:
|
||||||
|
- 即使 n-select 把未匹配 value 当 tag 渲染,自定义 renderTag 也能保证显示文本是城市名。
|
||||||
|
|
||||||
|
**风险点**:
|
||||||
|
- naive-ui 的 `render-tag` API 行为在不同版本可能略有差异(建议先看一眼当前使用的 naive-ui 版本文档)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、验收标准(修完请前端自查)
|
||||||
|
|
||||||
|
- [ ] 进入产品编辑页(任意已有 gather_place 的产品,例如 `http://192.168.100.160:9527/product/edit?id=2045018534152478721`),DAY 3「开始 / 结束」下拉框显示城市名(不再是 JSON 字符串)
|
||||||
|
- [ ] 切换不同 DAY,每个 DAY 的「开始 / 结束」均正常显示
|
||||||
|
- [ ] 点开下拉框,远程搜索其他城市仍可正常切换
|
||||||
|
- [ ] 切换后保存产品,重新打开编辑页,回显的城市仍正确
|
||||||
|
- [ ] restaurant 搜索栏的城市筛选(CitySelect nameOnly 模式)功能未受影响
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、影响面
|
||||||
|
|
||||||
|
- **同组件复用点**:
|
||||||
|
- `searchForm.cityName`(restaurant 搜索栏,nameOnly 模式)→ 不受影响
|
||||||
|
- `RouteTagBar.vue` 中的 gather / dismissal → **受影响**
|
||||||
|
- **受影响产品数**:所有 `gather_place` / `dismissal_place` 不为空的产品的编辑页均中招。
|
||||||
|
- **是否影响保存**:**不影响**。store 里 `productData.itinerary[i].gatherPlace` 仍是后端返回的合法 dict,保存时 `transformToSaveDays`(行 460-470)原样回传,**保存数据正确**。仅"显示"层 BUG。
|
||||||
|
- **是否影响校验**:不影响。校验 `day.gatherPlace?.name`(ItineraryStep.vue 行 32)能拿到 name。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、后端附加提醒(非阻塞,不需要本次修,记录在册)
|
||||||
|
|
||||||
|
`hl-product-service-v2/src/main/java/com/hulalv/vo/admin/ProductItinerarySaveReqVO.java` 行 40 / 44:
|
||||||
|
|
||||||
|
```java
|
||||||
|
@ApiModelProperty(value = "集合地(高德行政区JSON)", required = true, notes = "{name,adcode,center,level}")
|
||||||
|
private Object gatherPlace;
|
||||||
|
|
||||||
|
@ApiModelProperty(value = "解散地(高德行政区JSON)", required = true, notes = "{name,adcode,center,level}")
|
||||||
|
private Object dismissalPlace;
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题**:`notes` 注释里写的字段集 `{name,adcode,center,level}` 与 DB / API 实际字段集 `{name,level,adcode,fullPath,levelName}` 不一致:
|
||||||
|
- 多了 `fullPath` / `levelName`
|
||||||
|
- 少了 `center`
|
||||||
|
|
||||||
|
**建议**:等本次前端 BUG 修复确认无问题后,下个批次同步注释为 `{name,level,adcode,fullPath,levelName}` 并明确"此字段透传,至少包含 `name` 用于显示"。本次不动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、参考文件路径
|
||||||
|
|
||||||
|
- 受影响组件:`D:/work2/hl-ui/src/components/CitySelect.vue`(第 176 行 `tag` 是元凶)
|
||||||
|
- 调用方:`D:/work2/hl-ui/src/views/product/edit/components/itinerary/RouteTagBar.vue` 行 48-66
|
||||||
|
- store:`D:/work2/hl-ui/src/store/productEdit.js#loadDetail` 行 255-335 / `transformToSaveDays` 行 460-470
|
||||||
|
- 后端 VO:`D:/work2/HL/hl-product-service-v2/src/main/java/com/hulalv/vo/admin/ProductItinerarySaveReqVO.java`
|
||||||
|
- 任务文件(含完整三尺定位):`D:/work2/HL/docs/tasks/20260420_修复_行程开始结束下拉显示JSON.md`
|
||||||
正在加载...
x
在新工单中引用
屏蔽一个用户