hl-api-changelog/changelogs/2026-04/2026-04-20-cityselect-tag-stringify-bug.md

10 KiB

fix(前端): CitySelect 集合地/解散地下拉显示 JSON 字符串而非城市名

  • 类型: BUG前端
  • 优先级: P1核心产品编辑功能体验严重异常
  • 影响端: 管理后台hl-ui
  • 后端是否需改动: 不需要。DB / API 数据完全正确,本次修复仅前端
  • 是否影响保存: 不影响。保存数据完全正确,仅显示层 BUG,前端可放心修
  • 任务文件: D:/work2/HL/docs/tasks/20260420_修复_行程开始结束下拉显示JSON.md
  • 报 BUG 时间: 2026-04-20wx 截图反馈)

一、现象描述

触发路径

管理后台 → 产品编辑行程编排任意 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 表) 数据合法:
    {"name":"黑山头镇","level":"street","adcode":"150784","fullPath":"内蒙古自治区/呼伦贝尔市/额尔古纳市/黑山头镇","levelName":"镇"}
    
  2. API 响应GET /admin/product/item/{id} 原样返回 dictJsonObjectTypeHandler 自动反序列化为 LinkedHashMap),契约一致。
  3. 前端渲染CitySelect.vue + n-select tag 模式) BUG 在此

直接 BUG 文件

文件D:/work2/hl-ui/src/components/CitySelect.vue

关键代码段

// 行 58-64n-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-161watch 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

<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 阶段可能为 nullstore 异步加载详情数据)→ 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.cityNamerestaurant 搜索栏):使用 nameOnly 模式,不受影响
  • RouteTagBar.vue 中的 gather/dismissal:完整 dict 模式,受影响

三、修复方案(前端 AI 三选一)

方案 A 推荐,根治 + 改动最小)

操作:删 D:/work2/hl-ui/src/components/CitySelect.vue 行 176tag 属性。

  <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 短路条件):

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 一致。

<n-select
  ...
  :render-tag="renderTag"
/>
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.cityNamerestaurant 搜索栏,nameOnly 模式)→ 不受影响
    • RouteTagBar.vue 中的 gather / dismissal → 受影响
  • 受影响产品数:所有 gather_place / dismissal_place 不为空的产品的编辑页均中招。
  • 是否影响保存不影响。store 里 productData.itinerary[i].gatherPlace 仍是后端返回的合法 dict,保存时 transformToSaveDays(行 460-470原样回传,保存数据正确。仅"显示"层 BUG。
  • 是否影响校验:不影响。校验 day.gatherPlace?.nameItineraryStep.vue 行 32能拿到 name。

六、后端附加提醒(非阻塞,不需要本次修,记录在册)

hl-product-service-v2/src/main/java/com/hulalv/vo/admin/ProductItinerarySaveReqVO.java 行 40 / 44

@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
  • storeD:/work2/hl-ui/src/store/productEdit.js#loadDetail 行 255-335 / transformToSaveDays 行 460-470
  • 后端 VOD:/work2/HL/hl-product-service-v2/src/main/java/com/hulalv/vo/admin/ProductItinerarySaveReqVO.java
  • 任务文件(含完整三尺定位):D:/work2/HL/docs/tasks/20260420_修复_行程开始结束下拉显示JSON.md