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

@ -39,12 +39,7 @@ function handleClick() {
display: flex;
align-items: center;
justify-content: center;
// rank podium
// .podium-N rank
background-image: url("/static/square/galaxy/topbj3.png");
background-size: cover;
background-position: center;
background-repeat: no-repeat;
// .podium-N
}
/* TOP 1: 中央大卡(最大) */
@ -52,27 +47,84 @@ function handleClick() {
top: 400rpx;
left: 50%;
transform: translateX(-50%);
width: 96rpx;
height: 128rpx;
width: 240rpx;
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 {
top: 150rpx;
right: 60rpx;
width: 96rpx;
height: 128rpx;
top: 184rpx;
left: -96rpx;
width: 200rpx;
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 {
top: 152rpx;
left: 96rpx;
width: 160rpx;
height: 208rpx;
right: -96rpx;
width: 220rpx;
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 {
width: 160rpx;
height: 208rpx;
width: 100%;
height: 100%;
}
}
}
@ -87,8 +139,7 @@ function handleClick() {
}
.cover-wrap {
position: absolute;
inset: 0;
position: relative;
z-index: 2;
}
@ -96,13 +147,12 @@ function handleClick() {
width: 100%;
height: 100%;
position: absolute;
inset: 0;
}
.top-label {
width: 64rpx;
position: absolute;
bottom: -64rpx;
bottom: 48rpx;
left: 50%;
transform: translateX(-50%);
color: #fffabd;

View File

@ -28,15 +28,11 @@
@click="handleClick(items[i])"
>
<!-- 相框最底层 -->
<image
class="ring-frame"
:src="ringFrameSrc(p.rank)"
mode="aspectFit"
/>
<image class="ring-frame" :src="ringFrameSrc(p.rank)" mode="aspectFit" />
<view class="top-label">{{ formatLabel(p.rank) }}</view>
<image
class="cover-image"
:src="(items[i]?.cover_url) || (items[i]?.cover_image) || ''"
:src="items[i]?.cover_url || items[i]?.cover_image || ''"
mode="aspectFill"
/>
</view>
@ -47,65 +43,64 @@
// 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'
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'])
const emit = defineEmits(["cardClick"]);
// base slot 0 (187, 565)item 46×72top-left = (164, 530)
const BASE_X = 164
const BASE_Y = 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
]
"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',
left: BASE_X + "rpx",
top: BASE_Y + "rpx",
zIndex: p.zIndex,
transform: SLOT_TRANSFORMS[p.rank - 4],
animationDelay: RING_DELAYS[p.rank - 4] + 's',
}
animationDelay: RING_DELAYS[p.rank - 4] + "s",
};
}
function formatLabel(rank) {
return 'TOP ' + 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`
return `/static/square/galaxy/LV${rank}.png`;
}
function handleClick(item) {
if (item) emit('cardClick', item)
if (item) emit("cardClick", item);
}
</script>
<style scoped>
.scattered-ranks {
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
top: 22%;
left: 22%;
width: 750rpx;
height: 720rpx;
pointer-events: none;
@ -123,79 +118,100 @@ function handleClick(item) {
.ring-item {
position: absolute;
width: 46rpx;
height: 72rpx; /* 14 label + 2 gap + 56 cover */
width: 84rpx;
height: 104rpx; /* 14 label + 2 gap + 56 cover */
transform-origin: center;
pointer-events: auto;
cursor: pointer;
display: flex;
/* display: flex; */
flex-direction: column;
animation: orbit 36s linear infinite;
}
.ring-frame {
position: absolute;
inset: -2rpx; /* extend slightly outside the item bounds */
width: calc(100% + 4rpx);
height: calc(100% + 4rpx);
z-index: 0;
inset: 0; /* extend slightly outside the item bounds */
width: 100%;
height: 100%;
z-index: 2;
pointer-events: none;
}
.top-label {
width: 46rpx;
height: 14rpx;
background: radial-gradient(ellipse, #C8E6FF, #fff 50%, #4D9AF8);
border-radius: 7rpx;
width: 80rpx;
position: relative;
bottom: 16rpx;
left: 50%;
transform: translateX(-50%);
color: #fffabd;
font-size: 20rpx;
font-weight: 800;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 7rpx;
font-weight: 900;
color: #FFFABD;
text-shadow: -1rpx 1rpx 2rpx rgba(206, 9, 9, 0.84);
z-index: 2; /* above frame */
}
/* TOP 4 label 是金渐变(最显眼) */
.r0 .top-label {
background: radial-gradient(ellipse, #FFFFFF, #FFFABD 30%, #4D9AF8 100%);
box-shadow: 0 4rpx 12rpx rgba(255, 250, 189, 0.55);
font-size: 8rpx;
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 {
margin-top: 2rpx;
width: 46rpx;
height: 56rpx;
width: 100%;
height: 100%;
border-radius: 5rpx;
box-shadow: 3rpx 3rpx 6rpx rgba(198, 13, 13, 0.45);
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>
/* 关键帧:放在非 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); }
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); }
0%,
100% {
transform: translateX(-50%) scale(1);
}
50% {
transform: translateX(-50%) scale(1.15);
}
}
/* 可访问性:减少动画 */

View File

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

View File

@ -8,41 +8,72 @@
></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"
>
<view v-if="activeContentTab === 'xinghe'" class="star-galaxy-wrapper">
<StarGalaxy @cardClick="handleCardClick" />
</view>
@ -111,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, () => {
@ -151,7 +188,8 @@ const onGridLoaded = (count) => {
};
// ========== Composables ==========
const { bannerActivities, banners, loadBannerActivities, loadBanners } = useBanner();
const { bannerActivities, banners, loadBannerActivities, loadBanners } =
useBanner();
// ========== Handlers ==========
@ -199,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}`,
});
@ -320,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;
@ -356,7 +400,7 @@ onUnmounted(() => {
width: 100%;
height: 100vh;
/* margin-top: 160rpx; */
padding: 208rpx 16rpx 0;
padding: 208rpx 0 0;
box-sizing: border-box;
}
@ -466,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: 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