Files
happy-life-star/docs/superpowers/plans/2026-06-27-message-card-component.md
T

659 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
author: claude
created_at: 2026-06-27
purpose: MessageCard 组件统一提取实现计划
---
# MessageCard 组件统一实现计划
> **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:** 提取 MessageCard.vue 组件,统一 story-card 和 result-chat-list 中 AI 消息卡片的样式,并按 100 字阈值自动切换短消息/完整卡片模式
**Architecture:** 新建 `MessageCard.vue` 组件封装长消息卡片结构,ScriptView.vue 中 story-card 和 result-chat-list 都复用该组件,短消息直接渲染简洁气泡
**Tech Stack:** Vue 3, UniApp, 微信小程序
---
## 文件结构
**创建:**
- `mini-program/src/components/MessageCard.vue` - 通用 AI 消息卡片组件
**修改:**
- `mini-program/src/pages/main/ScriptView.vue` - 替换 story-card 和 result-chat-list 中的 assistant 消息渲染,并迁移相关样式
---
### Task 1: 创建 MessageCard.vue 组件(短消息模式 + 基础结构)
**Files:**
- Create: `mini-program/src/components/MessageCard.vue`
- Modify: 无
- [ ] **Step 1: 创建组件文件并写入基础模板**
`mini-program/src/components/MessageCard.vue` 写入以下内容:
```vue
<template>
<view v-if="isShortMessage" class="chat-bubble system">
<text>{{ content }}</text>
</view>
<view v-else class="story-card" :class="{ collapsed }">
<view class="story-head">
<view class="story-title-wrap">
<text class="story-title">{{ title || '我的人生剧本' }}</text>
<view v-if="tags?.length" class="tag-row">
<text v-for="tag in tags" :key="tag" class="tag">{{ tag }}</text>
</view>
</view>
<view class="story-head-actions">
<button class="collapse-icon" @click="$emit('toggle-collapse')">
<text class="collapse-icon-text">{{ collapsed ? '展开' : '收起' }}</text>
<view class="collapse-chevron" :class="{ down: collapsed }">
<view></view>
<view></view>
</view>
</button>
<button class="copy-card-btn" @click="$emit('copy')">复制</button>
</view>
</view>
<scroll-view
v-if="collapsed"
class="story-body-scroll"
scroll-y
:enhanced="true"
:show-scrollbar="true"
>
<text class="story-body" :selectable="true" :user-select="true">{{ content }}</text>
</scroll-view>
<text v-else class="story-body" :selectable="true" :user-select="true">{{ content }}</text>
<view class="collapse-row" @click="$emit('toggle-collapse')">
<text class="collapse-row-text">{{ collapsed ? '展开全文' : '收起全文' }}</text>
<view class="collapse-chevron" :class="{ down: collapsed }">
<view></view>
<view></view>
</view>
</view>
<view class="result-actions">
<button class="action-btn" @click="$emit('change-direction')">换个方向</button>
<button class="action-btn" @click="$emit('not-like-me')">不像我</button>
<button class="action-btn" @click="$emit('continue')">继续生成</button>
<button class="action-btn primary" @click="$emit('play-tts')">
<text class="action-icon">{{ ttsIcon || '▶' }}</text>
<text>{{ ttsText || '播放' }}</text>
</button>
</view>
</view>
</template>
<script setup>
const props = defineProps({
content: { type: String, required: true },
title: { type: String, default: '' },
tags: { type: Array, default: () => [] },
collapsed: { type: Boolean, default: false },
contentLength: { type: Number, required: true },
isShortMessage: { type: Boolean, required: true },
ttsIcon: { type: String, default: '▶' },
ttsText: { type: String, default: '播放' }
})
defineEmits([
'toggle-collapse',
'copy',
'change-direction',
'not-like-me',
'continue',
'play-tts'
])
</script>
<style scoped>
.chat-bubble {
max-width: 86%;
display: flex;
flex-direction: column;
gap: 8rpx;
padding: 24rpx 28rpx;
border-radius: 36rpx;
font-size: 34rpx;
line-height: 52rpx;
}
.chat-bubble.system {
align-self: flex-start;
background: rgba(16, 8, 34, 0.28);
border: 1rpx solid rgba(192, 132, 252, 0.3);
color: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8rpx);
-webkit-backdrop-filter: blur(8rpx);
}
.story-card {
border-radius: 52rpx;
padding: 34rpx;
background: rgba(12, 5, 28, 0.34);
border: 1rpx solid rgba(192, 132, 252, 0.46);
box-shadow: 0 0 48rpx rgba(125, 55, 205, 0.14);
backdrop-filter: blur(6rpx);
-webkit-backdrop-filter: blur(6rpx);
}
.story-card.collapsed {
box-shadow: 0 0 42rpx rgba(125, 55, 205, 0.14);
}
.story-head {
display: flex;
align-items: flex-start;
gap: 20rpx;
}
.story-head-actions {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12rpx;
}
.story-title-wrap {
flex: 1;
min-width: 0;
}
.story-title {
display: block;
font-size: 52rpx;
font-weight: 700;
line-height: 1.35;
}
.collapse-icon {
width: 120rpx;
min-width: 120rpx;
height: 58rpx;
min-height: 58rpx;
line-height: 1;
padding: 0 18rpx;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 8rpx;
color: rgba(255, 244, 255, 0.95);
background:
linear-gradient(135deg, rgba(134, 72, 255, 0.44), rgba(39, 15, 76, 0.42)),
rgba(255, 255, 255, 0.05);
border: 1rpx solid rgba(216, 180, 254, 0.46);
box-shadow:
0 14rpx 30rpx rgba(61, 18, 113, 0.22),
inset 0 1rpx 0 rgba(255, 255, 255, 0.26),
inset 0 -10rpx 18rpx rgba(33, 9, 73, 0.16);
backdrop-filter: blur(8rpx);
-webkit-backdrop-filter: blur(8rpx);
font-size: 23rpx;
font-weight: 800;
letter-spacing: 0;
}
.collapse-icon::after {
border: 0;
}
.collapse-icon:active,
.copy-card-btn:active,
.collapse-row:active {
transform: scale(0.98);
opacity: 0.92;
}
.copy-card-btn {
width: 120rpx;
min-width: 120rpx;
height: 50rpx;
min-height: 50rpx;
line-height: 1;
padding: 0;
border-radius: 999rpx;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 244, 255, 0.9);
font-size: 23rpx;
font-weight: 800;
letter-spacing: 0;
background:
radial-gradient(circle at 50% -30%, rgba(255, 255, 255, 0.18), transparent 50%),
rgba(88, 28, 135, 0.26);
border: 1rpx solid rgba(216, 180, 254, 0.32);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.16),
0 10rpx 22rpx rgba(61, 18, 113, 0.12);
backdrop-filter: blur(8rpx);
-webkit-backdrop-filter: blur(8rpx);
}
.copy-card-btn::after {
border: 0;
}
.collapse-icon-text,
.collapse-row-text {
line-height: 1;
white-space: nowrap;
}
.collapse-chevron {
position: relative;
width: 20rpx;
height: 16rpx;
flex-shrink: 0;
transition: transform 0.18s ease;
}
.collapse-chevron.down {
transform: rotate(180deg);
}
.collapse-chevron view {
position: absolute;
top: 7rpx;
width: 12rpx;
height: 4rpx;
border-radius: 999rpx;
background: linear-gradient(90deg, #fff3b0, #ffd86b);
box-shadow: 0 0 12rpx rgba(255, 216, 107, 0.5);
}
.collapse-chevron view:first-child {
left: 0;
transform: rotate(-38deg);
transform-origin: right center;
}
.collapse-chevron view:last-child {
right: 0;
transform: rotate(38deg);
transform-origin: left center;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 18rpx;
}
.tag {
padding: 6rpx 16rpx;
border-radius: 999rpx;
color: #d18aff;
font-size: 24rpx;
background: rgba(168, 85, 247, 0.22);
}
.story-body {
display: block;
margin-top: 28rpx;
font-size: 32rpx;
font-weight: 400;
line-height: 1.78;
color: rgba(255, 255, 255, 0.92);
white-space: pre-wrap;
}
.story-body-scroll {
max-height: 420rpx;
height: auto;
margin-top: 28rpx;
padding-right: 8rpx;
box-sizing: border-box;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.story-body-scroll .story-body {
margin-top: 0;
}
.collapse-row {
position: relative;
height: 62rpx;
margin-top: 22rpx;
padding: 0 28rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
border-radius: 999rpx;
color: rgba(246, 230, 255, 0.96);
font-size: 26rpx;
font-weight: 800;
overflow: hidden;
background:
radial-gradient(circle at 50% -20%, rgba(255, 255, 255, 0.2), transparent 46%),
linear-gradient(135deg, rgba(98, 40, 174, 0.3), rgba(24, 8, 58, 0.24));
border: 1rpx solid rgba(192, 132, 252, 0.36);
box-shadow:
inset 0 1rpx 0 rgba(255, 255, 255, 0.16),
0 12rpx 28rpx rgba(61, 18, 113, 0.14);
backdrop-filter: blur(6rpx);
-webkit-backdrop-filter: blur(6rpx);
}
.collapse-row::before {
content: '';
position: absolute;
left: 34rpx;
right: 34rpx;
top: 0;
height: 1rpx;
background: linear-gradient(90deg, transparent, rgba(255, 236, 180, 0.6), transparent);
}
.result-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14rpx;
margin-top: 26rpx;
}
.action-btn {
height: 72rpx;
min-height: 72rpx;
padding: 0 8rpx;
border-radius: 28rpx;
display: flex;
align-items: center;
justify-content: center;
color: #e8ccff;
font-size: 26rpx;
font-weight: 700;
line-height: 1.15;
text-align: center;
white-space: normal;
box-sizing: border-box;
background: rgba(88, 28, 135, 0.18);
border: 1rpx solid rgba(192, 132, 252, 0.35);
}
.action-btn.primary {
color: #fff;
background: linear-gradient(145deg, #8c44f2, #5f1db8);
}
.action-icon {
margin-right: 8rpx;
font-size: 24rpx;
font-weight: 900;
line-height: 1;
}
.action-btn::after {
border: 0;
}
</style>
```
- [ ] **Step 2: 验证组件文件创建成功**
确认文件路径:`mini-program/src/components/MessageCard.vue` 存在。
```bash
ls "G:/IdeaProjects/emotion-museun/mini-program/src/components/MessageCard.vue"
```
- [ ] **Step 3: 提交组件创建**
```bash
cd "G:/IdeaProjects/emotion-museun"
git add mini-program/src/components/MessageCard.vue
git commit -m "feat: 创建 MessageCard 组件
封装长消息/故事卡片结构,支持短消息简洁气泡模式"
```
---
### Task 2: 在 ScriptView.vue 中注册并使用 MessageCard 组件
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`
- [ ] **Step 1: 导入组件**
`ScriptView.vue``<script setup>` 顶部,导入 MessageCard 组件:
修改前:
```javascript
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
```
修改后:
```javascript
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import MessageCard from '../../components/MessageCard.vue'
```
- [ ] **Step 2: 替换 story-card 模板**
找到第 172-219 行的 `story-card` 模板,替换为 MessageCard 组件。
修改前:
```vue
<view class="story-card" :class="{ collapsed: storyCollapsed, dialogMode: resultHasScriptReplies }">
...原有 story-card 全部内容...
</view>
```
修改后:
```vue
<MessageCard
:content="displayedResultContent"
:title="currentResult?.title"
:tags="resultTags"
:collapsed="storyCollapsed"
:content-length="displayedResultContent.length"
:is-short-message="false"
:tts-icon="ttsActionIcon"
:tts-text="ttsActionText"
@toggle-collapse="toggleStoryCollapse"
@copy="copyResultContent"
@change-direction="changeDirection"
@not-like-me="notLikeMe"
@continue="continueInChat"
@play-tts="trackTtsClick"
/>
```
- [ ] **Step 3: 替换 result-chat-list 中的 assistant 消息**
找到第 221-254 行的 `result-chat-list` 模板,替换为使用 MessageCard。
修改前:
```vue
<view v-if="resultMessages.length" class="result-chat-list">
<view
v-for="message in resultMessages"
:key="message.id"
class="chat-bubble"
:class="{ user: message.role === 'user', system: message.role === 'assistant', pending: message.pending }"
>
<text selectable user-select>{{ getMessageDisplayContent(message) }}</text>
<view v-if="message.pending" class="thinking-dots">
<view></view>
<view></view>
<view></view>
</view>
<view
v-if="isAssistantMessage(message)"
class="message-toggle"
@click.stop="toggleMessageCollapse(message)"
>
<text>{{ isMessageCollapsed(message) ? '展开' : '收起' }}</text>
...
</view>
<view v-if="isAssistantMessage(message)" class="message-actions">
...
</view>
<text class="bubble-time">{{ message.time }}</text>
</view>
</view>
```
修改后:
```vue
<view v-if="resultMessages.length" class="result-chat-list">
<view
v-for="message in resultMessages"
:key="message.id"
>
<MessageCard
v-if="isAssistantMessage(message)"
:content="message.content"
:collapsed="isMessageCollapsed(message)"
:content-length="message.content.length"
:is-short-message="message.content.length < 100"
:tts-icon="ttsPlayer.playing.value ? '' : ''"
:tts-text="ttsPlayer.playing.value ? '暂停' : '播放'"
@toggle-collapse="toggleMessageCollapse(message)"
@copy="copyMessageContent(message)"
@change-direction="changeDirection"
@not-like-me="notLikeMe"
@continue="continueInChat"
@play-tts="playMessageTts(message)"
/>
<view v-else class="chat-bubble user">
<text>{{ message.content }}</text>
<text class="bubble-time">{{ message.time }}</text>
</view>
</view>
</view>
```
- [ ] **Step 4: 提交使用组件**
```bash
cd "G:/IdeaProjects/emotion-museun"
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "feat: ScriptView 复用 MessageCard 组件
- story-card 使用 MessageCard
- result-chat-list 中的 assistant 消息按内容长度自动切换短消息/完整卡片模式"
```
---
### Task 3: 迁移并清理样式
**Files:**
- Modify: `mini-program/src/pages/main/ScriptView.vue`
- [ ] **Step 1: 从 ScriptView.vue 中删除已迁移到 MessageCard 的样式**
删除以下 CSS 类(从 `<style scoped>` 中移除):
- `.story-card``.story-card.collapsed`
- `.story-head``.story-head-actions``.story-title-wrap`
- `.story-title``.tag-row``.tag`
- `.collapse-icon``.copy-card-btn``.collapse-row`
- `.collapse-chevron``.story-body``.story-body-scroll`
- `.result-actions``.action-btn`
- `.message-actions`(上一轮临时添加的,不再使用)
- `.message-toggle`(不再使用)
保留 `ScriptView.vue` 中仍然需要的样式,如:
- `.chat-bubble.user`
- `.bubble-time`
- `.result-chat-list`
- `.thinking-dots`
- 页面整体布局相关样式
- [ ] **Step 2: 验证样式清理后无遗漏**
```bash
cd "G:/IdeaProjects/emotion-museun/mini-program"
npm run build:h5 2>&1 | tail -20
```
预期输出:`DONE Build complete.`
- [ ] **Step 3: 提交样式清理**
```bash
cd "G:/IdeaProjects/emotion-museun"
git add mini-program/src/pages/main/ScriptView.vue
git commit -m "refactor: 迁移 story-card 相关样式到 MessageCard
删除 ScriptView.vue 中已迁移到 MessageCard 的样式类"
```
---
### Task 4: 最终验证
**Files:**
- 无需修改文件,仅做验证
- [ ] **Step 1: 构建验证**
```bash
cd "G:/IdeaProjects/emotion-museun/mini-program"
npm run build:h5 2>&1 | tail -20
```
预期输出:`DONE Build complete.`
- [ ] **Step 2: 功能验证(在 H5 浏览器中)**
启动 H5 开发服务器并验证:
```bash
cd "G:/IdeaProjects/emotion-museun/mini-program"
npm run dev:h5
```
打开浏览器访问显示的端口(默认 `http://localhost:5173`),执行以下验证:
1. **第一次生成故事**:确认 story-card 样式、标题、标签、展开/收起、复制、功能按钮组均正常
2. **重新生成(生成 script 类型长消息)**
- 内容 ≥ 100 字:显示与 story-card 完全一致的卡片样式
- 内容 < 100 字:显示简洁气泡,无任何功能按钮
3. **普通聊天 assistant 消息**
- 内容 ≥ 100 字:显示完整卡片
- 内容 < 100 字:显示简洁气泡
4. **用户消息**:保持原有 `.chat-bubble.user` 样式不变
5. **控制台**:确认无 Vue 警告和 JavaScript 错误
- [ ] **Step 3: 提交验证结果(如有修复)**
如果在验证中修复了问题,提交:
```bash
git add mini-program/src/components/MessageCard.vue mini-program/src/pages/main/ScriptView.vue
git commit -m "fix: 修复 MessageCard 组件验证中发现的问题
[描述具体修复内容]"
```
如果验证全部通过,无需额外提交。
---
## 自审检查
- **Spec 覆盖:**
- ✅ 创建 MessageCard.vue 组件 → Task 1
- ✅ ScriptView.vue 复用组件 → Task 2
- ✅ 100 字阈值判断 → Task 2 (`:is-short-message="message.content.length < 100"`)
- ✅ 样式迁移和清理 → Task 3
- ✅ 验证 → Task 4
- **无占位符:** 计划中没有 TBD/TODO
- **类型一致:** props 名称 `contentLength` 与模板中使用一致,事件名称在组件和父组件中一致