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

17 KiB
Raw Blame History

author, created_at, purpose
author created_at purpose
claude 2026-06-27 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 写入以下内容:

<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 存在。

ls "G:/IdeaProjects/emotion-museun/mini-program/src/components/MessageCard.vue"
  • Step 3: 提交组件创建
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 组件:

修改前:

import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'

修改后:

import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import MessageCard from '../../components/MessageCard.vue'
  • Step 2: 替换 story-card 模板

找到第 172-219 行的 story-card 模板,替换为 MessageCard 组件。

修改前:

<view class="story-card" :class="{ collapsed: storyCollapsed, dialogMode: resultHasScriptReplies }">
  ...原有 story-card 全部内容...
</view>

修改后:

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

修改前:

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

修改后:

<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: 提交使用组件
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: 验证样式清理后无遗漏

cd "G:/IdeaProjects/emotion-museun/mini-program"
npm run build:h5 2>&1 | tail -20

预期输出:DONE Build complete.

  • Step 3: 提交样式清理
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: 构建验证

cd "G:/IdeaProjects/emotion-museun/mini-program"
npm run build:h5 2>&1 | tail -20

预期输出:DONE Build complete.

  • Step 2: 功能验证(在 H5 浏览器中)

启动 H5 开发服务器并验证:

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: 提交验证结果(如有修复)

如果在验证中修复了问题,提交:

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 与模板中使用一致,事件名称在组件和父组件中一致