Compare commits

...

23 Commits

Author SHA1 Message Date
d4e26337de feat: 修改星河top 2026-06-10 19:33:17 +08:00
61d39e2c1e feat: 新增星河的 2026-06-10 19:25:15 +08:00
90803234ba style(stargalaxy): remove shared podium size — each .podium-N controls its own width/height 2026-06-10 19:16:03 +08:00
021782d42f style(stargalaxy): add topbj3.png background-image to .podium-card (shared, not rank-specific) 2026-06-10 18:01:39 +08:00
b130a51474 refactor(stargalaxy): simplify rank — PodiumCard uses 1/2/3 directly (no +4 offset) 2026-06-10 17:28:31 +08:00
563969c4e0 perf(stargalaxy): replace label JS constants with pure CSS classes (.podium-N .top-label) 2026-06-10 17:26:26 +08:00
7b7902bba6 perf(stargalaxy): inline displayRank/frameSrc into template, drop computed 2026-06-10 17:24:08 +08:00
617bb201dc perf(stargalaxy): replace JS computed sizing with pure CSS classes (.podium-4/5/6) 2026-06-10 17:22:13 +08:00
7f831c4fbe refactor(stargalaxy): move PODIUM_SIZES/POSITIONS into PodiumCard for self-contained layout 2026-06-10 17:18:35 +08:00
6231295e36 refactor(stargalaxy): remove unused frameGradients/frameStyle from PodiumCard (user to redesign) 2026-06-10 17:15:37 +08:00
32bc65aa1b refactor(stargalaxy): remove unused diamond-frame/cover-highlight/diamond-border/crown from PodiumCard 2026-06-10 17:09:50 +08:00
308cf33a14 style(stargalaxy): center ScatteredRanks vertically in parent container 2026-06-10 17:01:46 +08:00
ca87faf98d fix(stargalaxy): reverse top/bottom mask to fade header at top instead of bottom 2026-06-10 16:55:28 +08:00
1542147291 fix(stargalaxy): correct LV frame mapping in ScatteredRanks (rank 4→LV4, not LV1) 2026-06-10 16:51:01 +08:00
f66be49384 feat(stargalaxy): add LV1-12 frame images to podium and scattered items 2026-06-10 16:48:34 +08:00
dd5f95d734 fix(stargalaxy): set slot-specific static transform to prevent 9-item overlap fallback 2026-06-10 16:01:29 +08:00
f7b76e3813 docs(stargalaxy): add component README with cross-platform compatibility notes 2026-06-10 15:44:15 +08:00
ef82d7a57d feat(square): wire StarGalaxy component into 星河 tab 2026-06-10 15:38:16 +08:00
4ad16dd91f feat(stargalaxy): add container with data loading, decoration, podium + scattered orchestration 2026-06-10 15:33:56 +08:00
bfb15c57e5 refactor(stargalaxy): remove dead ORBIT_KEYFRAMES import with explanatory comment 2026-06-10 15:31:24 +08:00
04609adb5e feat(stargalaxy): add ScatteredRanks with 9 ring items + 36s orbit animation 2026-06-10 15:26:12 +08:00
2bd5733ca0 feat(stargalaxy): add PodiumCard for TOP 1-3 with gold/silver/bronze labels 2026-06-10 15:20:18 +08:00
a870943dc1 feat(stargalaxy): add ring position config and orbit keyframes 2026-06-10 15:14:23 +08:00
37 changed files with 2340 additions and 54 deletions

3
.gitignore vendored
View File

@ -46,3 +46,6 @@ hookify.*.local.md
# statisticService runtime logs (created by logger when running tests)
backend/services/statisticService/**/logs/
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"
class="grid-card"
:class="{
'grid-card-top': index < 3,
[`grid-card-top-${index + 1}`]: index < 3,
[`grid-card-top-${index + 1}`]: index < 5,
'grid-card-top-other': index >= 5,
}"
@click="handleCardClick(item)"
>
@ -147,9 +147,33 @@ const tabs = [
{
key: "hot",
label: "热度榜",
icon: "/static/square/rementubiao.png",
iconWidth: 32,
iconHeight: 40,
icon: "/static/square/galaxy/dianzanbang.png",
iconWidth: 64,
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),
},
];
@ -279,7 +303,7 @@ onUnmounted(() => {
});
</script>
<style scoped>
<style scoped lang="scss">
.hot-category-block {
padding: 0 9.5rpx;
border-radius: 24rpx;
@ -289,11 +313,12 @@ onUnmounted(() => {
/* Tab 栏 */
.ranking-tabs {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 16rpx;
position: relative;
left: 50%;
top: 16rpx;
transform: translate(-50%);
opacity: 0.8;
border-top-left-radius: 14px;
@ -302,20 +327,22 @@ onUnmounted(() => {
border-bottom-left-radius: 7px;
z-index: 1; /* 在内容网格之下 */
width: 480rpx;
height: 80rpx;
height: 96rpx;
background: linear-gradient(183.58deg, #ff5a5d -36.55%, #c2ebff 121.2%);
backdrop-filter: blur(11.699999809265137px);
}
.ranking-tab-item {
height: 80rpx;
width: 88rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
flex-direction: column;
transition: all 0.25s ease;
position: absolute;
top: 24rpx;
/* position: relative .ranking-tab-icon position: absolute
注意不能写 position: absolute否则 4 tab 会从 flex 流中抽离并全部堆在 left:0 重叠 */
position: relative;
}
.ranking-tab-item.active {
@ -326,7 +353,11 @@ onUnmounted(() => {
);
backdrop-filter: blur(1.7999999523162842px);
height: 144rpx;
width: 88rpx;
top: 40rpx;
}
.ranking-tab-item.active .ranking-tab-icon {
top: -24rpx;
}
.ranking-tab-label {
@ -341,7 +372,6 @@ onUnmounted(() => {
.ranking-tab-icon {
display: block;
position: absolute;
top: -8rpx;
}
.ranking-tab-item.active .ranking-tab-label {
@ -451,7 +481,6 @@ onUnmounted(() => {
position: relative;
width: 90rpx;
height: 120rpx;
}
.card-image {
width: 100%;
@ -475,17 +504,57 @@ onUnmounted(() => {
transform: rotate(45deg);
}
/* 前 3 名专属:卡片整体突出 */
.grid-card-top {
}
.grid-card-top-1 {
background: url("/static/square/galaxy/TOP.png") no-repeat center;
}
.grid-card-top-2 {
background: url("/static/square/galaxy/TOP2.png") no-repeat center;
}
.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 之上) */

View File

@ -0,0 +1,171 @@
<template>
<view class="podium-card" :class="['podium-' + rank]" @click="handleClick">
<!-- 藏品主图不规则圆角 -->
<view class="cover-wrap">
<!-- 相框最底层 -->
<image
class="podium-frame"
:src="`/static/square/galaxy/LV${rank}.png`"
mode="aspectFit"
/>
<image
class="cover-image"
:src="item.cover_url || item.cover_image || ''"
mode="aspectFill"
/>
</view>
<!-- TOP N 标签cover 下方居中 -->
<view class="top-label">TOP {{ rank }}</view>
</view>
</template>
<script setup>
const props = defineProps({
item: { type: Object, required: true },
rank: { type: Number, required: true, validator: (v) => v >= 1 && v <= 3 },
});
const emit = defineEmits(["cardClick"]);
function handleClick() {
emit("cardClick", props.item);
}
</script>
<style scoped lang="scss">
.podium-card {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 60vw;
height: 320rpx;
}
/* TOP 1: 中央大卡(最大) */
.podium-1 {
top: 400rpx;
left: 50%;
transform: translateX(-50%);
&::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: 左上 */
.podium-2 {
top: 184rpx;
left: -96rpx;
&::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: 右上 */
.podium-3 {
top: 152rpx;
right: -96rpx;
&::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 {
width: 100%;
height: 100%;
}
}
}
.podium-frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none;
transform: scale(1.5);
}
.cover-wrap {
position: relative;
z-index: 2;
}
.cover-image {
width: 100%;
height: 100%;
position: absolute;
}
.top-label {
width: 64rpx;
position: absolute;
bottom: 48rpx;
left: 50%;
transform: translateX(-50%);
color: #fffabd;
font-size: 20rpx;
font-weight: 800;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
text-shadow: -1px 1px 4px #ce0909d6;
background: linear-gradient(
93.1deg,
rgba(224, 180, 247, 0.71) -12.06%,
rgba(178, 246, 204, 0.71) 52.09%,
rgba(98, 178, 244, 0.71) 163.5%
);
backdrop-filter: blur(11.699999809265137px);
z-index: 7;
padding: 0 16rpx;
}
</style>

View File

@ -0,0 +1,79 @@
# StarGalaxy 组件
square 页「星河」tab 的排行榜组件,渲染 TOP 1-12 的 3D 倾斜椭圆轨道 + 顺时针旋转动画。
## 视觉特征
- 65° 倾斜椭圆轨道rx=130, ry=55
- TOP 1-3 颁奖台cover + 下方标签 + 钻石外框 + 金/银/铜渐变)
- TOP 4-12 9 个散落 itemcover + 上方标签 + 蓝白钻石渐变)
- 9 item 顺时针 36s 旋转一圈(单组 @keyframes + 9 个不同 animation-delay 错开)
- 近大远小scale 0.75→1.15TOP 4 最大TOP 8/9 最小)
- 装饰层:粉红渐变 overlay + 樱花粉/暖黄双光晕
- 标题「★ 星河 ★」+ 底部提示「每日 0:00 更新榜单」
## 数据来源
复用现有 `getHotRankingApi("displaying", null, 1, 12)` — 与「星榜」共用 API取前 12 条。
## 文件
| 文件 | 职责 |
|----|----|
| `index.vue` | 容器数据加载、装饰层、4 状态分支、子组件编排 |
| `PodiumCard.vue` | TOP 1-3 大卡cover + 下方 TOP N 标签 + 可选皇冠) |
| `ScatteredRanks.vue` | TOP 4-12 9 散落 item共享 36s 旋转动画) |
| `config.js` | 9 slot 位置/translate/scale 公式 + RING_DELAYS |
## 可调参数
| 参数 | 位置 | 默认值 | 说明 |
|----|----|----|----|
| 椭圆倾斜度 | `config.js: RING.ry` | 55 (cos⁻¹ 65°) | 改 ry 调整倾斜度(越小越斜) |
| 旋转周期 | `ScatteredRanks.vue: animation` | 36s | 改秒数调整速度 |
| 近大远小范围 | `config.js: yFactor + 0.40 * yFactor` | 0.75→1.15 | 改 0.75/1.15 调整 |
| TOP 6/11 位置 | `config.js: OVERRIDES` | x:321 / x:8 | 边缘推拉 |
| Podium 位置/尺寸 | `index.vue: PODIUM_SIZES/POSITIONS` | 240×260 等 | 调整 1-3 大卡 |
## 跨端兼容性
| CSS 特性 | H5 | APP | 微信小程序 | 备注 |
|----|----|----|----|----|
| `transform: scale/translate` | ✅ | ✅ | ✅ | 全支持 |
| `filter: blur(Nrpx)` | ✅ | ✅ | ⚠️ 部分 | 光晕效果用MP 可能降级 |
| `radial-gradient` | ✅ | ✅ | ⚠️ 有限 | 渐变背景,部分 MP 降级为线性 |
| `@keyframes` + `animation` | ✅ | ✅ | ✅ | 全支持 |
| `mask-composite: exclude` | ✅ | ⚠️ | ❌ | 仅 WebKitMP 需 border-image 替代 |
| `backdrop-filter` | ✅ | ⚠️ | ❌ | 仅 H5装饰层用 `opacity` 兜底 |
**降级策略**
- 微信小程序:如 `mask-composite` 不渲染钻石边框会变成无边框不致命cover 仍可见)
- 微信小程序:如 `radial-gradient` 降级为 linear标签渐变变为纯色视觉略降级
## 可访问性
- `prefers-reduced-motion: reduce` 媒体查询关闭 `.ring-item``.crown` 的动画
- 标签文字使用高对比度的 `#FFFABD` + 红色 `text-shadow`
- TOP 1 卡片有皇冠装饰作为「第一名」视觉提示
## 状态管理
| 状态 | 触发条件 | 表现 |
|----|----|----|
| Loading | 初次加载 | 12 个骨架占位3 大 + 9 小)+ shimmer 动画 |
| Error | API 失败 | 「加载失败,点击重试」+ 重试按钮 |
| Empty | API 返回空数组 | 「暂无星河数据」 |
| Success | 12 条数据加载完成 | 完整布局 |
## 已知限制
1. `prefers-reduced-motion` 触发时 9 个 item 会堆叠在 slot 0 位置spec §6.4 已说明)
2. `ORBIT_KEYFRAMES` 模板字符串无法在 Vue `<style>` 块中通过 JS 变量注入(已在 ScatteredRanks.vue 内联关键帧)
3. 跨端兼容性问题主要在微信小程序端,已在风险表中说明
## 后续改进 (v2)
- 缩略图懒加载优化(`<image lazy-load>`
- 9 item 悬停暂停动画
- 微信小程序钻石边框降级方案(用 `border-image` 替代 `mask-composite`
- 骨架屏精细化(与 square 风格统一)

View File

@ -0,0 +1,226 @@
<template>
<view class="scattered-ranks">
<!-- 椭圆轨道装饰虚线 -->
<!-- <svg class="orbit-svg" viewBox="0 0 375 170" preserveAspectRatio="none">
<defs>
<linearGradient id="sg-orbit-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="rgba(255,255,255,0.15)" />
<stop offset="50%" stop-color="rgba(255,255,255,0.4)" />
<stop offset="100%" stop-color="rgba(255,250,189,0.85)" />
</linearGradient>
<radialGradient id="sg-center-glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(255,250,189,0.5)" />
<stop offset="100%" stop-color="transparent" />
</radialGradient>
</defs>
<ellipse cx="187" cy="55" rx="80" ry="35" fill="url(#sg-center-glow)" />
<ellipse cx="187" cy="55" rx="130" ry="55" fill="none" stroke="url(#sg-orbit-grad)" stroke-width="1.5" stroke-dasharray="3,3" />
<path d="M 57,55 A 130,55 0 0,0 317,55" stroke="rgba(255,250,189,0.85)" stroke-width="2.5" fill="none" />
</svg> -->
<!-- 9 个散落 item -->
<view
v-for="(p, i) in positions"
:key="p.rank"
class="ring-item"
:class="'r' + (p.rank - 4)"
:style="ringItemStyle(p)"
@click="handleClick(items[i])"
>
<!-- 相框最底层 -->
<view class="top-label">{{ formatLabel(p.rank) }}</view>
<image class="ring-frame" :src="ringFrameSrc(p.rank)" mode="aspectFit" />
<image
class="cover-image"
:src="items[i]?.cover_url || items[i]?.cover_image || ''"
mode="aspectFill"
/>
</view>
</view>
</template>
<script setup>
// Note: @keyframes orbit is inlined below (not imported from config.js ORBIT_KEYFRAMES)
// because Vue <style> blocks cannot interpolate JS string constants.
// config.js ORBIT_KEYFRAMES is kept as documentation/source-of-truth.
import { RING_DELAYS } from "./config.js";
const props = defineProps({
items: { type: Array, required: true }, // length 9
positions: { type: Array, required: true }, // from generateRingPositions()
});
const emit = defineEmits(["cardClick"]);
// base slot 0 (187, 565)item 46×72top-left = (164, 530)
const BASE_X = 164;
const BASE_Y = 530;
// 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),
// items stay at their slot positions instead of stacking at (BASE_X, BASE_Y).
const SLOT_TRANSFORMS = [
"translate(0, 0) scale(1.15)", // slot 0: TOP 4 (front center)
"translate(84px, -13px) scale(1.05)", // slot 1: TOP 5
"translate(157px, -43px) scale(0.95)", // slot 2: TOP 6
"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 5: TOP 9
"translate(-113px, -83px) scale(0.85)", // slot 6: TOP 10
"translate(-156px, -43px) scale(0.95)", // slot 7: TOP 11
"translate(-84px, -13px) scale(1.05)", // slot 8: TOP 12
];
function ringItemStyle(p) {
return {
left: BASE_X + "rpx",
top: BASE_Y + "rpx",
zIndex: p.zIndex,
transform: SLOT_TRANSFORMS[p.rank - 4],
animationDelay: RING_DELAYS[p.rank - 4] + "s",
};
}
function formatLabel(rank) {
return "TOP " + rank;
}
function ringFrameSrc(rank) {
// 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)
return `/static/square/galaxy/LV${rank}.png`;
}
function handleClick(item) {
if (item) emit("cardClick", item);
}
</script>
<style scoped>
.scattered-ranks {
position: absolute;
top: 22%;
left: 22%;
width: 750rpx;
height: 720rpx;
pointer-events: none;
}
.orbit-svg {
position: absolute;
top: 390rpx;
left: 0;
width: 750rpx;
height: 340rpx;
pointer-events: none;
z-index: 0;
}
.ring-item {
position: absolute;
width: 84rpx;
height: 104rpx; /* 14 label + 2 gap + 56 cover */
transform-origin: center;
pointer-events: auto;
cursor: pointer;
/* display: flex; */
/* flex-direction: column; */
animation: orbit 36s linear infinite;
}
.ring-frame {
position: absolute;
inset: 0; /* extend slightly outside the item bounds */
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none;
}
.top-label {
width: 80rpx;
position: relative;
bottom: 32rpx;
left: 50%;
transform: translateX(-50%);
color: #fffabd;
font-size: 20rpx;
font-weight: 800;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
text-shadow: -1px 1px 4px #ce0909d6;
background: linear-gradient(
93.1deg,
rgba(224, 180, 247, 0.71) -12.06%,
rgba(178, 246, 204, 0.71) 52.09%,
rgba(98, 178, 244, 0.71) 163.5%
);
backdrop-filter: blur(11.699999809265137px);
z-index: 7;
}
.cover-image {
width: 100%;
height: 100%;
border-radius: 5rpx;
z-index: 1; /* between frame and label */
position: absolute;
top: 0;
}
</style>
<style>
/* 关键帧:放在非 scoped 块中,让所有 ring-item 共享 */
@keyframes orbit {
0% {
transform: translate(0, 0) scale(1.15);
}
11.11% {
transform: translate(84px, -13px) scale(1.05);
}
22.22% {
transform: translate(157px, -43px) scale(0.95);
}
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 {
0%,
100% {
transform: translateX(-50%) scale(1);
}
50% {
transform: translateX(-50%) scale(1.15);
}
}
/* 可访问性:减少动画 */
@media (prefers-reduced-motion: reduce) {
.ring-item,
.crown {
animation: none !important;
}
}
</style>

View File

@ -0,0 +1,80 @@
// StarGalaxy 组件配置常量
// 9 个散落 item 沿 65° 倾斜椭圆轨道排列
// slot 0 = 最前底部TOP 4 起始位置slot 4-5 = 最后(顶部)
export const RING = {
cx: 187, // 椭圆圆心 x
cy: 510, // 椭圆圆心 y
rx: 130, // 水平半径
ry: 55, // 垂直半径cos(65°) ≈ 0.423,模拟向后倾 65°
startAngle: 180, // 起始角slot 0 在正下方
step: 40, // 间隔角(顺时针 = 负方向 → step = -40 在 CSS 中)
}
// item 固定尺寸label 在 cover 上方)
export const ITEM = {
width: 46, // cover + label 宽度
labelHeight: 14, // 顶部 label 高度
coverHeight: 56, // 底部 cover 高度
gap: 2, // label 与 cover 之间的间距
}
// total: 14 + 2 + 56 = 72
// TOP 6 / TOP 11 推到边缘,避免与 TOP 5/7、TOP 10/12 重叠
// TOP 6 推到屏幕右侧 (321, 488)TOP 11 推到屏幕左侧 (8, 488)
export const OVERRIDES = {
6: { x: 321, y: 488 },
11: { x: 8, y: 488 },
}
// 计算 y 在椭圆前/后位置的比例0 = 最后, 1 = 最前)
function yFactor(y) {
// y=458 (back) → 0; y=565 (front) → 1
return Math.max(0, Math.min(1, (y - 458) / 107))
}
// 生成 9 个 item 的位置配置
export function generateRingPositions() {
return Array.from({ length: 9 }, (_, i) => {
const rank = i + 4
const alpha = (RING.startAngle + i * RING.step) * Math.PI / 180
const baseX = RING.cx + RING.rx * Math.sin(alpha) - ITEM.width / 2
const baseY = RING.cy - RING.ry * Math.cos(alpha) - (ITEM.labelHeight + ITEM.gap + ITEM.coverHeight) / 2
const ovr = OVERRIDES[rank]
const y = ovr?.y ?? baseY
const x = ovr?.x ?? baseX
const f = yFactor(y)
return {
rank,
x,
y,
scale: 0.75 + 0.40 * f, // 0.75 → 1.15
zIndex: Math.round(f * 10), // 0 → 10
}
})
}
// 单组 @keyframesCSS 模板字符串)
// translate 值是相对 slot 0 中心 (164, 530) 的偏移
export const ORBIT_KEYFRAMES = `
@keyframes orbit {
0% { transform: translate(0,0) scale(1.15); }
11.11% { transform: translate(84px,-13px) scale(1.05); }
22.22% { transform: translate(157px,-43px) scale(0.95); }
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 {
0%, 100% { transform: translateX(-50%) scale(1); }
50% { transform: translateX(-50%) scale(1.15); }
}
`
// 各 slot 对应 ring-item 类的 r0..r8 的 delay负值让 item 起始位置 = slot
export const RING_DELAYS = [0, -4, -8, -12, -16, -20, -24, -28, -32]

View File

@ -0,0 +1,309 @@
<template>
<view class="stargalaxy-container">
<!-- 装饰层粉红渐变 overlay + 樱花粉光晕 + 暖黄光晕 -->
<!-- <view class="decoration-layer">
<view class="halo-pink"></view>
<view class="halo-yellow"></view>
</view> -->
<!-- 标题 -->
<!-- <view class="title"> 星河 </view> -->
<!-- Loading 骨架 -->
<view v-if="loading" class="skeleton-grid">
<view v-for="i in 3" :key="'p' + i" class="skeleton-podium"></view>
<view v-for="i in 9" :key="'s' + i" class="skeleton-ring"></view>
</view>
<!-- Error -->
<view v-else-if="error" class="error-state">
<text class="error-text">加载失败点击重试</text>
<view class="retry-btn" @click="loadData">重试</view>
</view>
<!-- Empty数据为空 -->
<view v-else-if="items.length === 0" class="empty-state">
<text class="empty-text">暂无星河数据</text>
</view>
<!-- Success -->
<template v-else>
<!-- 颁奖台TOP 1-3 -->
<!-- <view class="podium-row"> -->
<PodiumCard
v-for="(item, i) in podiumItems"
:key="item.id || i"
:item="item"
:rank="i + 1"
@cardClick="handleCardClick"
/>
<!-- </view> -->
<!-- 散落 9 itemTOP 4-12 -->
<ScatteredRanks
:items="scatteredItems"
:positions="ringPositions"
@cardClick="handleCardClick"
/>
<!-- 底部提示 -->
<!-- <view class="footer-hint">每日 0:00 更新榜单</view> -->
</template>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import PodiumCard from "./PodiumCard.vue";
import ScatteredRanks from "./ScatteredRanks.vue";
import { generateRingPositions } from "./config.js";
import { getHotRankingApi } from "@/utils/api.js";
import { getAssetCoverRealUrl } from "@/utils/assetImageHelper.js";
const emit = defineEmits(["cardClick"]);
const items = ref([]);
const loading = ref(true);
const error = ref(false);
const ringPositions = generateRingPositions();
const podiumItems = computed(() => items.value.slice(0, 3));
const scatteredItems = computed(() => items.value.slice(3, 12));
async function resolveUrl(item) {
const cover = item.cover_url || item.cover_image || "";
if (cover) {
item.cover_url = await getAssetCoverRealUrl(cover);
}
return item;
}
async function loadData() {
loading.value = true;
error.value = false;
try {
const res = await getHotRankingApi("displaying", null, 1, 12);
if (res && res.code === 200 && res.data?.items) {
items.value = await Promise.all(
res.data.items.map(async (item) => {
return await resolveUrl({ ...item, id: item.id || item.asset_id });
}),
);
} else {
items.value = [];
}
} catch (e) {
console.error("[StarGalaxy] 加载失败", e?.message ?? e);
error.value = true;
} finally {
loading.value = false;
}
}
function handleCardClick(item) {
emit("cardClick", item);
}
onMounted(() => {
loadData();
});
onUnmounted(() => {
// timer
});
</script>
<style scoped lang="scss">
.stargalaxy-container {
position: relative;
width: 750rpx;
min-height: 1440rpx;
padding-bottom: 392rpx;
top: -128rpx;
// [3] bj.png opacity
&::before {
content: "";
position: absolute;
inset: 0;
background: url("/static/square/galaxy/bj.png") center no-repeat;
background-size: 115% 100%;
pointer-events: none;
z-index: 0;
bottom: 512rpx;
// StarGalaxy 20%
// header/ bj.png
// 700rpx 0-20%(0-140rpx) 20-40%(140-280rpx)
mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent 0%,
#000 14%,
#000 100%
);
-webkit-mask-image: linear-gradient(
to bottom,
transparent 0%,
transparent 0%,
#000 14%,
#000 100%
);
}
}
.decoration-layer {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
background: linear-gradient(
179deg,
#ffe5e5 0%,
#f3a0a1 0%,
#ff9c9c 86%,
#ff2024 100%
);
opacity: 0.85;
}
.halo-pink {
position: absolute;
top: 580rpx;
left: 50%;
transform: translateX(-50%);
width: 680rpx;
height: 340rpx;
background: #f3d3e3;
border-radius: 50%;
filter: blur(60rpx);
opacity: 0.7;
}
.halo-yellow {
position: absolute;
top: 100rpx;
left: 50%;
transform: translateX(-50%);
width: 400rpx;
height: 400rpx;
background: #fffabd;
border-radius: 50%;
filter: blur(50rpx);
opacity: 0.3;
}
.title {
position: absolute;
top: 28rpx;
left: 50%;
transform: translateX(-50%);
font-size: 40rpx;
font-weight: 900;
color: #fffabd;
text-shadow: -1rpx 1rpx 4rpx rgba(206, 9, 9, 0.84);
letter-spacing: 8rpx;
z-index: 5;
}
.podium-row {
position: relative;
z-index: 2;
}
.skeleton-grid {
position: relative;
z-index: 2;
padding: 200rpx 60rpx;
display: flex;
flex-wrap: wrap;
gap: 40rpx;
justify-content: center;
}
.skeleton-podium {
width: 200rpx;
height: 200rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 12rpx;
animation: shimmer 1.5s linear infinite;
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.25) 50%,
rgba(255, 255, 255, 0.05) 100%
);
background-size: 200% 100%;
}
.skeleton-ring {
width: 60rpx;
height: 80rpx;
background: rgba(255, 255, 255, 0.15);
border-radius: 8rpx;
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0.05) 0%,
rgba(255, 255, 255, 0.25) 50%,
rgba(255, 255, 255, 0.05) 100%
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.error-state {
position: relative;
z-index: 2;
padding: 600rpx 0 0;
text-align: center;
}
.error-text {
display: block;
font-size: 28rpx;
color: #fff;
margin-bottom: 30rpx;
}
.empty-state {
position: relative;
z-index: 2;
padding: 600rpx 0 0;
text-align: center;
}
.empty-text {
display: block;
font-size: 28rpx;
color: rgba(255, 255, 255, 0.85);
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.3);
}
.retry-btn {
display: inline-block;
padding: 16rpx 60rpx;
background: linear-gradient(135deg, #ffd700, #ff6b6b);
color: #fff;
border-radius: 30rpx;
font-size: 28rpx;
font-weight: 600;
}
.footer-hint {
position: absolute;
bottom: 16rpx;
left: 50%;
transform: translateX(-50%);
font-size: 20rpx;
color: rgba(255, 255, 255, 0.7);
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.3);
z-index: 3;
}
</style>

View File

@ -8,35 +8,74 @@
></image> -->
<!-- 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"
@update:visible="handleRankingModalClose" @visit="handleRankingVisit" />
<RankingModal
:visible="showRankingModal"
:parent-active="true"
:star-id="currentStarId"
@update:visible="handleRankingModalClose"
@visit="handleRankingVisit"
/>
<!-- 底部导航栏 -->
<BottomNav :activeTab="4" :isExpanded="navExpanded" @update:activeTab="handleTabChange"
@update:isExpanded="navExpanded = $event" />
<BottomNav
:activeTab="4"
:isExpanded="navExpanded"
@update:activeTab="handleTabChange"
@update:isExpanded="navExpanded = $event"
/>
<!-- 全局引导遮罩 -->
<GuideOverlay />
<!-- 内容区域 -->
<scroll-view class="content-wrapper" :class="{ 'spotlight-ready': isH5 }" scroll-y :show-scrollbar="false"
:bounce="false" @scroll="onScroll" @touchstart="onTouchStart" @touchmove="onTouchMove"
@scrolltolower="handleScrollToLower">
<scroll-view
class="content-wrapper"
: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">
<BannerCarousel :bannerActivities="bannerActivities" :banners="banners"
@activityClick="handleActivityClick" @top3Click="showRankingModal = true"
@bannerClick="handleBannerClick" />
<BannerCarousel
:bannerActivities="bannerActivities"
:banners="banners"
@activityClick="handleActivityClick"
@top3Click="showRankingModal = true"
@bannerClick="handleBannerClick"
/>
</view>
<ContentTabs ref="contentTabsRef" class="tabs" :modelValue="activeContentTab"
@update:modelValue="activeContentTab = $event" />
<ContentTabs
ref="contentTabsRef"
class="tabs"
:modelValue="activeContentTab"
@update:modelValue="activeContentTab = $event"
/>
<!-- 星河区块 - 仅在 星河 时显示 -->
<view v-if="activeContentTab === 'xinghe'" class="star-galaxy-wrapper">
<StarGalaxy @cardClick="handleCardClick" />
</view>
<!-- 在线榜单区块 - 仅在 星榜 时显示 -->
<view
@ -70,6 +109,7 @@ import BannerCarousel from "./components/BannerCarousel.vue";
import ContentTabs from "./components/ContentTabs.vue";
import HotCategoryBlock from "./components/HotCategoryBlock.vue";
import CreationGrid from "./components/CreationGrid.vue";
import StarGalaxy from "./components/StarGalaxy/index.vue";
// import { clearSubStepProgress, shouldShowGuideStartModal } from '@/utils/guideConfig.js'
import { useBanner } from "./composables/useBanner.js";
import { useSpotlight } from "./composables/useSpotlight.js";
@ -102,32 +142,38 @@ const allSpotlightRefs = () => {
// hotCategoryRef.value,
creationGridRef.value?.mainTabsRef?.value,
creationGridRef.value?.categoryRef?.value,
].filter(Boolean)
].filter(Boolean);
const cards = creationGridRef.value?.getCardRefs?.() || []
return [...sections, ...cards]
}
const cards = creationGridRef.value?.getCardRefs?.() || [];
return [...sections, ...cards];
};
const { update, bindScroll, start: startSpotlight, stop: stopSpotlight, isH5 } = useSpotlight({
const {
update,
bindScroll,
start: startSpotlight,
stop: stopSpotlight,
isH5,
} = useSpotlight({
getElements: allSpotlightRefs,
})
});
// scroll spotlight + CreationGrid fixed
const onScroll = (e) => {
bindScroll() // scroll rAF
const scrollTop = (e && e.detail && e.detail.scrollTop) || 0
bindScroll(); // scroll rAF
const scrollTop = (e && e.detail && e.detail.scrollTop) || 0;
// CreationGrid scrollTop fixed
creationGridRef.value?.updateScroll?.(scrollTop)
}
creationGridRef.value?.updateScroll?.(scrollTop);
};
// rAF opacity
// touchstart / touchmove update() rAF
const onTouchStart = () => {
update()
}
update();
};
const onTouchMove = () => {
update()
}
update();
};
// tab spotlight
watch(activeContentTab, () => {
@ -142,7 +188,8 @@ const onGridLoaded = (count) => {
};
// ========== Composables ==========
const { bannerActivities, banners, loadBannerActivities, loadBanners } = useBanner();
const { bannerActivities, banners, loadBannerActivities, loadBanners } =
useBanner();
// ========== Handlers ==========
@ -190,17 +237,17 @@ const handleActivityClick = (item) => {
// banner ()
const handleBannerClick = (banner) => {
console.log('[square] banner click', banner);
console.log("[square] banner click", banner);
// 使 route
if (banner.route) {
return uni.navigateTo({ url: banner.route });
}
if (banner.link_type === 'activity') {
if (banner.link_type === "activity") {
return uni.navigateTo({
url: `/pages/castlove/detail?id=${banner.link_value}`,
});
}
if (banner.link_type === 'topic') {
if (banner.link_type === "topic") {
return uni.navigateTo({
url: `/pages/topic/detail?id=${banner.link_value}`,
});
@ -245,10 +292,10 @@ const handleTabChange = (newTab) => {
// Tab / CreationGrid
// ========== Tile Change Callback ==========
const handleTileChange = () => { };
const handleTileChange = () => {};
// ========== Reset Square ==========
const resetSquare = async () => { };
const resetSquare = async () => {};
// ========== Lifecycle ==========
onMounted(() => {
@ -311,7 +358,13 @@ onUnmounted(() => {
<style lang="scss" scoped>
.square-container {
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);
width: 100vw;
@ -347,7 +400,7 @@ onUnmounted(() => {
width: 100%;
height: 100vh;
/* margin-top: 160rpx; */
padding: 208rpx 16rpx 0;
padding: 208rpx 0 0;
box-sizing: border-box;
}
@ -457,7 +510,6 @@ onUnmounted(() => {
/* 动效减弱的可访问性兜底 */
@media (prefers-reduced-motion: reduce) {
.spotlight,
.spotlight-ready .banner-section,
.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: 85 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