Compare commits
23 Commits
233b527236
...
d4e26337de
| Author | SHA1 | Date | |
|---|---|---|---|
| d4e26337de | |||
| 61d39e2c1e | |||
| 90803234ba | |||
| 021782d42f | |||
| b130a51474 | |||
| 563969c4e0 | |||
| 7b7902bba6 | |||
| 617bb201dc | |||
| 7f831c4fbe | |||
| 6231295e36 | |||
| 32bc65aa1b | |||
| 308cf33a14 | |||
| ca87faf98d | |||
| 1542147291 | |||
| f66be49384 | |||
| dd5f95d734 | |||
| f7b76e3813 | |||
| ef82d7a57d | |||
| 4ad16dd91f | |||
| bfb15c57e5 | |||
| 04609adb5e | |||
| 2bd5733ca0 | |||
| a870943dc1 |
3
.gitignore
vendored
@ -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
|
||||
1021
docs/superpowers/plans/2026-06-10-square-stargalaxy-component.md
Normal 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 个散落 item(cover + 上方 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) → ScatteredRanks(9 个 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)
|
||||
│ ├─ 青绿色高光 overlay(180deg #53F4D3 → 透明)
|
||||
│ └─ 钻石渐变边框层(4px 渐变描边)
|
||||
└─ TOP N 标签(绝对定位 bottom: -4px,居中)
|
||||
```
|
||||
|
||||
**皇冠**(仅 TOP 1):绝对定位 `top: -44px; left: 50%; transform: translateX(-50%)`,font-size: 44rpx,2s 循环 pulse 动画。
|
||||
|
||||
### 5.4 ScatteredRanks 9 个 slot 位置
|
||||
|
||||
9 个 item 在 65° 倾斜椭圆上,等角度 40° 间隔(顺时针),起始角 180°(TOP 4 在正下方 = 最前)。
|
||||
|
||||
椭圆参数:
|
||||
- 圆心 (cx, cy) = (187, 510)
|
||||
- 水平半径 rx = 130
|
||||
- 垂直半径 ry = 55(cos(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> <!-- 14rpx,label 在 cover 上方 -->
|
||||
<image class="cover" src="..." /> <!-- 56rpx,cover 在 label 下方 -->
|
||||
</view>
|
||||
```
|
||||
|
||||
- **label**(顶部 14rpx):
|
||||
- 背景:`radial-gradient(ellipse, #C8E6FF, #fff 50%, #4D9AF8)`(蓝白钻石)
|
||||
- TOP 4 label 用金渐变:`#FFFFFF, #FFFABD 30%, #4D9AF8 100%`
|
||||
- 圆角 7rpx
|
||||
- 文字:14rpx Baloo Bhai(fallback 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 */
|
||||
}
|
||||
```
|
||||
|
||||
- 总周期 36s,4s 一步
|
||||
- 线性 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 起始位置 = 对应的 slot,9 个 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.0,2s 循环)
|
||||
|
||||
---
|
||||
|
||||
## 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 三端视觉一致
|
||||
@ -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 之上) */
|
||||
|
||||
171
frontend/pages/square/components/StarGalaxy/PodiumCard.vue
Normal 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>
|
||||
79
frontend/pages/square/components/StarGalaxy/README.md
Normal 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 个散落 item(cover + 上方标签 + 蓝白钻石渐变)
|
||||
- 9 item 顺时针 36s 旋转一圈(单组 @keyframes + 9 个不同 animation-delay 错开)
|
||||
- 近大远小(scale 0.75→1.15,TOP 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` | ✅ | ⚠️ | ❌ | 仅 WebKit;MP 需 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 风格统一)
|
||||
226
frontend/pages/square/components/StarGalaxy/ScatteredRanks.vue
Normal 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×72,top-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>
|
||||
80
frontend/pages/square/components/StarGalaxy/config.js
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 单组 @keyframes(CSS 模板字符串)
|
||||
// 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]
|
||||
309
frontend/pages/square/components/StarGalaxy/index.vue
Normal 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 item:TOP 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>
|
||||
@ -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,
|
||||
|
||||
BIN
frontend/static/square/galaxy/LV1.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
frontend/static/square/galaxy/LV10.png
Normal file
|
After Width: | Height: | Size: 156 KiB |
BIN
frontend/static/square/galaxy/LV11.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
frontend/static/square/galaxy/LV12.png
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
frontend/static/square/galaxy/LV2.png
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
frontend/static/square/galaxy/LV3.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
frontend/static/square/galaxy/LV4.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
frontend/static/square/galaxy/LV5.png
Normal file
|
After Width: | Height: | Size: 161 KiB |
BIN
frontend/static/square/galaxy/LV6.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
frontend/static/square/galaxy/LV7.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
frontend/static/square/galaxy/LV8.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
BIN
frontend/static/square/galaxy/LV9.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
frontend/static/square/galaxy/TOP.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
frontend/static/square/galaxy/TOP2.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
frontend/static/square/galaxy/TOP3.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
frontend/static/square/galaxy/baoguangbang.png
Normal file
|
After Width: | Height: | Size: 260 KiB |
BIN
frontend/static/square/galaxy/bj.png
Normal file
|
After Width: | Height: | Size: 3.5 MiB |
BIN
frontend/static/square/galaxy/dianzanbang.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
frontend/static/square/galaxy/dizuo1.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/static/square/galaxy/dizuo2.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
frontend/static/square/galaxy/dizuo3.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
frontend/static/square/galaxy/huoyuebang.png
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
frontend/static/square/galaxy/tongchengbang.png
Normal file
|
After Width: | Height: | Size: 308 KiB |
BIN
frontend/static/square/galaxy/topbj1.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
frontend/static/square/galaxy/topbj2.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
frontend/static/square/galaxy/topbj3.png
Normal file
|
After Width: | Height: | Size: 920 KiB |
BIN
frontend/static/square/top/bj.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |