diff --git a/docs/superpowers/specs/2026-06-02-vertical-progress-bar-design.md b/docs/superpowers/specs/2026-06-02-vertical-progress-bar-design.md new file mode 100644 index 0000000..26fc35a --- /dev/null +++ b/docs/superpowers/specs/2026-06-02-vertical-progress-bar-design.md @@ -0,0 +1,227 @@ +# VerticalProgressBar 组件设计 + +**日期**:2026-06-02 +**作者**:zerosaturation +**状态**:设计中 + +## 背景与目标 + +`support-activity` 页面(生日应援活动)目前仅在 [ThemeBanner.vue](../../../frontend/pages/support-activity/components/ThemeBanner.vue) 右下角以纯文字形式展示进度: + +``` +当前进度 19,901,123 / 19,911,005 +``` + +缺少一个**直观的、可视化**的进度呈现。本次新增一个独立的竖向进度条组件 `VerticalProgressBar`,承担"展示当前活动进度"这一职责,并实时反映后端轮询数据。 + +## 范围 + +- **In**: + - 新增可复用组件 `VerticalProgressBar.vue` + - 包含竖向进度条 + 当前/目标数字 + - 当前进度圆随实时数据沿轨道自下而上移动 +- **Out**: + - 不修改 `ThemeBanner.vue` + - 不在 `ThemeBanner.vue` 中嵌入此组件 + - 视觉细节(颜色、圆角、阴影等)不调优,由用户后续自行调整 + +## 组件设计 + +### 文件位置 + +`frontend/pages/support-activity/components/VerticalProgressBar.vue` + +### Props + +| 名称 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `current` | Number | 0 | 当前进度值 | +| `target` | Number | 100 | 目标进度值 | +| `barHeight` | String | `'200rpx'` | 轨道总高度 | +| `barWidth` | String | `'16rpx'` | 轨道宽度 | +| `circleSize` | String | `'60rpx'` | 进度圆直径 | +| `fillColor` | String | `'linear-gradient(180deg, #FFD700, #FFA500)'` | 填充渐变 | +| `trackColor` | String | `'rgba(255,255,255,0.2)'` | 轨道底色 | +| `textColor` | String | `'#FFD700'` | 当前数字颜色 | +| `targetColor` | String | `'rgba(255,255,255,0.8)'` | 目标数字颜色 | +| `showText` | Boolean | `true` | 是否显示数字(方便单测与单用) | + +### 视觉结构 + +``` + 19,911,005 ← 目标数字(位于目标圆上方) + ┌─┐ + │ │ ← 目标圆(固定在 bar 顶部) + ├─┤ + │ │ ← 未填充轨道(trackColor) + │ │ + │ │ + ┌─┴─┐ + │ │ + │19,│ ← 当前圆(带数字在内部,随 progress 上移) + │901│ + │ │ + ├─==┤ ← 已填充(fillColor,从底部至当前圆底部) + │ │ + │ │ + └───┘ +``` + +### 模板 + +```vue + +``` + +### 计算公式 + +```js +// 进度比例(0~1) +const ratio = computed(() => { + if (props.target === 0) return 0 + return Math.min(props.current / props.target, 1) +}) + +// 填充高度:从底部起,占 bar 的 ratio × 100% +const fillHeight = computed(() => ratio.value * 100 + '%') + +// 当前圆位置:从顶部起,(1 - ratio) × 100% +const circleTop = computed(() => (1 - ratio.value) * 100 + '%') + +// 数字本地化(避免每帧重建) +const formattedCurrent = ref(props.current.toLocaleString()) +const formattedTarget = ref(props.target.toLocaleString()) + +watch(() => props.current, (v) => { formattedCurrent.value = v.toLocaleString() }) +watch(() => props.target, (v) => { formattedTarget.value = v.toLocaleString() }) +``` + +> **公式可替换点**:未来若要换成非线性/缓动/阈值公式,仅需替换 `ratio` 这一处计算。 + +### CSS 关键点 + +```css +.v-progress { + position: fixed; + left: 24rpx; + top: 50%; + transform: translateY(-50%); + z-index: 50; + display: flex; + flex-direction: column; + align-items: center; + /* 其它样式留空,方便用户自行调 */ +} + +.v-track { + position: relative; + background: v-bind(trackColor); + border-radius: 999rpx; + overflow: visible; /* 关键:让圆能露出轨道边界 */ +} + +.v-fill { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + background: v-bind(fillColor); + border-radius: 999rpx; + transition: height 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.v-circle { + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.v-target-circle { + top: 0; + background: rgba(255, 255, 255, 0.3); + /* 不放数字 */ +} + +.v-current-circle { + background: v-bind(fillColor); + color: v-bind(textColor); + transition: top 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.v-current-text, +.v-target-text { + font-size: 20rpx; + line-height: 1; + white-space: nowrap; +} + +.v-target-text { + color: v-bind(targetColor); + margin-bottom: 8rpx; +} +``` + +### 行为 + +1. 组件挂载后,初始 `ratio` 决定当前圆位置和填充高度 +2. 父组件更新 `current` 时,`formattedCurrent` 通过 watch 重算(避免每帧 `toLocaleString`) +3. 当前圆 `top` 与 `.v-fill` 高度过渡时长 0.4s,使用 ease 曲线 +4. `target = 0` 时 `ratio = 0`,当前圆停在底部 + +## 边界与错误处理 + +| 场景 | 行为 | +|------|------| +| `target = 0` | `ratio = 0`,圆停在底部,无报错 | +| `current > target` | `ratio` 被 `Math.min` 截断为 1,圆停在顶部 | +| `current < 0` | 透传,UI 表现圆停在 `ratio < 0` 的位置(视觉上圆略微被遮挡) | +| 父组件不传任何 prop | 使用全部默认值(current=0, target=100)| + +## 使用方式 + +**不修改 `ThemeBanner.vue`**,在 `support-activity/index.vue` 中以兄弟节点方式使用: + +```vue + +``` + +页面已通过 `progressManager` 实时更新 `progressData.current`,因此组件会自动响应。 + +## 测试 + +> 单元测试不在本次范围。后续可补: +> +> - `ratio` 计算(target=0、current>target、current=负数等) +> - watch 触发 `toLocaleString` 次数 +> - 视觉回归(手动) + +## 待办与未来扩展 + +- [ ] 替换为非线性/缓动公式(按用户后续设计) +- [ ] 加入 stage 段位刻度(沿 bar 显示 N 段) +- [ ] 完成后切换为"庆祝态"动画 +- [ ] 提取公共 composable(`useRatio`)便于其他页面复用