# 小程序编辑资料页：性格标签 / 兴趣爱好 标签库扩展 实现计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 让小程序编辑资料页的性格标签和兴趣爱好支持用户自由新增、删除、管理，标签容器自适应撑开，新增的自定义标签展示在最前面。

**Architecture:** 后端 `UserProfile` 实体新增两个 JSON 字段（`personality_tag_library` / `hobby_library`）存用户专属的完整标签库，原有"已选中标签"字段语义保留不变；前端通过 `stores/app.js` 的 `registrationData` 持有库数据，编辑资料页基于库数据计算有序展示列表，管理页通过 `uni.$emit` 事件将删除结果同步回编辑页。

**Tech Stack:** Spring Boot 2.7 + MyBatis-Plus + MySQL 8（后端），UniApp 3 + Vue 3 Composition API + 微信小程序（前端）。

**Spec 文档：** `docs/superpowers/specs/2026-06-24-personality-tag-library-design.md`

---

## 文件结构总览

### 新增文件

| 文件 | 职责 |
|---|---|
| `server/src/main/resources/db/migration/V20260624__add_user_profile_tag_library.sql` | 数据库字段迁移脚本 |
| `mini-program/src/pages/onboarding/tag-manage.vue` | 管理标签独立页面（性格标签 / 兴趣爱好） |

### 修改文件

| 文件 | 修改内容 |
|---|---|
| `server/src/main/java/com/emotion/entity/UserProfile.java` | 新增 `personalityTagLibrary` / `hobbyLibrary` 字段 |
| `server/src/main/java/com/emotion/dto/request/userprofile/UserProfileUpdateRequest.java` | 同上 |
| `server/src/main/java/com/emotion/dto/request/userprofile/UserProfileCreateRequest.java` | 同上 |
| `server/src/main/java/com/emotion/dto/response/userprofile/UserProfileResponse.java` | 同上 |
| `mini-program/src/services/userProfile.js` | 请求/响应转换增加两个库字段 |
| `mini-program/src/stores/app.js` | `registrationData` 增加两个库字段 |
| `mini-program/src/pages.json` | 注册 `tag-manage` 路由 |
| `mini-program/src/pages/onboarding/index.vue` | 标签区 UI 重构、新增标签弹窗、管理入口、事件监听 |

### 不修改文件

| 文件 | 原因 |
|---|---|
| `mini-program/src/pages/main/MineView.vue` | 只读"已选中的标签"（沿用 `hobbies` 字段），不需要感知完整库 |
| `server/src/main/java/com/emotion/service/impl/UserProfileServiceImpl.java` | 使用 `BeanUtils.copyProperties` 自动映射，新字段透传 |
| `server/src/main/java/com/emotion/controller/UserProfileController.java` | 同上 |

---

## Task 1: 后端数据库与实体层

**目标：** 给 `t_user_profile` 表新增两个 JSON 字段，同步更新实体类与 3 个 DTO，保证接口透传。

**Files:**
- Create: `server/src/main/resources/db/migration/V20260624__add_user_profile_tag_library.sql`
- Modify: `server/src/main/java/com/emotion/entity/UserProfile.java`
- Modify: `server/src/main/java/com/emotion/dto/request/userprofile/UserProfileUpdateRequest.java`
- Modify: `server/src/main/java/com/emotion/dto/request/userprofile/UserProfileCreateRequest.java`
- Modify: `server/src/main/java/com/emotion/dto/response/userprofile/UserProfileResponse.java`

### Step 1: 创建数据库迁移脚本

创建 `server/src/main/resources/db/migration/V20260624__add_user_profile_tag_library.sql`：

```sql
-- 用户档案表新增标签库字段，用于存储用户专属的完整标签列表（含剩余预设 + 自定义）
ALTER TABLE t_user_profile
  ADD COLUMN personality_tag_library TEXT DEFAULT NULL COMMENT '用户完整性格标签库(JSON数组)',
  ADD COLUMN hobby_library TEXT DEFAULT NULL COMMENT '用户完整兴趣爱好库(JSON数组)';
```

### Step 2: UserProfile 实体新增字段

修改 `server/src/main/java/com/emotion/entity/UserProfile.java`。

在 `personalityTags` 字段下方追加两个字段（保持与现有 `personalityTags` / `hobbies` 字段风格一致）：

```java
    /**
     * 性格标签 (JSON字符串)
     */
    @TableField("personality_tags")
    private String personalityTags;

    /**
     * 用户完整性格标签库 (JSON字符串，含剩余预设 + 自定义)
     */
    @TableField("personality_tag_library")
    private String personalityTagLibrary;

    /**
     * 用户完整兴趣爱好库 (JSON字符串，含剩余预设 + 自定义)
     */
    @TableField("hobby_library")
    private String hobbyLibrary;
```

### Step 3: UserProfileUpdateRequest 新增字段

修改 `server/src/main/java/com/emotion/dto/request/userprofile/UserProfileUpdateRequest.java`。

在 `personalityTags` 字段下方追加：

```java
    /**
     * 性格标签 (JSON字符串)
     */
    private String personalityTags;

    /**
     * 用户完整性格标签库 (JSON字符串)
     */
    private String personalityTagLibrary;

    /**
     * 用户完整兴趣爱好库 (JSON字符串)
     */
    private String hobbyLibrary;
```

### Step 4: UserProfileCreateRequest 新增字段

修改 `server/src/main/java/com/emotion/dto/request/userprofile/UserProfileCreateRequest.java`。

在 `personalityTags` 字段下方追加：

```java
    /**
     * 性格标签 (JSON字符串)
     */
    private String personalityTags;

    /**
     * 用户完整性格标签库 (JSON字符串)
     */
    private String personalityTagLibrary;

    /**
     * 用户完整兴趣爱好库 (JSON字符串)
     */
    private String hobbyLibrary;
```

### Step 5: UserProfileResponse 新增字段

修改 `server/src/main/java/com/emotion/dto/response/userprofile/UserProfileResponse.java`。

在 `personalityTags` 字段下方追加：

```java
    /**
     * 性格标签 (JSON字符串)
     */
    private String personalityTags;

    /**
     * 用户完整性格标签库 (JSON字符串)
     */
    private String personalityTagLibrary;

    /**
     * 用户完整兴趣爱好库 (JSON字符串)
     */
    private String hobbyLibrary;
```

### Step 6: 本地编译验证

```bash
cd server
mvn clean install -DskipTests -pl :server -am
```

预期：`BUILD SUCCESS`。

### Step 7: 提交

```bash
git add server/src/main/resources/db/migration/V20260624__add_user_profile_tag_library.sql \
        server/src/main/java/com/emotion/entity/UserProfile.java \
        server/src/main/java/com/emotion/dto/request/userprofile/UserProfileUpdateRequest.java \
        server/src/main/java/com/emotion/dto/request/userprofile/UserProfileCreateRequest.java \
        server/src/main/java/com/emotion/dto/response/userprofile/UserProfileResponse.java
git commit -m "feat：UserProfile 新增性格标签库与兴趣爱好库字段"
```

---

## Task 2: 前端数据层（services + stores + pages.json）

**目标：** 前端请求/响应转换、全局 store、路由注册全部支持新字段。

**Files:**
- Modify: `mini-program/src/services/userProfile.js`
- Modify: `mini-program/src/stores/app.js`
- Modify: `mini-program/src/pages.json`

### Step 1: services/userProfile.js 响应解析增加库字段

修改 `mini-program/src/services/userProfile.js`。

在 `transformToFrontendFormat` 函数中，解构时增加两个字段，并在返回对象中解析它们：

原代码：

```javascript
  const {
    id,
    userId,
    nickname,
    gender,
    zodiac,
    profession,
    mbti,
    hobbies,
    childhoodDate,
    childhoodContent,
    peakDate,
    peakContent,
    valleyDate,
    valleyContent,
    futureVision,
    idealLife,
    city,
    industry,
    company,
    personalityTags,
    birthday
  } = backendData
```

改为：

```javascript
  const {
    id,
    userId,
    nickname,
    gender,
    zodiac,
    profession,
    mbti,
    hobbies,
    childhoodDate,
    childhoodContent,
    peakDate,
    peakContent,
    valleyDate,
    valleyContent,
    futureVision,
    idealLife,
    city,
    industry,
    company,
    personalityTags,
    personalityTagLibrary,
    hobbyLibrary,
    birthday
  } = backendData
```

原返回对象末尾：

```javascript
    personalityTags: parseJsonArray(personalityTags),
    birthday: normalizeDate(birthday)
  }
```

改为：

```javascript
    personalityTags: parseJsonArray(personalityTags),
    personalityTagLibrary: parseJsonArray(personalityTagLibrary),
    hobbyLibrary: parseJsonArray(hobbyLibrary),
    birthday: normalizeDate(birthday)
  }
```

### Step 2: services/userProfile.js 请求提交增加库字段

修改 `transformToBackendFormat` 函数。

原解构：

```javascript
  const {
    id,
    nickname,
    gender,
    zodiac,
    profession,
    mbti,
    hobbies,
    childhood,
    joy,
    low,
    future,
    city,
    industry,
    company,
    personalityTags,
    birthday
  } = frontendData
```

改为：

```javascript
  const {
    id,
    nickname,
    gender,
    zodiac,
    profession,
    mbti,
    hobbies,
    childhood,
    joy,
    low,
    future,
    city,
    industry,
    company,
    personalityTags,
    personalityTagLibrary,
    hobbyLibrary,
    birthday
  } = frontendData
```

原返回对象末尾：

```javascript
    personalityTags: Array.isArray(personalityTags) ? JSON.stringify(personalityTags) : personalityTags,
    birthday: birthday || null
  }
```

改为：

```javascript
    personalityTags: Array.isArray(personalityTags) ? JSON.stringify(personalityTags) : personalityTags,
    personalityTagLibrary: Array.isArray(personalityTagLibrary) ? JSON.stringify(personalityTagLibrary) : personalityTagLibrary,
    hobbyLibrary: Array.isArray(hobbyLibrary) ? JSON.stringify(hobbyLibrary) : hobbyLibrary,
    birthday: birthday || null
  }
```

### Step 3: stores/app.js registrationData 增加库字段

修改 `mini-program/src/stores/app.js`。

原 `state.registrationData`：

```javascript
  registrationData: {
    nickname: '',
    gender: '',
    mbti: '',
    zodiac: '',
    profession: '',
    hobbies: [],
    childhood: { date: '', text: '' },
    joy: { date: '', text: '' },
    low: { date: '', text: '' },
    future: { vision: '', ideal: '' },
    city: '',
    industry: '',
    company: '',
    personalityTags: [],
    birthday: ''
  }
```

改为：

```javascript
  registrationData: {
    nickname: '',
    gender: '',
    mbti: '',
    zodiac: '',
    profession: '',
    hobbies: [],
    childhood: { date: '', text: '' },
    joy: { date: '', text: '' },
    low: { date: '', text: '' },
    future: { vision: '', ideal: '' },
    city: '',
    industry: '',
    company: '',
    personalityTags: [],
    personalityTagLibrary: [],
    hobbyLibrary: [],
    birthday: ''
  }
```

### Step 4: pages.json 注册 tag-manage 路由

修改 `mini-program/src/pages.json`。

在 `pages/onboarding/index` 条目后面插入新页面：

```json
		{
			"path": "pages/onboarding/index",
			"style": {
				"navigationStyle": "custom"
			}
		},
		{
			"path": "pages/onboarding/tag-manage",
			"style": {
				"navigationStyle": "custom",
				"navigationBarTitleText": "管理标签"
			}
		},
```

### Step 5: 提交

```bash
git add mini-program/src/services/userProfile.js \
        mini-program/src/stores/app.js \
        mini-program/src/pages.json
git commit -m "feat：前端数据层与路由注册支持标签库字段"
```

---

## Task 3: 编辑资料页数据初始化与兼容

**目标：** 编辑资料页 `form` 持有两个库字段，`syncFromStore` 加载库数据并对老用户做兼容初始化。

**Files:**
- Modify: `mini-program/src/pages/onboarding/index.vue`

### Step 1: form reactive 增加库字段

修改 `mini-program/src/pages/onboarding/index.vue`。

原 `form`：

```javascript
const form = reactive({
  nickname: '',
  gender: '',
  zodiac: '',
  mbti: '',
  profession: '',
  city: '',
  industry: '',
  company: '',
  personalityTags: [],
  hobbies: [],
  childhood: { date: '', text: '' },
  joy: { date: '', text: '' },
  low: { date: '', text: '' },
  future: { vision: '', ideal: '' }
})
```

改为：

```javascript
const form = reactive({
  nickname: '',
  gender: '',
  zodiac: '',
  mbti: '',
  profession: '',
  city: '',
  industry: '',
  company: '',
  personalityTags: [],
  hobbies: [],
  personalityTagLibrary: [],
  hobbyLibrary: [],
  childhood: { date: '', text: '' },
  joy: { date: '', text: '' },
  low: { date: '', text: '' },
  future: { vision: '', ideal: '' }
})
```

### Step 2: syncFromStore 增加库字段加载与老用户兼容初始化

替换原 `syncFromStore` 函数为：

```javascript
/**
 * 合并预设数组与已选数组，去重返回完整库
 */
const buildInitialLibrary = (preset, selected) => {
  const library = [...preset]
  if (Array.isArray(selected)) {
    selected.forEach((tag) => {
      if (!library.includes(tag)) library.push(tag)
    })
  }
  return library
}

const syncFromStore = () => {
  const source = store.userProfile || store.registrationData || {}

  // 性格标签库：后端有则用后端，否则用预设 + 已选合并（老用户兼容）
  const personalityTagLibrary = Array.isArray(source.personalityTagLibrary) && source.personalityTagLibrary.length > 0
    ? [...source.personalityTagLibrary]
    : buildInitialLibrary(personalityTags, source.personalityTags)

  // 兴趣爱好库：后端有则用后端，否则用预设 + 已选合并（老用户兼容）
  const hobbyLibrary = Array.isArray(source.hobbyLibrary) && source.hobbyLibrary.length > 0
    ? [...source.hobbyLibrary]
    : buildInitialLibrary(hobbyOptions, source.hobbies)

  Object.assign(form, {
    nickname: source.nickname || '',
    gender: source.gender || '',
    zodiac: source.zodiac || '',
    mbti: source.mbti || '',
    profession: source.profession || '',
    city: source.city || '',
    industry: source.industry || '',
    company: source.company || '',
    personalityTags: Array.isArray(source.personalityTags) ? [...source.personalityTags] : [],
    hobbies: Array.isArray(source.hobbies) ? [...source.hobbies] : [],
    personalityTagLibrary,
    hobbyLibrary,
    childhood: { date: source.childhood?.date || '', text: source.childhood?.text || '' },
    joy: { date: source.joy?.date || '', text: source.joy?.text || '' },
    low: { date: source.low?.date || '', text: source.low?.text || '' },
    future: { vision: source.future?.vision || '', ideal: source.future?.ideal || '' }
  })
  birthday.value = source.birthday || ''
}
```

### Step 3: 提交

```bash
git add mini-program/src/pages/onboarding/index.vue
git commit -m "feat：编辑资料页 form 增加库字段并对老用户兼容初始化"
```

---

## Task 4: 编辑资料页标签区 UI 重构

**目标：** 性格标签、兴趣爱好两个 panel 重构为基于库数据计算的有序展示列表，标题行右侧新增"管理"按钮，末尾"＋ 添加"绑定新增弹窗。

**Files:**
- Modify: `mini-program/src/pages/onboarding/index.vue`

### Step 1: 新增两个 computed 计算有序展示列表

在 `script setup` 中（`syncFromStore` 函数之后）新增：

```javascript
/**
 * 性格标签有序展示列表：
 * 1. 新增的自定义标签（不在预设中）按库顺序排在最前
 * 2. 已选中的其他标签紧随其后
 * 3. 其余未选中的预设标签排在最后
 */
const displayPersonalityTags = computed(() => {
  const selectedSet = new Set(form.personalityTags)
  const presetSet = new Set(personalityTags)

  // 自定义标签：不在预设数组中的标签，按库顺序
  const customTags = form.personalityTagLibrary.filter((tag) => !presetSet.has(tag))
  // 已选中的预设标签
  const selectedPreset = form.personalityTagLibrary.filter(
    (tag) => presetSet.has(tag) && selectedSet.has(tag)
  )
  // 未选中的预设标签
  const unselectedPreset = form.personalityTagLibrary.filter(
    (tag) => presetSet.has(tag) && !selectedSet.has(tag)
  )

  return [...customTags, ...selectedPreset, ...unselectedPreset]
})

/**
 * 兴趣爱好有序展示列表（逻辑同上）
 */
const displayHobbyTags = computed(() => {
  const selectedSet = new Set(form.hobbies)
  const presetSet = new Set(hobbyOptions)

  const customTags = form.hobbyLibrary.filter((tag) => !presetSet.has(tag))
  const selectedPreset = form.hobbyLibrary.filter(
    (tag) => presetSet.has(tag) && selectedSet.has(tag)
  )
  const unselectedPreset = form.hobbyLibrary.filter(
    (tag) => presetSet.has(tag) && !selectedSet.has(tag)
  )

  return [...customTags, ...selectedPreset, ...unselectedPreset]
})
```

### Step 2: 替换性格标签 panel 模板

原性格标签模板（约在 129-145 行）：

```html
      <view class="panel glass-card">
        <view class="section-head">
          <view class="section-icon smile-title-icon"></view>
          <text class="section-title">性格标签</text>
          <text class="section-hint">（最多选择5个）</text>
        </view>
        <view class="tag-grid">
          <text
            v-for="tag in personalityTags"
            :key="tag"
            class="tag-choice"
            :class="{ active: form.personalityTags.includes(tag) }"
            @click="toggleList(form.personalityTags, tag, 5)"
          >{{ tag }}</text>
          <text class="tag-choice dashed">＋ 添加标签</text>
        </view>
      </view>
```

替换为：

```html
      <view class="panel glass-card">
        <view class="section-head with-action">
          <view class="section-title-wrap">
            <view class="section-icon smile-title-icon"></view>
            <text class="section-title">性格标签</text>
            <text class="section-hint">（最多选择5个）</text>
          </view>
          <text class="manage-link" @click="goTagManage('personality')">管理</text>
        </view>
        <view class="tag-grid">
          <text
            v-for="tag in displayPersonalityTags"
            :key="tag"
            class="tag-choice"
            :class="{ active: form.personalityTags.includes(tag) }"
            @click="toggleList(form.personalityTags, tag, 5)"
          >{{ tag }}</text>
          <text class="tag-choice dashed" @click="addCustomTag('personality')">＋ 添加标签</text>
        </view>
      </view>
```

### Step 3: 替换兴趣爱好 panel 模板

原兴趣爱好模板（约在 147-165 行）：

```html
      <view class="panel glass-card">
        <view class="section-head with-action">
          <view class="section-title-wrap">
            <view class="section-icon heart-title-icon"></view>
            <text class="section-title">兴趣爱好</text>
            <text class="section-hint">（最多选择5个）</text>
          </view>
          <text class="custom" @click="addCustomHobby">＋ 自定义兴趣</text>
        </view>
        <view class="tag-grid">
          <text
            v-for="tag in hobbyOptions"
            :key="tag"
            class="tag-choice"
            :class="{ active: form.hobbies.includes(tag) }"
            @click="toggleList(form.hobbies, tag, 5)"
          >{{ tag }}</text>
        </view>
      </view>
```

替换为：

```html
      <view class="panel glass-card">
        <view class="section-head with-action">
          <view class="section-title-wrap">
            <view class="section-icon heart-title-icon"></view>
            <text class="section-title">兴趣爱好</text>
            <text class="section-hint">（最多选择5个）</text>
          </view>
          <text class="manage-link" @click="goTagManage('hobby')">管理</text>
        </view>
        <view class="tag-grid">
          <text
            v-for="tag in displayHobbyTags"
            :key="tag"
            class="tag-choice"
            :class="{ active: form.hobbies.includes(tag) }"
            @click="toggleList(form.hobbies, tag, 5)"
          >{{ tag }}</text>
          <text class="tag-choice dashed" @click="addCustomTag('hobby')">＋ 添加兴趣</text>
        </view>
      </view>
```

### Step 4: 新增管理页跳转与标签新增函数（替换旧 addCustomHobby）

在 `script setup` 中删除原 `addCustomHobby` 函数，替换为以下三个函数：

```javascript
/**
 * 跳转到管理标签页面
 */
const goTagManage = (type) => {
  uni.navigateTo({
    url: `/pages/onboarding/tag-manage?type=${type}`
  })
}

/**
 * 新增自定义标签（性格标签 / 兴趣爱好通用）
 * @param {string} type 'personality' 或 'hobby'
 */
const addCustomTag = (type) => {
  const isPersonality = type === 'personality'
  const library = isPersonality ? form.personalityTagLibrary : form.hobbyLibrary
  const selected = isPersonality ? form.personalityTags : form.hobbies
  const title = isPersonality ? '添加性格标签' : '添加兴趣爱好'

  uni.showModal({
    title,
    editable: true,
    placeholderText: '请输入标签（最多8个字符）',
    success: (res) => {
      const value = String(res.content || '').trim()
      if (!res.confirm) return
      if (!value) {
        uni.showToast({ title: '请输入标签内容', icon: 'none' })
        return
      }
      if (value.length > 8) {
        uni.showToast({ title: '标签最多8个字符', icon: 'none' })
        return
      }
      if (library.includes(value)) {
        uni.showToast({ title: '标签已存在', icon: 'none' })
        return
      }
      // 新标签插入到库数组最前面
      library.unshift(value)
      // 若已选未满 5 个，自动加入已选；否则仅提示
      if (selected.length >= 5) {
        uni.showToast({ title: '已达上限，仅加入标签库', icon: 'none' })
        return
      }
      selected.push(value)
    }
  })
}
```

### Step 5: 样式调整 — "管理"链接样式

在 `<style scoped>` 中追加（沿用现有 `.custom` 风格，保持视觉一致）：

```css
.manage-link {
  color: #c06dff;
  font-size: 23rpx;
}
```

### Step 6: 提交

```bash
git add mini-program/src/pages/onboarding/index.vue
git commit -m "feat：编辑资料页标签区 UI 重构，新增管理入口与添加标签弹窗"
```

---

## Task 5: 编辑资料页保存流程与事件监听

**目标：** 保存时携带库字段；`onShow` 监听管理页发回的事件，合并库更新。

**Files:**
- Modify: `mini-program/src/pages/onboarding/index.vue`

### Step 1: 保存流程携带库字段

修改 `saveProfile` 函数，把库字段一起塞入 `store.updateRegistration`。

原代码：

```javascript
  saving.value = true
  store.updateRegistration(JSON.parse(JSON.stringify({ ...form, birthday: birthday.value })))
  const res = await store.saveUserProfile()
```

无需修改 —— `form` 已包含 `personalityTagLibrary` / `hobbyLibrary`，展开后会被 `updateRegistration` 合并到 `registrationData`，`saveUserProfile` 会自动带上。验证 `saveProfile` 函数已能正确提交库字段。

### Step 2: onShow 监听管理页事件

在 `script setup` 中引入 `onShow`：

原 import：

```javascript
import { computed, onMounted, reactive, ref } from 'vue'
```

改为（UniApp 的 `onShow` 从 `@dcloudio/uni-app` 导入）：

```javascript
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
```

在 `onMounted` 之后追加 `onShow` 与清理逻辑：

```javascript
/**
 * 处理管理页发回的库更新事件
 */
const handleTagLibraryUpdated = (payload) => {
  if (!payload || !payload.type) return
  if (payload.type === 'personality') {
    if (Array.isArray(payload.library)) {
      form.personalityTagLibrary = [...payload.library]
      // 同步移除已选中已被删除的标签
      form.personalityTags = form.personalityTags.filter(
        (tag) => payload.library.includes(tag)
      )
    }
  } else if (payload.type === 'hobby') {
    if (Array.isArray(payload.library)) {
      form.hobbyLibrary = [...payload.library]
      form.hobbies = form.hobbies.filter(
        (tag) => payload.library.includes(tag)
      )
    }
  }
}

onShow(() => {
  uni.$on('tag-library-updated', handleTagLibraryUpdated)
})

onUnmounted(() => {
  uni.$off('tag-library-updated', handleTagLibraryUpdated)
})
```

### Step 3: 提交

```bash
git add mini-program/src/pages/onboarding/index.vue
git commit -m "feat：编辑资料页保存携带库字段，onShow 监听管理页更新事件"
```

---

## Task 6: 管理标签页 tag-manage.vue

**目标：** 新增独立页面，接收 `type` 参数，渲染完整标签库，支持 × 按钮与左滑手势删除，返回时通过事件同步编辑页。

**Files:**
- Create: `mini-program/src/pages/onboarding/tag-manage.vue`

### Step 1: 创建 tag-manage.vue

创建 `mini-program/src/pages/onboarding/tag-manage.vue`，完整内容：

```vue
<template>
  <view class="manage-page">
    <view class="space-bg"></view>
    <view class="status-space" :style="{ height: capsuleTopReservePx + 'px' }"></view>

    <view class="topbar">
      <text class="back" @click="goBack">‹</text>
      <text class="title">{{ pageTitle }}</text>
      <text class="placeholder-slot"></text>
    </view>

    <scroll-view class="scroll" scroll-y :show-scrollbar="false">
      <view class="panel glass-card">
        <view class="section-head">
          <text class="section-title">全部标签</text>
          <text class="section-hint">（共 {{ library.length }} 个）</text>
        </view>

        <view v-if="library.length === 0" class="empty-tip">
          <text>暂无标签</text>
        </view>

        <view
          v-for="(tag, index) in library"
          :key="tag"
          class="tag-row"
          @touchstart="onTouchStart($event, index)"
          @touchmove="onTouchMove($event, index)"
          @touchend="onTouchEnd($event, index)"
        >
          <view class="tag-row-inner" :style="{ transform: `translateX(${swipeOffsets[index] || 0}px)` }">
            <view class="tag-row-main">
              <text class="tag-text">{{ tag }}</text>
            </view>
            <view class="tag-row-delete" @click.stop="confirmDelete(tag)">
              <text class="delete-text">删除</text>
            </view>
          </view>
          <view class="tag-row-close" @click.stop="confirmDelete(tag)">
            <text class="close-icon">×</text>
          </view>
        </view>
      </view>
    </scroll-view>
  </view>
</template>

<script setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { useAppStore } from '../../stores/app.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'

const store = useAppStore()
const { capsuleTopReservePx } = useMenuButtonSafeArea({ extraTopPx: 2 })

const type = ref('personality')
const library = ref([])
const selected = ref([])
const swipeOffsets = reactive({})

// 预设数组（与编辑资料页保持一致）
const personalityTags = ['理性', '感性', '乐观', '独立', '有创造力', '坚韧', '细腻', '好奇', '内敛', '冒险', '自由']
const hobbyOptions = ['阅读', '旅行', '音乐', '写作', '摄影', '电影', '运动', '绘画', '咖啡', '游戏']

const pageTitle = computed(() => (type.value === 'personality' ? '管理性格标签' : '管理兴趣爱好'))

/**
 * 从 store 初始化库数据（与编辑页 syncFromStore 同逻辑）
 */
const buildInitialLibrary = (preset, selectedArr) => {
  const lib = [...preset]
  if (Array.isArray(selectedArr)) {
    selectedArr.forEach((tag) => {
      if (!lib.includes(tag)) lib.push(tag)
    })
  }
  return lib
}

const initFromStore = () => {
  const source = store.userProfile || store.registrationData || {}
  const isPersonality = type.value === 'personality'

  const preset = isPersonality ? personalityTags : hobbyOptions
  const selectedArr = isPersonality ? source.personalityTags : source.hobbies
  const libraryArr = isPersonality ? source.personalityTagLibrary : source.hobbyLibrary

  const finalLibrary = Array.isArray(libraryArr) && libraryArr.length > 0
    ? [...libraryArr]
    : buildInitialLibrary(preset, selectedArr)

  library.value = finalLibrary
  selected.value = Array.isArray(selectedArr) ? [...selectedArr] : []
}

/**
 * 左滑手势处理
 */
const touchState = ref({ startX: 0, startY: 0, currentIndex: -1, moved: false })
const DELETE_THRESHOLD = -60

const onTouchStart = (event, index) => {
  const touch = event.touches[0]
  touchState.value = { startX: touch.clientX, startY: touch.clientY, currentIndex: index, moved: false }
}

const onTouchMove = (event, index) => {
  const touch = event.touches[0]
  const deltaX = touch.clientX - touchState.value.startX
  const deltaY = touch.clientY - touchState.value.startY
  // 只处理水平滑动
  if (Math.abs(deltaX) < Math.abs(deltaY)) return
  touchState.value.moved = true
  const offset = Math.min(0, Math.max(deltaX, -100))
  swipeOffsets[index] = offset
}

const onTouchEnd = (event, index) => {
  const currentOffset = swipeOffsets[index] || 0
  if (currentOffset < DELETE_THRESHOLD) {
    swipeOffsets[index] = -80
  } else {
    swipeOffsets[index] = 0
  }
}

/**
 * 删除确认
 */
const confirmDelete = (tag) => {
  uni.showModal({
    title: '确认删除',
    content: `确定删除「${tag}」吗？删除后不可恢复`,
    success: (res) => {
      if (!res.confirm) return
      deleteTag(tag)
    }
  })
}

/**
 * 执行删除：从库数组移除，若同时在已选数组也一并移除
 */
const deleteTag = (tag) => {
  const idx = library.value.indexOf(tag)
  if (idx >= 0) library.value.splice(idx, 1)
  const selectedIdx = selected.value.indexOf(tag)
  if (selectedIdx >= 0) selected.value.splice(selectedIdx, 1)

  // 同步到 store.registrationData
  const isPersonality = type.value === 'personality'
  const key = isPersonality ? 'personalityTagLibrary' : 'hobbyLibrary'
  const selectedKey = isPersonality ? 'personalityTags' : 'hobbies'
  store.updateRegistration({
    [key]: [...library.value],
    [selectedKey]: [...selected.value]
  })

  // 通知编辑资料页同步
  uni.$emit('tag-library-updated', {
    type: type.value,
    library: [...library.value],
    selected: [...selected.value]
  })
}

const goBack = () => {
  uni.navigateBack()
}

onMounted(() => {
  const pages = getCurrentPages()
  const currentPage = pages[pages.length - 1]
  type.value = currentPage?.options?.type === 'hobby' ? 'hobby' : 'personality'
  initFromStore()
})
</script>

<style scoped>
.manage-page {
  position: relative;
  height: 100vh;
  min-height: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  color: #fff;
  background: #050615;
}

.space-bg {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(circle at 84% 3%, rgba(87, 122, 255, 0.12), transparent 28%),
    radial-gradient(circle at 14% 26%, rgba(130, 71, 255, 0.16), transparent 30%),
    linear-gradient(180deg, #05081b 0%, #07031a 52%, #03020d 100%);
}

.status-space,
.topbar,
.scroll {
  position: relative;
  z-index: 1;
}

.status-space,
.topbar {
  flex-shrink: 0;
}

.topbar {
  height: 72rpx;
  display: grid;
  grid-template-columns: 76rpx 1fr 76rpx;
  align-items: center;
  padding: 0 28rpx;
}

.back {
  color: #fff;
  font-size: 56rpx;
  line-height: 1;
}

.title {
  text-align: center;
  color: #fff;
  font-size: 32rpx;
  font-weight: 900;
}

.placeholder-slot {
  width: 1px;
}

.scroll {
  flex: 1;
  height: 0;
  min-height: 0;
  box-sizing: border-box;
  padding: 0 24rpx 28rpx;
  margin-top: -6rpx;
}

.glass-card {
  position: relative;
  overflow: hidden;
  border: 1rpx solid rgba(155, 110, 255, 0.14);
  background:
    radial-gradient(circle at 92% 12%, rgba(104, 66, 255, 0.1), transparent 34%),
    rgba(10, 13, 43, 0.54);
  box-shadow: inset 0 0 30rpx rgba(123, 60, 255, 0.05), 0 10rpx 36rpx rgba(0, 0, 0, 0.16);
  backdrop-filter: blur(24rpx);
  -webkit-backdrop-filter: blur(24rpx);
}

.panel {
  border-radius: 22rpx;
  margin-bottom: 18rpx;
  padding: 22rpx 24rpx;
}

.section-head {
  display: flex;
  align-items: center;
  min-height: 44rpx;
  gap: 10rpx;
}

.section-title {
  color: rgba(239, 232, 255, 0.9);
  font-size: 24rpx;
  font-weight: 800;
}

.section-hint {
  color: rgba(222, 211, 240, 0.54);
  font-size: 22rpx;
}

.empty-tip {
  padding: 40rpx 0;
  text-align: center;
  color: rgba(222, 211, 240, 0.5);
  font-size: 24rpx;
}

.tag-row {
  position: relative;
  overflow: hidden;
  border-top: 1rpx solid rgba(180, 139, 255, 0.12);
}

.tag-row-inner {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: center;
  min-height: 72rpx;
  background: transparent;
  transition: transform 0.2s ease;
}

.tag-row-main {
  flex: 1;
  min-width: 0;
  padding: 16rpx 8rpx;
}

.tag-text {
  color: rgba(255, 255, 255, 0.9);
  font-size: 26rpx;
}

.tag-row-delete {
  position: absolute;
  right: 0;
  top: 0;
  bottom: 0;
  width: 140rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, #ff4d6d, #c9184a);
  z-index: 1;
}

.delete-text {
  color: #fff;
  font-size: 24rpx;
  font-weight: 700;
}

.tag-row-close {
  position: absolute;
  right: 16rpx;
  top: 50%;
  transform: translateY(-50%);
  width: 44rpx;
  height: 44rpx;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(255, 77, 109, 0.16);
  z-index: 3;
}

.close-icon {
  color: #ff4d6d;
  font-size: 32rpx;
  line-height: 1;
  font-weight: 700;
}
</style>
```

### Step 2: 启动 H5 开发服务器预览

```bash
cd mini-program
npm run dev:h5
```

在浏览器中打开 `http://localhost:5173`，登录后进入编辑资料页，验证：
- 性格标签、兴趣爱好 panel 标题右侧显示"管理"链接。
- 点击"管理"跳转管理页，页面标题显示"管理性格标签"或"管理兴趣爱好"。
- 每行标签右侧显示 × 按钮，左滑显示"删除"按钮。
- 点击 × 或滑动后点击删除，弹出确认对话框。
- 确认后标签从列表消失。

### Step 3: 提交

```bash
git add mini-program/src/pages/onboarding/tag-manage.vue
git commit -m "feat：新增管理标签独立页面，支持左滑与 × 按钮删除"
```

---

## Task 7: 端到端浏览器验证

**目标：** 在浏览器 H5 模式下覆盖 spec 所有 10 条验收标准。

### Step 1: 重启后端（已修改 Java 实体）

```bash
python dev-services.py restart server
```

等待启动完成，查看终端无报错。

### Step 2: 启动前端

```bash
python dev-services.py start mini-program
```

在浏览器中打开 `http://localhost:5284`。

### Step 3: 逐条验证验收标准

| # | 验收标准 | 验证方法 |
|---|---|---|
| 1 | 标签容器高度自适应撑开，无截断 | 添加多个标签后观察面板是否自动增高 |
| 2 | 新增标签出现在列表最前面 | 点击"＋ 添加标签"/"＋ 添加兴趣"，弹窗输入后观察位置 |
| 3 | 重复输入同名标签，toast 提示"标签已存在" | 输入已存在的标签名，验证 toast |
| 4 | 空输入、超长输入 toast 提示对应原因 | 分别提交空内容、9 字符内容，验证 toast |
| 5 | "管理"按钮跳转管理页，列表展示全部标签 | 点击"管理"，验证页面标题与列表 |
| 6 | 管理页左滑或点 × 删除标签，二次确认后消失 | 分别测试左滑、点 ×，确认 modal 弹出 |
| 7 | 删除已在"已选"中的标签，返回编辑资料页后已选同步移除 | 先选中某标签，管理页删除后返回观察 |
| 8 | 保存后重新进入编辑资料页，新增标签仍在最前 | 保存→重新进入，验证顺序 |
| 9 | 老用户首次进入编辑资料页，已选标签保留，预设标签可见 | 使用无新字段用户登录，验证库初始化 |
| 10 | 接口失败时，页面保留原状态并 toast 真实错误 | 断网或后端停止后保存，验证 toast 真实错误 |

### Step 4: 最终提交

所有验收标准通过后，合并所有改动提交（如果之前未 commit）：

```bash
git status
```

确认工作区干净，无遗留未提交改动。

---

## 执行顺序建议

1. Task 1（后端）→ 重启后端
2. Task 2（前端数据层）→ Task 3（编辑页数据初始化）→ Task 4（编辑页 UI）→ Task 5（保存 + 事件）→ Task 6（管理页）
3. Task 7（端到端验证）

Task 1 必须先完成并重启后端，否则后续前端请求会拿到旧响应结构。Task 2~6 顺序基本线性，每完成一个 task 可启动 H5 预览一次确认无回归。
