style(stargalaxy): remove shared podium size — each .podium-N controls its own width/height

This commit is contained in:
zheng020 2026-06-10 19:16:03 +08:00
parent 021782d42f
commit 90803234ba
35 changed files with 1638 additions and 160 deletions

3
.gitignore vendored
View File

@ -46,3 +46,6 @@ hookify.*.local.md
# statisticService runtime logs (created by logger when running tests) # statisticService runtime logs (created by logger when running tests)
backend/services/statisticService/**/logs/ backend/services/statisticService/**/logs/
backend/services/statisticService/**/logs/*.log backend/services/statisticService/**/logs/*.log
# superpowers
.superpowers

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,276 @@
# 星河StarGalaxy组件设计
> **日期**: 2026-06-10
> **状态**: 待评审
> **目的**: 为 square 页面的「星河」tab 设计一个 3D 倾斜椭圆轨道 + 顺时针旋转的排行榜组件
> **参考文档**: `docs/figma-analysis-xiaohongshu-stars.md`Figma 节点 94:78「星河新版本」
---
## 1. 背景
square 页面现有 3 个内容 tab星河 / 星榜 / 广场),其中「星榜」是 12 行的横向列表样式(`HotCategoryBlock.vue`。本次新增「星河」tab沿用 Figma 中的双层结构:
- **上层**TOP 1-3 颁奖台cover + 下方标签 + 钻石渐变外框)
- **下层**TOP 4-12 散落在 65° 倾斜椭圆轨道上,绕中心 3D 旋转
设计追求饭圈甜美梦幻风格(粉红渐变 + 玻璃拟态 + 暖黄光晕 + 渐变描边),不显示用户名/点赞数(保持 cover 纯净)。
---
## 2. 文件结构
```
frontend/pages/square/components/StarGalaxy/
├── index.vue ← 容器数据加载、装饰层、TOP 1-3 + ScatteredRanks 编排
├── PodiumCard.vue ← TOP 1-3 颁奖台卡片cover + 下方 TOP N 标签 + 钻石外框)
├── ScatteredRanks.vue ← TOP 4-12 9 个散落 itemcover + 上方 TOP N 标签)
└── config.js ← 9 slot 位置/translate/scale 公式
```
`square.vue` 改动:在星河 tab 分支中渲染 `<StarGalaxy @cardClick="handleCardClick" />`,复用现有 `handleCardClick`(单击跳详情 + 双击点赞)。
---
## 3. 组件职责
| 组件 | 职责 | 内部状态 | 输入 Props | 事件 |
|----|----|----|----|----|
| `StarGalaxy/index.vue` | 数据加载(`getHotRankingApi`、装饰层渲染、3D 椭圆轨道 SVG、布局编排 | `items`、`loading` | — | `cardClick(item)` |
| `PodiumCard.vue` | 单张 TOP 1-3 大卡:钻石渐变外框 + cover + 下方 TOP N 标签 + 可选皇冠 | — | `rank: 4\|5\|6` 隐式;`item: HotRankingItem`、`size: {w,h}` | `click(item)` |
| `ScatteredRanks.vue` | TOP 4-12 共 9 个 item 散落在椭圆轨道 | — | `items: HotRankingItem[]` (length=9) | `cardClick(item)` |
| `config.js` | 9 个 slot 的绝对位置/translate/scale/zIndex 公式 | — | — | — |
---
## 4. 数据流
```
StarGalaxy/index.vue (onMounted)
├── getHotRankingApi("displaying", null, 1, 12)
│ │
│ └── res.data.items[0..11]
├── Promise.all → getAssetCoverRealUrl(item.cover_url) // 复用 HotCategoryBlock 的模式
└── items 拆成两段:
├── items.slice(0,3) → PodiumCard × 3
└── items.slice(3,12) → ScatteredRanks9 个 item
```
事件冒泡:所有 card 点击 → `emit('cardClick', item)``square.vue``handleCardClick`(已有,含单击跳详情 + 双击点赞)。
---
## 5. 视觉设计
### 5.1 容器尺寸
- 总宽 750rpx × 高约 1440rpx
- 适配 iPhone 标准 (375×812)
- 整体分上下两层:上层颁奖台 y=48~700下层椭圆轨道 y=800~1440
### 5.2 装饰层(背景 overlay
- 粉红渐变:`linear-gradient(179deg, #FFE5E5 0%, #F3A0A1 0%, #FF9C9C 86%, #FF2024 100%)`(覆盖 square 现有渐变之上)
- 樱花粉光晕:圆形 `#F3D3E3` + `filter: blur(60px)`居中top 580
- 暖黄光晕:圆形 `#FFFABD` + `filter: blur(50px)`top 50
- ⚠️ 不动 square.vue 现有渐变背景,星河只画自己的装饰层
### 5.3 PodiumCard 三个实例
| 排名 | 位置 (left, top) | 卡片尺寸 | 标签尺寸 | 装饰图 |
|----|----|----|----|----|
| TOP 1 | (50%, 400) translateX(-50%) | 240×260 (cover 240×260) | 192×44 (金渐变) | 钻石外框 + 皇冠 |
| TOP 2 | (60, 120) | 200×200 (cover 200×200) | 156×36 (银渐变) | 钻石外框 |
| TOP 3 | (470, 150) | 192×192 (cover 192×192) | 156×36 (铜渐变) | 钻石外框 |
**PodiumCard 内部层级**(从下到上):
```
┌─ 钻石渐变外框filter: blur(8-12px),不规则圆角 8px 22px 8px 19px
│ ├─ 藏品主图(不规则圆角 6px 20px 6px 17px
│ ├─ 青绿色高光 overlay180deg #53F4D3 → 透明)
│ └─ 钻石渐变边框层4px 渐变描边)
└─ TOP N 标签(绝对定位 bottom: -4px居中
```
**皇冠**(仅 TOP 1绝对定位 `top: -44px; left: 50%; transform: translateX(-50%)`font-size: 44rpx2s 循环 pulse 动画。
### 5.4 ScatteredRanks 9 个 slot 位置
9 个 item 在 65° 倾斜椭圆上,等角度 40° 间隔(顺时针),起始角 180°TOP 4 在正下方 = 最前)。
椭圆参数:
- 圆心 (cx, cy) = (187, 510)
- 水平半径 rx = 130
- 垂直半径 ry = 55cos(65°) ≈ 0.423,模拟向后倾斜 65°
- 起始角 startAngle = 180°slot 0 在正下方)
- 间隔角 step = -40°顺时针 = 负方向)
| Slot | Rank | 中心 (x, y) | 渲染尺寸 (w×h) | scale | z-index |
|----|----|----|----|----|----|
| 0 | TOP 4 | (187, 565) | 46×70 | 1.15 | 10 |
| 1 | TOP 5 | (271, 552) | 46×70 | 1.05 | 9 |
| 2 | TOP 6 | (321, 488) | 46×70 | 0.95 | 6 |
| 3 | TOP 7 | (300, 482) | 46×70 | 0.85 | 3 |
| 4 | TOP 8 | (232, 458) | 46×70 | 0.75 | 0 |
| 5 | TOP 9 | (142, 458) | 46×70 | 0.75 | 0 |
| 6 | TOP 10 | (74, 482) | 46×70 | 0.85 | 3 |
| 7 | TOP 11 | (8, 488) | 46×70 | 0.95 | 6 |
| 8 | TOP 12 | (80, 518) | 46×70 | 1.05 | 9 |
**OVERRIDES**TOP 6 / TOP 11 推到边缘 (8, 488) 和 (321, 488),避免与 TOP 5/7、TOP 12/10 重叠。
### 5.5 单个 item 结构ScatteredRanks
尺寸 46×70 = label 14 + gap 2 + cover 56
```vue
<view class="ring-item">
<view class="top-label">TOP 4</view> <!-- 14rpxlabel 在 cover 上方 -->
<image class="cover" src="..." /> <!-- 56rpxcover 在 label 下方 -->
</view>
```
- **label**(顶部 14rpx
- 背景:`radial-gradient(ellipse, #C8E6FF, #fff 50%, #4D9AF8)`(蓝白钻石)
- TOP 4 label 用金渐变:`#FFFFFF, #FFFABD 30%, #4D9AF8 100%`
- 圆角 7rpx
- 文字14rpx Baloo Bhaifallback monospace`#FFFABD``text-shadow: -1px 1px 2px rgba(206,9,9,0.84)`
- **cover**(下方 56rpx
- 圆角 5rpx
- box-shadow`3px 3px 6px rgba(198,13,13,0.45)`(按 scale 调整)
### 5.6 颜色 / 渐变 / 阴影
| Token | 值 | 用途 |
|----|----|----|
| 主背景 | `linear-gradient(179deg, #FFE5E5 0%, #F3A0A1 0%, #FF9C9C 86%, #FF2024 100%)` | 装饰层渐变 |
| 樱花粉光晕 | `#F3D3E3` + `blur(60px)` | 椭圆中心光晕 |
| 暖黄光晕 | `#FFFABD` + `blur(50px)` | 顶部装饰光晕 |
| 钻石渐变(外框) | `radial-gradient(ellipse at -10% 5%, #86BEFF, #FF3939 32%, #88FFCE 59%, #4D9AF8)` | PodiumCard 描边 |
| 蓝白钻石(标签) | `radial-gradient(ellipse, #C8E6FF, #fff 50%, #4D9AF8)` | 9 item label |
| 金渐变TOP 4 label | `radial-gradient(ellipse, #FFFFFF, #FFFABD 30%, #4D9AF8 100%)` | TOP 4 label |
| 金渐变TOP 1 标签) | `radial-gradient(ellipse, #FFD700, #FFF6A8 30%, #DAA520 100%)` | TOP 1 标签 |
| 银渐变TOP 2 标签) | `radial-gradient(ellipse, #C0C0C0, #E8E8E8 50%, #7A7A7A)` | TOP 2 标签 |
| 铜渐变TOP 3 标签) | `radial-gradient(ellipse, #CD7F32, #E8A45C 50%, #A0522D)` | TOP 3 标签 |
| TOP 数字字色 | `#FFFABD` + `text-shadow: -1px 1px 2px rgba(206,9,9,0.84)` | label 文字 |
| 卡片阴影 | `3px 3px 6px rgba(198,13,13,0.45)`(随 scale 微调) | cover 阴影 |
### 5.7 字体
| 字体 | 用途 | Fallback |
|----|----|----|
| Baloo Bhai | label 数字TOP 4、TOP 5 等) | monospace |
| HYQiHei | 不使用(已移除用户名显示) | PingFang |
| C800 | 不使用(已移除 TOP 排名文字) | system bold sans |
---
## 6. 动画设计
### 6.1 9 item 顺时针旋转
```
@keyframes orbit {
0% { transform: translate(0,0) scale(1.15); } /* slot 0: front */
11.11% { transform: translate(84px,-13px) scale(1.05); } /* slot 1 */
22.22% { transform: translate(157px,-43px) scale(0.95); } /* slot 2 */
33.33% { transform: translate(113px,-83px) scale(0.85); } /* slot 3 */
44.44% { transform: translate(45px,-107px) scale(0.75); } /* slot 4 */
55.55% { transform: translate(-45px,-107px) scale(0.75); } /* slot 5 */
66.66% { transform: translate(-113px,-83px) scale(0.85); } /* slot 6 */
77.77% { transform: translate(-156px,-43px) scale(0.95); } /* slot 7 */
88.88% { transform: translate(-84px,-13px) scale(1.05); } /* slot 8 */
100% { transform: translate(0,0) scale(1.15); } /* loop */
}
```
- 总周期 36s4s 一步
- 线性 timing匀速旋转
- infinite 循环
### 6.2 9 个 item 的 delay 错开
```
.ring-item { animation: orbit 36s linear infinite; }
.r0 { animation-delay: 0s; } /* TOP 4 起始 slot 0 */
.r1 { animation-delay: -4s; } /* TOP 5 起始 slot 1 */
.r2 { animation-delay: -8s; } /* TOP 6 起始 slot 2 */
...
.r8 { animation-delay: -32s; } /* TOP 12 起始 slot 8 */
```
负值 delay 让每个 item 起始位置 = 对应的 slot9 个 item 静态分布在 9 个 slot 上。
### 6.3 皇冠脉冲
```css
@keyframes crownPulse {
0%, 100% { transform: translateX(-50%) scale(1); }
50% { transform: translateX(-50%) scale(1.15); }
}
.crown { animation: crownPulse 2s ease-in-out infinite; }
```
### 6.4 可访问性
```css
@media (prefers-reduced-motion: reduce) {
.ring-item { animation: none; }
.crown { animation: none; }
/* item 用 inline transform 写到元素上,作为静态位置 */
}
```
---
## 7. 状态管理
| 状态 | 显示 |
|----|----|
| Loading | 12 个骨架占位3 大 + 9 小)+ shimmer 动画 |
| Success | 完整布局 |
| Empty (items.length < 3) | 数据加载中+ 重试按钮 |
| Error | 居中提示「加载失败,点击重试」+ 重试按钮 |
---
## 8. 交互细节
- **单击 PodiumCard / ScatteredRank 缩略图**emit('cardClick', item) → square.vue 已有逻辑 → 跳 `pages/asset-detail/asset-detail`
- **双击**:同样走 square.vue 的 `handleCardClick``doubleTapLike` + 波纹动画
- **TOP 1 奖牌/皇冠**CSS keyframes 微脉冲scale 1.0 → 1.15 → 1.02s 循环)
---
## 9. 性能/可访问性
- 图片懒加载用 uni-app `<image lazy-load>`(与现有 HotCategoryBlock 一致)
- `prefers-reduced-motion: reduce` 关闭旋转和脉冲
- 9 item 动画使用 GPU 加速transform
- 椭圆轨道 SVG 装饰层 `pointer-events: none`
---
## 10. 风险与备选
| 风险 | 缓解 |
|----|----|
| 旋转动画在低端机上卡顿 | 可降级到 60s 周期;或加 `will-change: transform` |
| 椭圆轨道的虚线 SVG 在某些小程序不渲染 | 提供 `v-if` 兜底,仅 H5 显示 |
| 数据 < 9 条时 ScatteredRanks 渲染异常 | v-for 配合 length 校验< 9 时只渲染实际条数 |
---
## 11. 验收标准
1. ✅ square 页点击「星河」tab显示 12 个 item 的 3D 排行榜
2. ✅ TOP 1-3 在上方颁奖台位置cover 下方有金/银/铜 TOP N 标签
3. ✅ TOP 4-12 在下方椭圆轨道9 个 item 等大 46×70标签在 cover 上方
4. ✅ 9 item 顺时针连续旋转36s 一圈)
5. ✅ 近大远小scale 0.75→1.15)表达 3D 透视
6. ✅ 点击任一卡片跳 asset-detail
7. ✅ 双击点赞 + 波纹动画
8. ✅ Loading / Empty / Error 状态都正常显示
9. ✅ `prefers-reduced-motion: reduce` 时关闭旋转
10. ✅ H5 + 小程序 + APP 三端视觉一致

View File

@ -42,8 +42,8 @@
:key="item.id || index" :key="item.id || index"
class="grid-card" class="grid-card"
:class="{ :class="{
'grid-card-top': index < 3, [`grid-card-top-${index + 1}`]: index < 5,
[`grid-card-top-${index + 1}`]: index < 3, 'grid-card-top-other': index >= 5,
}" }"
@click="handleCardClick(item)" @click="handleCardClick(item)"
> >
@ -147,9 +147,33 @@ const tabs = [
{ {
key: "hot", key: "hot",
label: "热度榜", label: "热度榜",
icon: "/static/square/rementubiao.png", icon: "/static/square/galaxy/dianzanbang.png",
iconWidth: 32, iconWidth: 64,
iconHeight: 40, iconHeight: 72,
fetch: () => getHotRankingApi("displaying", null, 1, 11),
},
{
key: "new",
label: "活跃榜",
icon: "/static/square/galaxy/huoyuebang.png",
iconWidth: 64,
iconHeight: 72,
fetch: () => getHotRankingApi("displaying", null, 1, 11),
},
{
key: "trending",
label: "曝光榜",
icon: "/static/square/galaxy/baoguangbang.png",
iconWidth: 64,
iconHeight: 72,
fetch: () => getHotRankingApi("displaying", null, 1, 11),
},
{
key: "trending",
label: "同城榜",
icon: "/static/square/galaxy/tongchengbang.png",
iconWidth: 64,
iconHeight: 72,
fetch: () => getHotRankingApi("displaying", null, 1, 11), fetch: () => getHotRankingApi("displaying", null, 1, 11),
}, },
]; ];
@ -279,7 +303,7 @@ onUnmounted(() => {
}); });
</script> </script>
<style scoped> <style scoped lang="scss">
.hot-category-block { .hot-category-block {
padding: 0 9.5rpx; padding: 0 9.5rpx;
border-radius: 24rpx; border-radius: 24rpx;
@ -289,11 +313,12 @@ onUnmounted(() => {
/* Tab 栏 */ /* Tab 栏 */
.ranking-tabs { .ranking-tabs {
display: flex; display: flex;
flex-direction: row; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 16rpx; padding: 0 16rpx;
position: relative; position: relative;
left: 50%; left: 50%;
top: 16rpx;
transform: translate(-50%); transform: translate(-50%);
opacity: 0.8; opacity: 0.8;
border-top-left-radius: 14px; border-top-left-radius: 14px;
@ -302,20 +327,22 @@ onUnmounted(() => {
border-bottom-left-radius: 7px; border-bottom-left-radius: 7px;
z-index: 1; /* 在内容网格之下 */ z-index: 1; /* 在内容网格之下 */
width: 480rpx; width: 480rpx;
height: 80rpx; height: 96rpx;
background: linear-gradient(183.58deg, #ff5a5d -36.55%, #c2ebff 121.2%); background: linear-gradient(183.58deg, #ff5a5d -36.55%, #c2ebff 121.2%);
backdrop-filter: blur(11.699999809265137px); backdrop-filter: blur(11.699999809265137px);
} }
.ranking-tab-item { .ranking-tab-item {
height: 80rpx; height: 80rpx;
width: 88rpx;
border-radius: 20rpx; border-radius: 20rpx;
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;
transition: all 0.25s ease; transition: all 0.25s ease;
position: absolute; /* position: relative .ranking-tab-icon position: absolute
top: 24rpx; 注意不能写 position: absolute否则 4 tab 会从 flex 流中抽离并全部堆在 left:0 重叠 */
position: relative;
} }
.ranking-tab-item.active { .ranking-tab-item.active {
@ -326,7 +353,11 @@ onUnmounted(() => {
); );
backdrop-filter: blur(1.7999999523162842px); backdrop-filter: blur(1.7999999523162842px);
height: 144rpx; height: 144rpx;
width: 88rpx; top: 40rpx;
}
.ranking-tab-item.active .ranking-tab-icon {
top: -24rpx;
} }
.ranking-tab-label { .ranking-tab-label {
@ -341,7 +372,6 @@ onUnmounted(() => {
.ranking-tab-icon { .ranking-tab-icon {
display: block; display: block;
position: absolute; position: absolute;
top: -8rpx;
} }
.ranking-tab-item.active .ranking-tab-label { .ranking-tab-item.active .ranking-tab-label {
@ -451,7 +481,6 @@ onUnmounted(() => {
position: relative; position: relative;
width: 90rpx; width: 90rpx;
height: 120rpx; height: 120rpx;
} }
.card-image { .card-image {
width: 100%; width: 100%;
@ -475,17 +504,57 @@ onUnmounted(() => {
transform: rotate(45deg); transform: rotate(45deg);
} }
/* 前 3 名专属:卡片整体突出 */
.grid-card-top {
}
.grid-card-top-1 { .grid-card-top-1 {
background: url("/static/square/galaxy/TOP.png") no-repeat center;
} }
.grid-card-top-2 { .grid-card-top-2 {
background: url("/static/square/galaxy/TOP2.png") no-repeat center;
} }
.grid-card-top-3 { .grid-card-top-3 {
background: url("/static/square/galaxy/TOP3.png") no-repeat center;
}
.grid-card-top-4 {
position: relative;
overflow: hidden;
// [3] bj.png opacity
&::before {
content: "";
position: absolute;
inset: 0;
background: url("/static/dashboard/bj.png") center / cover no-repeat;
opacity: 0.8; // 0=1=
pointer-events: none;
z-index: 0;
}
}
.grid-card-top-5 {
position: relative;
overflow: hidden;
// [3] bj.png opacity
&::before {
content: "";
position: absolute;
inset: 0;
background: url("/static/dashboard/bj.png") center / cover no-repeat;
opacity: 0.8; // 0=1=
pointer-events: none;
z-index: 0;
}
}
.grid-card-top-other {
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
inset: 0;
background: url("/static/square/top/bj.png") center / cover no-repeat;
opacity: 0.26;
}
} }
/* 前 3 名专属:包裹藏品图的边框图(叠加在 card-image 之上) */ /* 前 3 名专属:包裹藏品图的边框图(叠加在 card-image 之上) */

View File

@ -39,12 +39,7 @@ function handleClick() {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
// rank podium // .podium-N
// .podium-N rank
background-image: url("/static/square/galaxy/topbj3.png");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
} }
/* TOP 1: 中央大卡(最大) */ /* TOP 1: 中央大卡(最大) */
@ -52,27 +47,84 @@ function handleClick() {
top: 400rpx; top: 400rpx;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
width: 96rpx; width: 240rpx;
height: 128rpx; height: 320rpx;
&::before {
content: "";
position: absolute;
inset: 0;
background-image: url("/static/square/galaxy/topbj1.png");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 0.95;
transform: scale(1.15);
}
.cover-wrap {
width: 144rpx;
height: 240rpx;
bottom: 136rpx;
left: 32rpx;
.podium-frame {
width: 100%;
height: 100%;
}
}
} }
/* TOP 2: 右上 */ /* TOP 2: 上 */
.podium-2 { .podium-2 {
top: 150rpx; top: 184rpx;
right: 60rpx; left: -96rpx;
width: 96rpx; width: 200rpx;
height: 128rpx; height: 280rpx;
&::before {
content: "";
position: absolute;
inset: 0;
background-image: url("/static/square/galaxy/topbj2.png");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 0.95;
transform: rotate(-14deg) scale(0.85);
}
.cover-wrap {
width: 144rpx;
height: 240rpx;
bottom: 72rpx;
.podium-frame {
width: 100%;
height: 100%;
}
}
} }
/* TOP 3: 左上 */ /* TOP 3: 上 */
.podium-3 { .podium-3 {
top: 152rpx; top: 152rpx;
left: 96rpx; right: -96rpx;
width: 160rpx; width: 220rpx;
height: 208rpx; height: 260rpx;
&::before {
content: "";
position: absolute;
inset: 0;
background-image: url("/static/square/galaxy/topbj3.png");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 0.95;
transform: scale(0.85);
}
.cover-wrap {
width: 176rpx;
height: 180rpx;
bottom: 72rpx;
.podium-frame { .podium-frame {
width: 160rpx; width: 100%;
height: 208rpx; height: 100%;
}
} }
} }
@ -87,8 +139,7 @@ function handleClick() {
} }
.cover-wrap { .cover-wrap {
position: absolute; position: relative;
inset: 0;
z-index: 2; z-index: 2;
} }
@ -96,13 +147,12 @@ function handleClick() {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: absolute; position: absolute;
inset: 0;
} }
.top-label { .top-label {
width: 64rpx; width: 64rpx;
position: absolute; position: absolute;
bottom: -64rpx; bottom: 48rpx;
left: 50%; left: 50%;
transform: translateX(-50%); transform: translateX(-50%);
color: #fffabd; color: #fffabd;

View File

@ -28,15 +28,11 @@
@click="handleClick(items[i])" @click="handleClick(items[i])"
> >
<!-- 相框最底层 --> <!-- 相框最底层 -->
<image <image class="ring-frame" :src="ringFrameSrc(p.rank)" mode="aspectFit" />
class="ring-frame"
:src="ringFrameSrc(p.rank)"
mode="aspectFit"
/>
<view class="top-label">{{ formatLabel(p.rank) }}</view> <view class="top-label">{{ formatLabel(p.rank) }}</view>
<image <image
class="cover-image" class="cover-image"
:src="(items[i]?.cover_url) || (items[i]?.cover_image) || ''" :src="items[i]?.cover_url || items[i]?.cover_image || ''"
mode="aspectFill" mode="aspectFill"
/> />
</view> </view>
@ -47,65 +43,64 @@
// Note: @keyframes orbit is inlined below (not imported from config.js ORBIT_KEYFRAMES) // Note: @keyframes orbit is inlined below (not imported from config.js ORBIT_KEYFRAMES)
// because Vue <style> blocks cannot interpolate JS string constants. // because Vue <style> blocks cannot interpolate JS string constants.
// config.js ORBIT_KEYFRAMES is kept as documentation/source-of-truth. // config.js ORBIT_KEYFRAMES is kept as documentation/source-of-truth.
import { RING_DELAYS } from './config.js' import { RING_DELAYS } from "./config.js";
const props = defineProps({ const props = defineProps({
items: { type: Array, required: true }, // length 9 items: { type: Array, required: true }, // length 9
positions: { type: Array, required: true }, // from generateRingPositions() positions: { type: Array, required: true }, // from generateRingPositions()
}) });
const emit = defineEmits(['cardClick']) const emit = defineEmits(["cardClick"]);
// base slot 0 (187, 565)item 46×72top-left = (164, 530) // base slot 0 (187, 565)item 46×72top-left = (164, 530)
const BASE_X = 164 const BASE_X = 164;
const BASE_Y = 530 const BASE_Y = 530;
// Each item's static fallback position = position at its slot in the orbit keyframe cycle. // Each item's static fallback position = position at its slot in the orbit keyframe cycle.
// When animation runs, the keyframe overrides these. When animation doesn't run (e.g. prefers-reduced-motion), // When animation runs, the keyframe overrides these. When animation doesn't run (e.g. prefers-reduced-motion),
// items stay at their slot positions instead of stacking at (BASE_X, BASE_Y). // items stay at their slot positions instead of stacking at (BASE_X, BASE_Y).
const SLOT_TRANSFORMS = [ const SLOT_TRANSFORMS = [
'translate(0, 0) scale(1.15)', // slot 0: TOP 4 (front center) "translate(0, 0) scale(1.15)", // slot 0: TOP 4 (front center)
'translate(84px, -13px) scale(1.05)', // slot 1: TOP 5 "translate(84px, -13px) scale(1.05)", // slot 1: TOP 5
'translate(157px, -43px) scale(0.95)', // slot 2: TOP 6 "translate(157px, -43px) scale(0.95)", // slot 2: TOP 6
'translate(113px, -83px) scale(0.85)', // slot 3: TOP 7 "translate(113px, -83px) scale(0.85)", // slot 3: TOP 7
'translate(45px, -107px) scale(0.75)', // slot 4: TOP 8 "translate(45px, -107px) scale(0.75)", // slot 4: TOP 8
'translate(-45px, -107px) scale(0.75)', // slot 5: TOP 9 "translate(-45px, -107px) scale(0.75)", // slot 5: TOP 9
'translate(-113px, -83px) scale(0.85)', // slot 6: TOP 10 "translate(-113px, -83px) scale(0.85)", // slot 6: TOP 10
'translate(-156px, -43px) scale(0.95)', // slot 7: TOP 11 "translate(-156px, -43px) scale(0.95)", // slot 7: TOP 11
'translate(-84px, -13px) scale(1.05)', // slot 8: TOP 12 "translate(-84px, -13px) scale(1.05)", // slot 8: TOP 12
] ];
function ringItemStyle(p) { function ringItemStyle(p) {
return { return {
left: BASE_X + 'rpx', left: BASE_X + "rpx",
top: BASE_Y + 'rpx', top: BASE_Y + "rpx",
zIndex: p.zIndex, zIndex: p.zIndex,
transform: SLOT_TRANSFORMS[p.rank - 4], transform: SLOT_TRANSFORMS[p.rank - 4],
animationDelay: RING_DELAYS[p.rank - 4] + 's', animationDelay: RING_DELAYS[p.rank - 4] + "s",
} };
} }
function formatLabel(rank) { function formatLabel(rank) {
return 'TOP ' + rank return "TOP " + rank;
} }
function ringFrameSrc(rank) { function ringFrameSrc(rank) {
// rank 4 LV4 (TOP 4), rank 5 LV5 (TOP 5), ..., rank 12 LV12 (TOP 12) // rank 4 LV4 (TOP 4), rank 5 LV5 (TOP 5), ..., rank 12 LV12 (TOP 12)
// ScatteredRanks rank == display rank (no offset, unlike PodiumCard which uses rank - 3) // ScatteredRanks rank == display rank (no offset, unlike PodiumCard which uses rank - 3)
return `/static/square/galaxy/LV${rank}.png` return `/static/square/galaxy/LV${rank}.png`;
} }
function handleClick(item) { function handleClick(item) {
if (item) emit('cardClick', item) if (item) emit("cardClick", item);
} }
</script> </script>
<style scoped> <style scoped>
.scattered-ranks { .scattered-ranks {
position: absolute; position: absolute;
top: 50%; top: 22%;
left: 0; left: 22%;
transform: translateY(-50%);
width: 750rpx; width: 750rpx;
height: 720rpx; height: 720rpx;
pointer-events: none; pointer-events: none;
@ -123,79 +118,100 @@ function handleClick(item) {
.ring-item { .ring-item {
position: absolute; position: absolute;
width: 46rpx; width: 84rpx;
height: 72rpx; /* 14 label + 2 gap + 56 cover */ height: 104rpx; /* 14 label + 2 gap + 56 cover */
transform-origin: center; transform-origin: center;
pointer-events: auto; pointer-events: auto;
cursor: pointer; cursor: pointer;
display: flex; /* display: flex; */
flex-direction: column; flex-direction: column;
animation: orbit 36s linear infinite; animation: orbit 36s linear infinite;
} }
.ring-frame { .ring-frame {
position: absolute; position: absolute;
inset: -2rpx; /* extend slightly outside the item bounds */ inset: 0; /* extend slightly outside the item bounds */
width: calc(100% + 4rpx); width: 100%;
height: calc(100% + 4rpx); height: 100%;
z-index: 0; z-index: 2;
pointer-events: none; pointer-events: none;
} }
.top-label { .top-label {
width: 46rpx; width: 80rpx;
height: 14rpx; position: relative;
background: radial-gradient(ellipse, #C8E6FF, #fff 50%, #4D9AF8); bottom: 16rpx;
border-radius: 7rpx; left: 50%;
transform: translateX(-50%);
color: #fffabd;
font-size: 20rpx;
font-weight: 800;
border-radius: 16rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 7rpx; text-shadow: -1px 1px 4px #ce0909d6;
font-weight: 900; background: linear-gradient(
color: #FFFABD; 93.1deg,
text-shadow: -1rpx 1rpx 2rpx rgba(206, 9, 9, 0.84); rgba(224, 180, 247, 0.71) -12.06%,
z-index: 2; /* above frame */ rgba(178, 246, 204, 0.71) 52.09%,
} rgba(98, 178, 244, 0.71) 163.5%
);
/* TOP 4 label 是金渐变(最显眼) */ backdrop-filter: blur(11.699999809265137px);
.r0 .top-label { z-index: 7;
background: radial-gradient(ellipse, #FFFFFF, #FFFABD 30%, #4D9AF8 100%);
box-shadow: 0 4rpx 12rpx rgba(255, 250, 189, 0.55);
font-size: 8rpx;
} }
.cover-image { .cover-image {
margin-top: 2rpx; width: 100%;
width: 46rpx; height: 100%;
height: 56rpx;
border-radius: 5rpx; border-radius: 5rpx;
box-shadow: 3rpx 3rpx 6rpx rgba(198, 13, 13, 0.45);
z-index: 1; /* between frame and label */ z-index: 1; /* between frame and label */
} }
.r0 .cover-image {
box-shadow: 0 12rpx 28rpx rgba(255, 32, 36, 0.5), 0 0 24rpx rgba(255, 250, 189, 0.55);
}
</style> </style>
<style> <style>
/* 关键帧:放在非 scoped 块中,让所有 ring-item 共享 */ /* 关键帧:放在非 scoped 块中,让所有 ring-item 共享 */
@keyframes orbit { @keyframes orbit {
0% { transform: translate(0,0) scale(1.15); } 0% {
11.11% { transform: translate(84px,-13px) scale(1.05); } transform: translate(0, 0) scale(1.15);
22.22% { transform: translate(157px,-43px) scale(0.95); } }
33.33% { transform: translate(113px,-83px) scale(0.85); } 11.11% {
44.44% { transform: translate(45px,-107px) scale(0.75); } transform: translate(84px, -13px) scale(1.05);
55.55% { transform: translate(-45px,-107px) scale(0.75); } }
66.66% { transform: translate(-113px,-83px) scale(0.85); } 22.22% {
77.77% { transform: translate(-156px,-43px) scale(0.95); } transform: translate(157px, -43px) scale(0.95);
88.88% { transform: translate(-84px,-13px) scale(1.05); } }
100% { transform: translate(0,0) scale(1.15); } 33.33% {
transform: translate(113px, -83px) scale(0.85);
}
44.44% {
transform: translate(45px, -107px) scale(0.75);
}
55.55% {
transform: translate(-45px, -107px) scale(0.75);
}
66.66% {
transform: translate(-113px, -83px) scale(0.85);
}
77.77% {
transform: translate(-156px, -43px) scale(0.95);
}
88.88% {
transform: translate(-84px, -13px) scale(1.05);
}
100% {
transform: translate(0, 0) scale(1.15);
}
} }
@keyframes crownPulse { @keyframes crownPulse {
0%, 100% { transform: translateX(-50%) scale(1); } 0%,
50% { transform: translateX(-50%) scale(1.15); } 100% {
transform: translateX(-50%) scale(1);
}
50% {
transform: translateX(-50%) scale(1.15);
}
} }
/* 可访问性:减少动画 */ /* 可访问性:减少动画 */

View File

@ -118,7 +118,7 @@ onUnmounted(() => {
position: relative; position: relative;
width: 750rpx; width: 750rpx;
min-height: 1440rpx; min-height: 1440rpx;
padding-bottom: 200rpx; padding-bottom: 392rpx;
top: -128rpx; top: -128rpx;
// [3] bj.png opacity // [3] bj.png opacity
&::before { &::before {

View File

@ -8,41 +8,72 @@
></image> --> ></image> -->
<!-- Header组件 --> <!-- Header组件 -->
<Header :showGuideIcon="true" :showTaskIcon="true" :showStarActivityIcon="true" backIconColor="#e6e6e6" /> <Header
:showGuideIcon="true"
:showTaskIcon="true"
:showStarActivityIcon="true"
backIconColor="#e6e6e6"
/>
<!-- 蒙层 - 导航栏展开时显示 --> <!-- 蒙层 - 导航栏展开时显示 -->
<view v-if="navExpanded" class="nav-mask" @click="navExpanded = false"></view> <view
v-if="navExpanded"
class="nav-mask"
@click="navExpanded = false"
></view>
<!-- 排行榜弹窗 --> <!-- 排行榜弹窗 -->
<RankingModal :visible="showRankingModal" :parent-active="true" :star-id="currentStarId" <RankingModal
@update:visible="handleRankingModalClose" @visit="handleRankingVisit" /> :visible="showRankingModal"
:parent-active="true"
:star-id="currentStarId"
@update:visible="handleRankingModalClose"
@visit="handleRankingVisit"
/>
<!-- 底部导航栏 --> <!-- 底部导航栏 -->
<BottomNav :activeTab="4" :isExpanded="navExpanded" @update:activeTab="handleTabChange" <BottomNav
@update:isExpanded="navExpanded = $event" /> :activeTab="4"
:isExpanded="navExpanded"
@update:activeTab="handleTabChange"
@update:isExpanded="navExpanded = $event"
/>
<!-- 全局引导遮罩 --> <!-- 全局引导遮罩 -->
<GuideOverlay /> <GuideOverlay />
<!-- 内容区域 --> <!-- 内容区域 -->
<scroll-view class="content-wrapper" :class="{ 'spotlight-ready': isH5 }" scroll-y :show-scrollbar="false" <scroll-view
:bounce="false" @scroll="onScroll" @touchstart="onTouchStart" @touchmove="onTouchMove" class="content-wrapper"
@scrolltolower="handleScrollToLower"> :class="{ 'spotlight-ready': isH5 }"
scroll-y
:show-scrollbar="false"
:bounce="false"
@scroll="onScroll"
@touchstart="onTouchStart"
@touchmove="onTouchMove"
@scrolltolower="handleScrollToLower"
>
<!-- 区域一顶部运营轮播图 --> <!-- 区域一顶部运营轮播图 -->
<view ref="bannerSectionRef" class="banner-section"> <view ref="bannerSectionRef" class="banner-section">
<BannerCarousel :bannerActivities="bannerActivities" :banners="banners" <BannerCarousel
@activityClick="handleActivityClick" @top3Click="showRankingModal = true" :bannerActivities="bannerActivities"
@bannerClick="handleBannerClick" /> :banners="banners"
@activityClick="handleActivityClick"
@top3Click="showRankingModal = true"
@bannerClick="handleBannerClick"
/>
</view> </view>
<ContentTabs ref="contentTabsRef" class="tabs" :modelValue="activeContentTab" <ContentTabs
@update:modelValue="activeContentTab = $event" /> ref="contentTabsRef"
class="tabs"
:modelValue="activeContentTab"
@update:modelValue="activeContentTab = $event"
/>
<!-- 星河区块 - 仅在 星河 时显示 --> <!-- 星河区块 - 仅在 星河 时显示 -->
<view <view v-if="activeContentTab === 'xinghe'" class="star-galaxy-wrapper">
v-if="activeContentTab === 'xinghe'"
class="star-galaxy-wrapper"
>
<StarGalaxy @cardClick="handleCardClick" /> <StarGalaxy @cardClick="handleCardClick" />
</view> </view>
@ -111,32 +142,38 @@ const allSpotlightRefs = () => {
// hotCategoryRef.value, // hotCategoryRef.value,
creationGridRef.value?.mainTabsRef?.value, creationGridRef.value?.mainTabsRef?.value,
creationGridRef.value?.categoryRef?.value, creationGridRef.value?.categoryRef?.value,
].filter(Boolean) ].filter(Boolean);
const cards = creationGridRef.value?.getCardRefs?.() || [] const cards = creationGridRef.value?.getCardRefs?.() || [];
return [...sections, ...cards] return [...sections, ...cards];
} };
const { update, bindScroll, start: startSpotlight, stop: stopSpotlight, isH5 } = useSpotlight({ const {
update,
bindScroll,
start: startSpotlight,
stop: stopSpotlight,
isH5,
} = useSpotlight({
getElements: allSpotlightRefs, getElements: allSpotlightRefs,
}) });
// scroll spotlight + CreationGrid fixed // scroll spotlight + CreationGrid fixed
const onScroll = (e) => { const onScroll = (e) => {
bindScroll() // scroll rAF bindScroll(); // scroll rAF
const scrollTop = (e && e.detail && e.detail.scrollTop) || 0 const scrollTop = (e && e.detail && e.detail.scrollTop) || 0;
// CreationGrid scrollTop fixed // CreationGrid scrollTop fixed
creationGridRef.value?.updateScroll?.(scrollTop) creationGridRef.value?.updateScroll?.(scrollTop);
} };
// rAF opacity // rAF opacity
// touchstart / touchmove update() rAF // touchstart / touchmove update() rAF
const onTouchStart = () => { const onTouchStart = () => {
update() update();
} };
const onTouchMove = () => { const onTouchMove = () => {
update() update();
} };
// tab spotlight // tab spotlight
watch(activeContentTab, () => { watch(activeContentTab, () => {
@ -151,7 +188,8 @@ const onGridLoaded = (count) => {
}; };
// ========== Composables ========== // ========== Composables ==========
const { bannerActivities, banners, loadBannerActivities, loadBanners } = useBanner(); const { bannerActivities, banners, loadBannerActivities, loadBanners } =
useBanner();
// ========== Handlers ========== // ========== Handlers ==========
@ -199,17 +237,17 @@ const handleActivityClick = (item) => {
// banner () // banner ()
const handleBannerClick = (banner) => { const handleBannerClick = (banner) => {
console.log('[square] banner click', banner); console.log("[square] banner click", banner);
// 使 route // 使 route
if (banner.route) { if (banner.route) {
return uni.navigateTo({ url: banner.route }); return uni.navigateTo({ url: banner.route });
} }
if (banner.link_type === 'activity') { if (banner.link_type === "activity") {
return uni.navigateTo({ return uni.navigateTo({
url: `/pages/castlove/detail?id=${banner.link_value}`, url: `/pages/castlove/detail?id=${banner.link_value}`,
}); });
} }
if (banner.link_type === 'topic') { if (banner.link_type === "topic") {
return uni.navigateTo({ return uni.navigateTo({
url: `/pages/topic/detail?id=${banner.link_value}`, url: `/pages/topic/detail?id=${banner.link_value}`,
}); });
@ -254,10 +292,10 @@ const handleTabChange = (newTab) => {
// Tab / CreationGrid // Tab / CreationGrid
// ========== Tile Change Callback ========== // ========== Tile Change Callback ==========
const handleTileChange = () => { }; const handleTileChange = () => {};
// ========== Reset Square ========== // ========== Reset Square ==========
const resetSquare = async () => { }; const resetSquare = async () => {};
// ========== Lifecycle ========== // ========== Lifecycle ==========
onMounted(() => { onMounted(() => {
@ -320,7 +358,13 @@ onUnmounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.square-container { .square-container {
position: relative; position: relative;
background: linear-gradient(179.98deg, rgba(255, 229, 229, 0.25) -32.49%, rgba(243, 160, 161, 0.25) -32.49%, rgba(255, 156, 156, 0.25) 86.46%, rgba(255, 32, 36, 0.25) 180.79%); background: linear-gradient(
179.98deg,
rgba(255, 229, 229, 0.25) -32.49%,
rgba(243, 160, 161, 0.25) -32.49%,
rgba(255, 156, 156, 0.25) 86.46%,
rgba(255, 32, 36, 0.25) 180.79%
);
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
width: 100vw; width: 100vw;
@ -356,7 +400,7 @@ onUnmounted(() => {
width: 100%; width: 100%;
height: 100vh; height: 100vh;
/* margin-top: 160rpx; */ /* margin-top: 160rpx; */
padding: 208rpx 16rpx 0; padding: 208rpx 0 0;
box-sizing: border-box; box-sizing: border-box;
} }
@ -466,7 +510,6 @@ onUnmounted(() => {
/* 动效减弱的可访问性兜底 */ /* 动效减弱的可访问性兜底 */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.spotlight, .spotlight,
.spotlight-ready .banner-section, .spotlight-ready .banner-section,
.spotlight-ready .tabs, .spotlight-ready .tabs,

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 920 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB