# 小程序添加标签按钮简化与自定义弹窗 实现计划

> **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:** 简化编辑资料页"添加"按钮文字、收紧字数限制到 4 字、标签不换行省略号截断、用自定义深色太空主题弹窗组件替换 `uni.showModal`。

**Architecture:** 新建 `TagDialog.vue` 组件支持 input / confirm 双模式，样式与小程序深色太空主题一致；编辑资料页和管理页分别引入该组件，重构 `addCustomTag` 和 `confirmDelete` 从 `uni.showModal` 切换到自定义弹窗；标签样式增加 `white-space: nowrap` 三件套防止换行。

**Tech Stack:** UniApp 3 + Vue 3 Composition API (`<script setup>`)，样式用 rpx，深色太空主题（glass-card 毛玻璃 + 紫色渐变 + 金色强调）。

**Spec 文档：** `docs/superpowers/specs/2026-06-24-tag-add-button-simplify-design.md`

---

## 文件结构总览

### 新增文件

| 文件 | 职责 |
|---|---|
| `mini-program/src/components/TagDialog.vue` | 自定义弹窗组件，input / confirm 双模式，深色太空主题样式 |

### 修改文件

| 文件 | 修改内容 |
|---|---|
| `mini-program/src/pages/onboarding/index.vue` | 按钮文字简化为"＋ 添加"、引入 TagDialog、重构 addCustomTag、字数限制 8→4、toast 文案、`.tag-choice` 不换行 |
| `mini-program/src/pages/onboarding/tag-manage.vue` | 引入 TagDialog、重构 confirmDelete、`.tag-text` 不换行 |

---

## Task 1: 新建 TagDialog.vue 组件

**目标：** 创建自定义弹窗组件，支持 input（输入）和 confirm（确认）两种模式，样式与小程序深色太空主题一致。

**Files:**
- Create: `mini-program/src/components/TagDialog.vue`

### Step 1: 创建组件文件

创建 `mini-program/src/components/TagDialog.vue`，完整内容：

```vue
<template>
  <view v-if="visible" class="tag-dialog-mask" @click="onMaskClick">
    <view class="tag-dialog-card glass-card" @click.stop>
      <view class="tag-dialog-title-row">
        <text class="tag-dialog-gold">✦</text>
        <text class="tag-dialog-title">{{ title }}</text>
      </view>

      <input
        v-if="mode === 'input'"
        class="tag-dialog-input"
        :value="modelValue"
        :placeholder="placeholder"
        placeholder-class="tag-dialog-placeholder"
        :maxlength="4"
        @input="onInput"
        @confirm="onInputConfirm"
      />

      <text v-if="mode === 'confirm'" class="tag-dialog-content">{{ content }}</text>

      <view class="tag-dialog-actions">
        <view class="tag-dialog-btn tag-dialog-btn-cancel" @click="onCancel">
          <text>取消</text>
        </view>
        <view class="tag-dialog-btn tag-dialog-btn-confirm" @click="onConfirm">
          <text>确定</text>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup>
const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  mode: {
    type: String,
    default: 'input'
  },
  title: {
    type: String,
    default: ''
  },
  content: {
    type: String,
    default: ''
  },
  placeholder: {
    type: String,
    default: ''
  },
  modelValue: {
    type: String,
    default: ''
  }
})

const emit = defineEmits(['confirm', 'cancel', 'update:modelValue', 'update:visible'])

const onInput = (event) => {
  const value = event.detail.value
  emit('update:modelValue', value)
}

const onInputConfirm = () => {
  emit('confirm', props.modelValue)
}

const onConfirm = () => {
  if (props.mode === 'input') {
    emit('confirm', props.modelValue)
  } else {
    emit('confirm')
  }
}

const onCancel = () => {
  emit('cancel')
  emit('update:visible', false)
}

const onMaskClick = () => {
  emit('cancel')
  emit('update:visible', false)
}
</script>

<style scoped>
.tag-dialog-mask {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(3, 2, 13, 0.72);
}

.tag-dialog-card {
  width: 560rpx;
  box-sizing: border-box;
  padding: 36rpx 32rpx 28rpx;
  border-radius: 22rpx;
  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);
}

.tag-dialog-title-row {
  display: flex;
  align-items: center;
  gap: 12rpx;
  margin-bottom: 24rpx;
}

.tag-dialog-gold {
  color: #ffd58c;
  font-size: 26rpx;
  text-shadow: 0 0 20rpx rgba(255, 202, 125, 0.45);
}

.tag-dialog-title {
  color: #fff;
  font-size: 30rpx;
  font-weight: 900;
}

.tag-dialog-input {
  width: 100%;
  box-sizing: border-box;
  height: 80rpx;
  margin-bottom: 28rpx;
  padding: 0 20rpx;
  border-radius: 14rpx;
  border: 1rpx solid rgba(151, 111, 255, 0.22);
  background: rgba(10, 12, 40, 0.66);
  color: #fff;
  font-size: 26rpx;
}

.tag-dialog-placeholder {
  color: rgba(214, 204, 235, 0.42);
}

.tag-dialog-content {
  display: block;
  margin-bottom: 32rpx;
  color: rgba(239, 232, 255, 0.86);
  font-size: 26rpx;
  line-height: 1.6;
}

.tag-dialog-actions {
  display: flex;
  gap: 20rpx;
}

.tag-dialog-btn {
  flex: 1;
  height: 72rpx;
  border-radius: 14rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 26rpx;
  font-weight: 700;
}

.tag-dialog-btn-cancel {
  color: rgba(239, 232, 255, 0.82);
  border: 1rpx solid rgba(151, 111, 255, 0.42);
  background: rgba(255, 255, 255, 0.02);
}

.tag-dialog-btn-confirm {
  color: #fff;
  border: 1rpx solid rgba(206, 82, 255, 0.95);
  background: linear-gradient(180deg, rgba(169, 61, 255, 0.62), rgba(107, 41, 206, 0.5));
  box-shadow: 0 0 18rpx rgba(168, 67, 255, 0.52), inset 0 1rpx 0 rgba(255, 255, 255, 0.22);
}
</style>
```

### Step 2: 提交

```bash
git add mini-program/src/components/TagDialog.vue
git commit -m "feat：新增 TagDialog 自定义弹窗组件，深色太空主题样式"
```

---

## Task 2: 编辑资料页改动（index.vue）

**目标：** 简化添加按钮文字、引入 TagDialog、重构 addCustomTag 用自定义弹窗、字数限制收紧到 4、标签不换行。

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

### Step 1: 模板 — 简化按钮文字

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

找到性格标签 panel 末尾占位（约第 146 行）：

```html
          <text class="tag-choice dashed" @click="addCustomTag('personality')">＋ 添加标签</text>
```

改为：

```html
          <text class="tag-choice dashed" @click="addCustomTag('personality')">＋ 添加</text>
```

找到兴趣爱好 panel 末尾占位（约第 167 行）：

```html
          <text class="tag-choice dashed" @click="addCustomTag('hobby')">＋ 添加兴趣</text>
```

改为：

```html
          <text class="tag-choice dashed" @click="addCustomTag('hobby')">＋ 添加</text>
```

### Step 2: 模板 — 挂载 TagDialog 组件

找到 `</scroll-view>` 和 `</view>` 包裹的页面结构末尾（约第 185-186 行）：

```html
    </scroll-view>
  </view>
</template>
```

在 `</scroll-view>` 之后、`</view>` 之前插入 TagDialog 挂载：

```html
    </scroll-view>

    <TagDialog
      v-model="dialogInput"
      v-model:visible="dialogVisible"
      :mode="dialogMode"
      :title="dialogTitle"
      :placeholder="dialogPlaceholder"
      :content="dialogContent"
      @confirm="onDialogConfirm"
      @cancel="onDialogCancel"
    />
  </view>
</template>
```

### Step 3: script — 引入组件

找到 import 区（约第 189-193 行）：

```javascript
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'
```

在最后一行 import 之后追加：

```javascript
import TagDialog from '../../components/TagDialog.vue'
```

### Step 4: script — 新增弹窗响应式状态

找到 `const saving = ref(false)` 这一行（约第 198 行附近，在 `const isEdit = ref(false)` 之后），在其下方追加：

```javascript
const dialogVisible = ref(false)
const dialogMode = ref('input')
const dialogTitle = ref('')
const dialogPlaceholder = ref('')
const dialogContent = ref('')
const dialogInput = ref('')
const dialogAction = ref(null)
```

### Step 5: script — 重构 addCustomTag 并新增 handleAddTag / onDialogConfirm / onDialogCancel

找到现有的 `addCustomTag` 函数（约第 410-449 行，从 `/**` 注释到 `}` 结束）：

```javascript
/**
 * 新增自定义标签（性格标签 / 兴趣爱好通用）
 * @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)
    }
  })
}
```

完整替换为以下四个函数：

```javascript
/**
 * 打开添加标签弹窗
 * @param {string} type 'personality' 或 'hobby'
 */
const addCustomTag = (type) => {
  const isPersonality = type === 'personality'
  dialogMode.value = 'input'
  dialogTitle.value = isPersonality ? '添加性格标签' : '添加兴趣爱好'
  dialogPlaceholder.value = '请输入标签（最多4个字）'
  dialogInput.value = ''
  dialogAction.value = { kind: 'add', type }
  dialogVisible.value = true
}

/**
 * 处理添加标签的实际逻辑（字数限制 4 字）
 */
const handleAddTag = (type, rawValue) => {
  const value = String(rawValue || '').trim()
  const isPersonality = type === 'personality'
  const library = isPersonality ? form.personalityTagLibrary : form.hobbyLibrary
  const selected = isPersonality ? form.personalityTags : form.hobbies

  if (!value) {
    uni.showToast({ title: '请输入标签内容', icon: 'none' })
    return
  }
  if (value.length > 4) {
    uni.showToast({ title: '内容不能超过4个字', 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)
}

/**
 * 弹窗确认回调
 */
const onDialogConfirm = (value) => {
  const action = dialogAction.value
  if (!action) return
  if (action.kind === 'add') {
    handleAddTag(action.type, value)
  }
  dialogAction.value = null
  dialogVisible.value = false
}

/**
 * 弹窗取消回调
 */
const onDialogCancel = () => {
  dialogAction.value = null
  dialogVisible.value = false
}
```

### Step 6: 样式 — .tag-choice 不换行

找到 `.tag-choice` 样式（约第 1008-1011 行）：

```css
.tag-choice {
  height: 40rpx;
  font-size: 21rpx;
}
```

改为：

```css
.tag-choice {
  height: 40rpx;
  font-size: 21rpx;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
```

### Step 7: 提交

```bash
git add mini-program/src/pages/onboarding/index.vue
git commit -m "feat：编辑页添加按钮简化为添加，字数限制4字，标签不换行，接入自定义弹窗"
```

---

## Task 3: 管理页改动（tag-manage.vue）

**目标：** 引入 TagDialog、重构 confirmDelete 用自定义弹窗、标签文字不换行。

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

### Step 1: 模板 — 挂载 TagDialog 组件

找到 `</scroll-view>` 和 `</view>` 包裹的页面结构末尾（约第 44-46 行）：

```html
    </scroll-view>
  </view>
</template>
```

在 `</scroll-view>` 之后、`</view>` 之前插入 TagDialog 挂载：

```html
    </scroll-view>

    <TagDialog
      v-model:visible="dialogVisible"
      mode="confirm"
      :title="dialogTitle"
      :content="dialogContent"
      @confirm="onDialogConfirm"
      @cancel="onDialogCancel"
    />
  </view>
</template>
```

### Step 2: script — 引入组件

找到 import 区（约第 48-51 行）：

```javascript
import { computed, onMounted, reactive, ref } from 'vue'
import { useAppStore } from '../../stores/app.js'
import { useMenuButtonSafeArea } from '../../composables/useMenuButtonSafeArea.js'
```

在最后一行 import 之后追加：

```javascript
import TagDialog from '../../components/TagDialog.vue'
```

### Step 3: script — 新增弹窗响应式状态

找到 `const type = ref('personality')` 这一行（约第 56 行），在其下方追加：

```javascript
const dialogVisible = ref(false)
const dialogTitle = ref('确认删除')
const dialogContent = ref('')
const pendingTag = ref(null)
```

### Step 4: script — 重构 confirmDelete 并新增 onDialogConfirm / onDialogCancel

找到现有的 `confirmDelete` 函数（约第 127-139 行）：

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

完整替换为以下三个函数：

```javascript
/**
 * 打开删除确认弹窗
 */
const confirmDelete = (tag) => {
  pendingTag.value = tag
  dialogContent.value = `确定删除「${tag}」吗？删除后不可恢复`
  dialogVisible.value = true
}

/**
 * 弹窗确认回调
 */
const onDialogConfirm = () => {
  if (pendingTag.value) {
    deleteTag(pendingTag.value)
    pendingTag.value = null
  }
  dialogVisible.value = false
}

/**
 * 弹窗取消回调
 */
const onDialogCancel = () => {
  pendingTag.value = null
  dialogVisible.value = false
}
```

### Step 5: 样式 — .tag-text 不换行

找到 `.tag-text` 样式（约第 311-314 行）：

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

改为：

```css
.tag-text {
  color: rgba(255, 255, 255, 0.9);
  font-size: 26rpx;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
```

### Step 6: 提交

```bash
git add mini-program/src/pages/onboarding/tag-manage.vue
git commit -m "feat：管理页删除确认接入自定义弹窗，标签文字不换行"
```

---

## Task 4: H5 浏览器验证

**目标：** 在 H5 模式下验证 spec 的 8 条验收标准。

### Step 1: 确认 H5 dev server 运行

```bash
python dev-services.py status
```

确认 `Emotion-museum-uniapp 前端` 状态为 `运行中`（端口 5175）。若未运行：

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

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

在浏览器中打开 `http://localhost:5175`，登录后进入编辑资料页（`?edit=1`）：

| # | 验收标准 | 验证方法 |
|---|---|---|
| 1 | 添加按钮只显示"＋ 添加" | 观察性格标签、兴趣爱好 panel 末尾占位文字 |
| 2 | 点击"＋ 添加"弹出深色太空主题弹窗 | 点击按钮，验证弹窗为毛玻璃卡片 + 紫色渐变按钮 + 金色 ✦，非白底蓝按钮 |
| 3 | 输入 5 字 toast 提示"内容不能超过4个字" | 输入 5 个字点确定，验证 toast 文案 |
| 4 | 输入 4 字以内新标签，确认后出现在最前 | 输入"测试"点确定，验证标签出现在列表第一位 |
| 5 | 标签过长时省略号截断不换行 | 输入 4 字标签，观察窄屏下是否单行省略号 |
| 6 | 管理页删除弹窗为深色主题 | 点"管理"进入管理页，点 × 或左滑，验证弹窗样式与文案 |
| 7 | 取消按钮和遮罩层点击都能关闭弹窗 | 分别测试取消按钮、点击遮罩层，验证弹窗关闭且不触发动作 |
| 8 | 弹窗 H5 下样式与深色主题一致 | 整体视觉检查 |

### Step 3: 验收通过后报告

所有 8 条通过后，确认工作区干净：

```bash
git status
```

---

## 执行顺序建议

1. Task 1（新建组件）→ Task 2（编辑页接入）→ Task 3（管理页接入）→ Task 4（验证）
2. Task 1 必须先完成，Task 2/3 依赖组件存在。
3. Task 2/3 完成后，H5 dev server 热加载自动生效，可直接进入 Task 4 验证。
