# 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`
