topfans/docs/superpowers/specs/2026-06-02-vertical-progress-bar-design.md
2026-06-02 21:35:48 +08:00

228 lines
6.4 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.

# 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
<template>
<view class="v-progress">
<text v-if="showText" class="v-target-text">{{ formattedTarget }}</text>
<view
class="v-track"
:style="{ width: barWidth, height: barHeight }"
>
<view class="v-fill" :style="{ height: fillHeight }" />
<view class="v-circle v-target-circle" :style="{ width: circleSize, height: circleSize }" />
<view
class="v-circle v-current-circle"
:style="{ top: circleTop, width: circleSize, height: circleSize }"
>
<text v-if="showText" class="v-current-text">{{ formattedCurrent }}</text>
</view>
</view>
</view>
</template>
```
### 计算公式
```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
<VerticalProgressBar
:current="progressData.current"
:target="progressData.target"
/>
```
页面已通过 `progressManager` 实时更新 `progressData.current`,因此组件会自动响应。
## 测试
> 单元测试不在本次范围。后续可补:
>
> - `ratio` 计算target=0、current>target、current=负数等)
> - watch 触发 `toLocaleString` 次数
> - 视觉回归(手动)
## 待办与未来扩展
- [ ] 替换为非线性/缓动公式(按用户后续设计)
- [ ] 加入 stage 段位刻度(沿 bar 显示 N 段)
- [ ] 完成后切换为"庆祝态"动画
- [ ] 提取公共 composable`useRatio`)便于其他页面复用