Compare commits
7 Commits
040a494923
...
36e8f251e2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36e8f251e2 | ||
|
|
4f77f8c68c | ||
|
|
dcd8cd4527 | ||
|
|
4538725884 | ||
|
|
ad773ffc27 | ||
| 6b26ef26db | |||
| 8b7a80f792 |
@ -397,7 +397,7 @@ func (p *SocialProvider) GetMyLikedAssets(ctx context.Context, req *pb.GetMyLike
|
|||||||
zap.Int32("page_size", req.PageSize),
|
zap.Int32("page_size", req.PageSize),
|
||||||
)
|
)
|
||||||
|
|
||||||
return p.assetLikeService.GetMyLikedAssets(ctx, req)
|
return p.assetLikeService.GetMyLikedAssets(ctx, req, userID, starID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractUserInfo 从 Dubbo attachments 中提取用户信息
|
// extractUserInfo 从 Dubbo attachments 中提取用户信息
|
||||||
|
|||||||
697
docs/superpowers/specs/2026-04-27-inspiration-flow-design.md
Normal file
697
docs/superpowers/specs/2026-04-27-inspiration-flow-design.md
Normal file
@ -0,0 +1,697 @@
|
|||||||
|
# 灵感瀑布流(Inspiration Flow)设计文档
|
||||||
|
|
||||||
|
> **创建日期:** 2026-04-28
|
||||||
|
> **更新日期:** 2026-04-29
|
||||||
|
> **项目:** TopFans 横向瀑布流藏品展示
|
||||||
|
> **服务:** galleryService (Go Dubbo-go)
|
||||||
|
> **状态:** 设计中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、设计目标
|
||||||
|
|
||||||
|
横向瀑布流展示该 star_id 下所有用户展出的藏品,支持**随机展示**、**双向横向无限滚动**、按类型过滤。
|
||||||
|
|
||||||
|
**核心特点:**
|
||||||
|
- 每次查询返回随机顺序的藏品
|
||||||
|
- 支持双向横向无限滚动:
|
||||||
|
- 向右滚动:加载更多新数据(发现新内容)
|
||||||
|
- 向左滚动:加载历史数据(回看)
|
||||||
|
- 支持按藏品类型过滤(badge/poster/original/all)
|
||||||
|
- 会话级缓存,刷新后数据重新随机
|
||||||
|
|
||||||
|
**无限滚动实现方式:** 使用游标分页(Cursor-based Pagination),前端滚动到边缘时携带 `cursor` 参数加载下一批数据。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、数据来源
|
||||||
|
|
||||||
|
**主表:** Exhibition(展品展示表)
|
||||||
|
|
||||||
|
**关联表:**
|
||||||
|
- Asset(资产表)- 用于获取藏品名称、封面、点赞数
|
||||||
|
- FanProfile(粉丝档案表)- 用于获取展出者昵称
|
||||||
|
|
||||||
|
**筛选条件:**
|
||||||
|
- `occupier_star_id = ?` (当前用户 star_id)
|
||||||
|
- `expire_at > now` (未过期)
|
||||||
|
- `deleted_at IS NULL` (未删除)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、API 设计
|
||||||
|
|
||||||
|
### 3.1 获取灵感瀑布藏品列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/inspiration-flow
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query 参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| cursor | string | 否 | 空 | 游标(首次请求为空) |
|
||||||
|
| direction | string | 否 | right | 滚动方向:right(加载新数据)/ left(加载历史) |
|
||||||
|
| limit | int | 否 | 10 | 每页数量(最大 20,移动端优化) |
|
||||||
|
| type | string | 否 | all | 过滤类型:badge/poster/original/all |
|
||||||
|
|
||||||
|
**HTTP 响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"asset_id": 123,
|
||||||
|
"name": "藏品名称",
|
||||||
|
"cover_url": "https://xxx.com/cover.png",
|
||||||
|
"like_count": 100,
|
||||||
|
"owner_nickname": "粉丝昵称"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"cursor": "eyJsaW1pdCI6MTB9",
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应字段说明:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| items | array | 藏品列表 |
|
||||||
|
| cursor | string | 下次请求的游标,base64 编码的 JSON |
|
||||||
|
| has_more | bool | 是否还有更多数据 |
|
||||||
|
|
||||||
|
**错误码:**
|
||||||
|
|
||||||
|
| code | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 200 | 成功 |
|
||||||
|
| 401 | 用户认证失败 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Proto 定义
|
||||||
|
|
||||||
|
### 4.1 Request / Response
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// 获取灵感瀑布藏品列表请求
|
||||||
|
message GetInspirationFlowRequest {
|
||||||
|
string cursor = 1; // 游标(首次请求为空)
|
||||||
|
string direction = 2; // 滚动方向:right(加载新数据)/ left(加载历史)
|
||||||
|
int32 limit = 3; // 每页数量(默认10,最大20)
|
||||||
|
string type = 4; // 过滤类型:badge/poster/original/all(默认all)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取灵感瀑布藏品列表响应
|
||||||
|
message GetInspirationFlowResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
InspirationFlowData data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 灵感瀑布数据
|
||||||
|
message InspirationFlowData {
|
||||||
|
repeated InspirationFlowItem items = 1; // 藏品列表
|
||||||
|
string cursor = 2; // 下次请求的游标
|
||||||
|
bool has_more = 3; // 是否有更多
|
||||||
|
}
|
||||||
|
|
||||||
|
// 灵感瀑布藏品项
|
||||||
|
message InspirationFlowItem {
|
||||||
|
int64 asset_id = 1; // 资产ID
|
||||||
|
string name = 2; // 藏品名称
|
||||||
|
string cover_url = 3; // 封面图URL
|
||||||
|
int32 like_count = 4; // 点赞数
|
||||||
|
string owner_nickname = 5; // 展出者昵称
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Service 方法
|
||||||
|
|
||||||
|
在 GalleryService 中新增方法:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// 展馆服务
|
||||||
|
service GalleryService {
|
||||||
|
// ... 现有方法 ...
|
||||||
|
|
||||||
|
// 获取灵感瀑布藏品列表
|
||||||
|
rpc GetInspirationFlow(GetInspirationFlowRequest) returns (GetInspirationFlowResponse) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/v1/inspiration-flow"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、核心逻辑
|
||||||
|
|
||||||
|
### 5.1 随机查询实现
|
||||||
|
|
||||||
|
**核心需求:** 每次查询返回随机顺序的藏品数据,而不是固定顺序。
|
||||||
|
|
||||||
|
**实现方式:** 使用 PostgreSQL 的 `ORDER BY RANDOM()` 实现随机排序。
|
||||||
|
|
||||||
|
**为什么选择 ORDER BY RANDOM():**
|
||||||
|
1. **实现简单**:一条 SQL 搞定,无需应用层处理
|
||||||
|
2. **数据量适配良好**:在几千到几万条数据时性能可接受(< 100ms)
|
||||||
|
3. **接口稳定**:便于后期扩展优化策略
|
||||||
|
|
||||||
|
**性能说明:**
|
||||||
|
|
||||||
|
| 数据量 | RANDOM() 性能 | 推荐程度 |
|
||||||
|
|--------|--------------|---------|
|
||||||
|
| < 1万条 | < 50ms | 强烈推荐 |
|
||||||
|
| 1-5万条 | 50-100ms | 推荐 |
|
||||||
|
| 5-10万条 | 100-500ms | 可接受 |
|
||||||
|
| > 10万条 | 开始变慢 | 需要优化 |
|
||||||
|
|
||||||
|
> 注:后期数据量超过 10 万条时,可考虑切换为"区间采样"策略,详见本章 5.5 节扩展说明。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 随机 offset 分页设计
|
||||||
|
|
||||||
|
**核心思路:** 每次请求都是独立的随机排序,随机生成 offset 值,而不是依赖游标累加 offset。
|
||||||
|
|
||||||
|
**游标结构(JSON,base64 编码):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"limit": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **说明:** 游标只记录 limit,不需要记录 offset(因为每次 offset 都是随机生成的)
|
||||||
|
|
||||||
|
**为什么用随机 offset:**
|
||||||
|
1. **数据变化无影响**:每次都是全新随机,数据变化不影响展示
|
||||||
|
2. **性能稳定**:offset 从 0 开始,不存在深分页性能问题
|
||||||
|
3. **实现简单**:无需处理数据变化时的缓存问题
|
||||||
|
|
||||||
|
**游标的行为说明:**
|
||||||
|
- 每次请求 offset 都是随机生成的(0 ~ max(0, total-limit))
|
||||||
|
- 同一会话内滚动时,顺序可能跳变(这是预期行为)
|
||||||
|
- 用户刷新页面后,看到全新的随机顺序
|
||||||
|
|
||||||
|
> **业务说明:** 由于每次请求都是独立随机,滚动加载过程中顺序可能跳变。这是预期行为,用户每次刷新看到的是不同顺序,符合"随机展示"的需求。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 查询逻辑
|
||||||
|
|
||||||
|
**实现流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 查询该 star_id 下有效展品的总数
|
||||||
|
2. 随机生成 offset 值(0 ~ max(0, total-limit))
|
||||||
|
3. 执行带随机 offset 的查询
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQL 查询:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. 先查询总数
|
||||||
|
SELECT COUNT(*) FROM exhibitions e
|
||||||
|
WHERE e.occupier_star_id = ?
|
||||||
|
AND e.expire_at > ?
|
||||||
|
AND e.deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- 2. 应用层生成随机 offset,然后查询
|
||||||
|
SELECT
|
||||||
|
e.asset_id,
|
||||||
|
a.name,
|
||||||
|
a.cover_url,
|
||||||
|
a.like_count,
|
||||||
|
fp.nickname as owner_nickname
|
||||||
|
FROM exhibitions e
|
||||||
|
JOIN assets a ON e.asset_id = a.id
|
||||||
|
JOIN fan_profiles fp ON e.occupier_uid = fp.user_id AND e.occupier_star_id = fp.star_id
|
||||||
|
WHERE e.occupier_star_id = ?
|
||||||
|
AND e.expire_at > ?
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
AND a.status = 1
|
||||||
|
AND a.is_active = true
|
||||||
|
AND (? = 'all' OR a.material_type = ?)
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT ? OFFSET ?; -- offset 由应用层随机生成
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明:**
|
||||||
|
- `? = star_id` (当前用户 star_id)
|
||||||
|
- `? = now` (当前时间戳)
|
||||||
|
- `? = type` (过滤类型)
|
||||||
|
- `? = limit` (每页数量)
|
||||||
|
- `? = offset` (随机生成的偏移量,非固定值)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 游标编解码
|
||||||
|
|
||||||
|
**说明:** 由于每次请求的 offset 是随机生成的,游标只需要记录 limit。
|
||||||
|
|
||||||
|
**编码(服务端):**
|
||||||
|
```go
|
||||||
|
limit := 10 // 默认值,实际从请求或配置获取
|
||||||
|
cursor := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf(`{"limit":%d}`, limit)))
|
||||||
|
```
|
||||||
|
|
||||||
|
**解码(服务端):**
|
||||||
|
```go
|
||||||
|
decoded, _ := base64.StdEncoding.DecodeString(cursor)
|
||||||
|
var cursorData map[string]int
|
||||||
|
json.Unmarshal(decoded, &cursorData)
|
||||||
|
limit := cursorData["limit"] // offset 由每次请求随机生成,无需从游标获取
|
||||||
|
```
|
||||||
|
|
||||||
|
**前端使用:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 首次请求
|
||||||
|
fetch('/api/v1/inspiration-flow?limit=10&type=all')
|
||||||
|
|
||||||
|
// 向右滚动(加载新数据)
|
||||||
|
fetch('/api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=right')
|
||||||
|
|
||||||
|
// 向左滚动(加载历史)
|
||||||
|
fetch('/api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=left')
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5 双向滚动实现
|
||||||
|
|
||||||
|
**核心思路:** 后端维护会话级已展示数据缓存,实现双向滚动时完全避免重复。
|
||||||
|
|
||||||
|
**滚动方向定义:**
|
||||||
|
- `direction: "right"` - 向右滚动,加载新数据(发现新内容)
|
||||||
|
- `direction: "left"` - 向左滚动,加载历史数据(回看)
|
||||||
|
|
||||||
|
**游标结构(JSON,base64 编码):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"limit": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **说明:** direction 是独立的 query 参数,不包含在游标中。游标只用于分页控制。
|
||||||
|
|
||||||
|
**缓存结构(Redis):**
|
||||||
|
```
|
||||||
|
Key: inspiration_flow:{star_id}:{session_id}
|
||||||
|
Type: Hash
|
||||||
|
Fields:
|
||||||
|
- display_order: [1, 5, 3, 9, 2, 4, ...] # 展示顺序
|
||||||
|
- history: {1: data1, 5: data5, 3: data3, ...} # 历史数据详情(方便回看)
|
||||||
|
TTL: 30分钟(无操作自动清理)
|
||||||
|
```
|
||||||
|
|
||||||
|
**向右滚动处理流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 前端请求:direction=right
|
||||||
|
2. 后端从 Redis 获取已展示ID集合
|
||||||
|
3. 后端执行随机查询,排除已展示ID
|
||||||
|
SELECT ... FROM exhibitions
|
||||||
|
WHERE id NOT IN (已展示ID)
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT ?
|
||||||
|
4. 返回新数据,并记录到缓存
|
||||||
|
5. 前端追加展示
|
||||||
|
```
|
||||||
|
|
||||||
|
**向左滚动处理流程:**
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 前端请求:direction=left, offset=?
|
||||||
|
2. 后端从 Redis 获取 display_order
|
||||||
|
3. 后端根据 offset 从历史中分页返回
|
||||||
|
4. 前端在左侧插入展示
|
||||||
|
```
|
||||||
|
|
||||||
|
**SQL 排除已展示ID查询:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
e.asset_id,
|
||||||
|
a.name,
|
||||||
|
a.cover_url,
|
||||||
|
a.like_count,
|
||||||
|
fp.nickname as owner_nickname
|
||||||
|
FROM exhibitions e
|
||||||
|
JOIN assets a ON e.asset_id = a.id
|
||||||
|
JOIN fan_profiles fp ON e.occupier_uid = fp.user_id AND e.occupier_star_id = fp.star_id
|
||||||
|
WHERE e.occupier_star_id = ?
|
||||||
|
AND e.expire_at > ?
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
AND e.id NOT IN (已展示ID列表)
|
||||||
|
AND a.status = 1
|
||||||
|
AND a.is_active = true
|
||||||
|
AND (? = 'all' OR a.material_type = ?)
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT ?;
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意:** 当已展示数据量很大时,`NOT IN` 查询性能会下降。需要在适当时机清理已展示缓存(建议 TTL 30分钟或达到一定数量后刷新随机顺序)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6 扩展说明:数据量大时的优化策略
|
||||||
|
|
||||||
|
**触发条件:** 当展品数据量超过 10 万条,`ORDER BY RANDOM()` 性能开始明显下降时。
|
||||||
|
|
||||||
|
**优化方案:区间采样**
|
||||||
|
|
||||||
|
**思路:** 把数据按 ID 区间分成多个桶,随机选择桶后在该桶内读取数据,应用层再对结果进行随机打乱
|
||||||
|
|
||||||
|
**优点:**
|
||||||
|
- 查询性能稳定,不随数据量增长而显著下降
|
||||||
|
- 能保证随机分布,每个数据都有机会被展示
|
||||||
|
- 跨桶采样,随机性更好
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 数据量大(10 万以上)
|
||||||
|
- 需要保证随机分布的均匀性
|
||||||
|
|
||||||
|
**实现示意:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 1. 获取该 star_id 下有效展品的 ID 范围
|
||||||
|
minID, maxID := "SELECT MIN(id), MAX(id) FROM exhibitions
|
||||||
|
WHERE occupier_star_id = ? AND expire_at > ? AND deleted_at IS NULL"
|
||||||
|
|
||||||
|
// 2. 计算桶信息(假设每个桶 10000 条)
|
||||||
|
bucketSize := int64(10000)
|
||||||
|
totalBuckets := (maxID - minID) / bucketSize + 1
|
||||||
|
|
||||||
|
// 3. 随机选择 1 个桶
|
||||||
|
randomBucket := random.Int63n(totalBuckets)
|
||||||
|
bucketStartID := minID + randomBucket*bucketSize
|
||||||
|
bucketEndID := bucketStartID + bucketSize
|
||||||
|
|
||||||
|
// 4. 在桶内查询所有有效 ID(数据量小,随机开销可忽略)
|
||||||
|
bucketIDs := "SELECT id FROM exhibitions
|
||||||
|
WHERE id BETWEEN ? AND ?
|
||||||
|
AND occupier_star_id = ? AND expire_at > ? AND deleted_at IS NULL
|
||||||
|
ORDER BY id"
|
||||||
|
|
||||||
|
// 5. 应用层对 bucketIDs 随机打乱,取前 limit 个
|
||||||
|
shuffle(bucketIDs)
|
||||||
|
selectedIDs := bucketIDs[:limit]
|
||||||
|
|
||||||
|
// 6. 根据 selectedIDs 查询完整数据
|
||||||
|
"SELECT ... FROM exhibitions WHERE id IN (?)", selectedIDs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 扩展接口设计(预留)
|
||||||
|
|
||||||
|
两种策略可以抽象成统一的接口,便于后期切换:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 随机策略接口
|
||||||
|
type RandomStrategy interface {
|
||||||
|
// GetRandomAssetIDs 获取随机的展品 ID 列表
|
||||||
|
GetRandomAssetIDs(ctx context.Context, starID string, limit int) ([]int64, error)
|
||||||
|
|
||||||
|
// Name 返回策略名称
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略1:随机排序(当前使用,数据量 < 10 万时推荐)
|
||||||
|
type RandomOrderStrategy struct{}
|
||||||
|
|
||||||
|
func (s *RandomOrderStrategy) GetRandomAssetIDs(ctx context.Context, starID string, limit int) ([]int64, error) {
|
||||||
|
// 1. 获取有效展品总数
|
||||||
|
total, err := repo.CountValidAssets(starID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return []int64{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 随机生成 offset(0 ~ max(0, total-limit))
|
||||||
|
maxOffset := total - int64(limit)
|
||||||
|
if maxOffset < 0 {
|
||||||
|
maxOffset = 0
|
||||||
|
}
|
||||||
|
randomOffset := random.Int63n(maxOffset + 1) // +1 是因为 Int63n(0) 会报错
|
||||||
|
|
||||||
|
// 3. 执行随机排序查询
|
||||||
|
return repo.GetRandomAssetsByOrder(starID, int(randomOffset), limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RandomOrderStrategy) Name() string {
|
||||||
|
return "random_order"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 策略2:区间采样(数据量 > 10 万时推荐)
|
||||||
|
type RangeSamplingStrategy struct {
|
||||||
|
bucketSize int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RangeSamplingStrategy) GetRandomAssetIDs(ctx context.Context, starID string, limit int) ([]int64, error) {
|
||||||
|
// 1. 获取 ID 范围
|
||||||
|
minID, maxID, err := repo.GetIDRange(starID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 计算桶信息并随机选择桶
|
||||||
|
bucketSize := s.bucketSize // 默认 10000
|
||||||
|
totalBuckets := (maxID - minID) / bucketSize + 1
|
||||||
|
randomBucket := random.Int63n(totalBuckets)
|
||||||
|
bucketStartID := minID + randomBucket*bucketSize
|
||||||
|
bucketEndID := bucketStartID + bucketSize
|
||||||
|
|
||||||
|
// 3. 在桶内查询 ID 列表
|
||||||
|
ids, err := repo.GetAssetIDsInRange(starID, bucketStartID, bucketEndID, limit*3)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 应用层随机打乱
|
||||||
|
shuffle(ids)
|
||||||
|
if len(ids) > limit {
|
||||||
|
ids = ids[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *RangeSamplingStrategy) Name() string {
|
||||||
|
return "range_sampling"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**策略选择建议:**
|
||||||
|
|
||||||
|
| 数据量 | 方案 | 随机性 | 推荐度 |
|
||||||
|
|--------|------|--------|--------|
|
||||||
|
| < 10 万 | RandomOrderStrategy | ✅ 真正随机 | 强烈推荐 |
|
||||||
|
| > 10 万 | RangeSamplingStrategy | ✅ 跨桶分布均匀 | 强烈推荐 |
|
||||||
|
|
||||||
|
**策略切换配置:**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// config.go
|
||||||
|
// 数据量 < 10 万(推荐)
|
||||||
|
var randomStrategy RandomStrategy = &RandomOrderStrategy{}
|
||||||
|
|
||||||
|
// 数据量 > 10 万(推荐)
|
||||||
|
// var randomStrategy RandomStrategy = &RangeSamplingStrategy{bucketSize: 10000}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **扩展提示:** 切换策略只需修改配置,将 `randomStrategy` 的具体实现替换,接口和调用方无需改动。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、数据模型
|
||||||
|
|
||||||
|
### 6.1 Exhibition 表(已有字段)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Exhibition struct {
|
||||||
|
ID int64 `gorm:"primaryKey"`
|
||||||
|
AssetID int64 `gorm:"not null"`
|
||||||
|
SlotID int64 `gorm:"not null"`
|
||||||
|
HostProfileID int64 `gorm:"not null"`
|
||||||
|
OccupierUID int64 `gorm:"not null"`
|
||||||
|
OccupierStarID int64 `gorm:"not null;index"`
|
||||||
|
StartTime int64 `gorm:"not null"`
|
||||||
|
ExpireAt int64 `gorm:"not null;index"`
|
||||||
|
CreatedAt int64 `gorm:"not null"`
|
||||||
|
UpdatedAt int64 `gorm:"not null"`
|
||||||
|
DeletedAt *int64 `gorm:"index"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Asset 表关联字段
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Asset struct {
|
||||||
|
// ... 现有字段 ...
|
||||||
|
MaterialType string `gorm:"column:material_type"` // 素材类型:badge/poster/original
|
||||||
|
IsOriginal bool `gorm:"column:is_original"`
|
||||||
|
LikeCount int32 `gorm:"not null;default:0"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、配置项
|
||||||
|
|
||||||
|
| 配置项 | 说明 | 默认值 |
|
||||||
|
|--------|------|--------|
|
||||||
|
| inspiration_flow_limit | 默认每页数量 | 10 |
|
||||||
|
| inspiration_flow_max_limit | 最大每页数量 | 20 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、项目文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── proto/
|
||||||
|
│ └── gallery.proto # 修改:新增 GetInspirationFlow 方法
|
||||||
|
|
||||||
|
├── pkg/proto/
|
||||||
|
│ ├── gallery/
|
||||||
|
│ │ ├── gallery.pb.go # 重新生成
|
||||||
|
│ │ └── gallery.triple.go # 重新生成
|
||||||
|
|
||||||
|
├── services/galleryService/
|
||||||
|
│ ├── repository/
|
||||||
|
│ │ └── gallery_repository.go # 修改:新增 GetInspirationFlow 方法,使用 ORDER BY RANDOM()
|
||||||
|
│ │
|
||||||
|
│ ├── service/
|
||||||
|
│ │ └── gallery_service.go # 修改:新增 GetInspirationFlow 方法
|
||||||
|
│ │
|
||||||
|
│ ├── provider/
|
||||||
|
│ │ └── gallery_provider.go # 修改:新增 GetInspirationFlow Handler
|
||||||
|
│ │
|
||||||
|
│ └── config/
|
||||||
|
│ └── gallery_config.go # 修改:新增灵感瀑布配置项
|
||||||
|
|
||||||
|
└── gateway/
|
||||||
|
├── controller/
|
||||||
|
│ └── gallery_controller.go # 修改:新增 GetInspirationFlow 路由处理
|
||||||
|
│
|
||||||
|
├── dto/
|
||||||
|
│ ├── gallery_dto.go # 修改:新增 InspirationFlow DTO
|
||||||
|
│ └── gallery_converter.go # 修改:新增转换函数
|
||||||
|
│
|
||||||
|
└── router/
|
||||||
|
└── router.go # 修改:新增 /api/v1/inspiration-flow 路由
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、数据库变更(必须执行)
|
||||||
|
|
||||||
|
**注意:** 当前 `assets` 表**没有** `material_type` 字段,此变更**必须执行**后才能支持按类型过滤。
|
||||||
|
|
||||||
|
### 9.1 DDL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- assets 表新增 material_type 字段
|
||||||
|
ALTER TABLE assets ADD COLUMN IF NOT EXISTS material_type VARCHAR(50) DEFAULT 'original';
|
||||||
|
|
||||||
|
-- 创建索引优化查询
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_material_type ON assets(material_type);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 迁移脚本
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- backend/scripts/migrate_add_material_type.sql
|
||||||
|
ALTER TABLE assets ADD COLUMN IF NOT EXISTS material_type VARCHAR(50) DEFAULT 'original';
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assets_material_type ON assets(material_type);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、前端对接说明
|
||||||
|
|
||||||
|
### 10.1 首次请求
|
||||||
|
```
|
||||||
|
GET /api/v1/inspiration-flow?limit=10&type=all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 向右滚动(加载新数据)
|
||||||
|
```
|
||||||
|
GET /api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=right
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 向左滚动(加载历史)
|
||||||
|
```
|
||||||
|
GET /api/v1/inspiration-flow?cursor=eyJsaW1pdCI6MTB9&limit=10&type=all&direction=left
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.4 前端逻辑
|
||||||
|
1. 首次请求 cursor 为空(`?cursor=` 不传或传空),direction 默认为 right
|
||||||
|
2. 解析响应中的 cursor 和 has_more
|
||||||
|
3. 滚动到右侧边缘时,若 has_more=true,携带 cursor 发起下一页请求(direction=right)
|
||||||
|
4. 滚动到左侧边缘时,携带 cursor 发起上一页请求(direction=left)
|
||||||
|
5. 会话级缓存,刷新后数据重新随机
|
||||||
|
|
||||||
|
### 10.5 双向滚动行为说明
|
||||||
|
- **首次进入**:看到随机顺序的藏品
|
||||||
|
- **向右滚动**:加载更多新数据(发现新内容)
|
||||||
|
- **向左滚动**:加载之前看过的历史数据(回看)
|
||||||
|
- **刷新页面**:数据重新随机,已浏览历史清空
|
||||||
|
- **首次进入时左滑**:前端禁用左滑操作(因为没有历史数据)
|
||||||
|
|
||||||
|
**前端实现要点:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// 判断是否可以左滑
|
||||||
|
const canScrollLeft = displayedIDs.length > 0;
|
||||||
|
|
||||||
|
// 首次进入时禁用左滑
|
||||||
|
if (!canScrollLeft) {
|
||||||
|
// 禁用左滑手势
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载数据后启用左滑
|
||||||
|
onDataLoaded() {
|
||||||
|
this.canScrollLeft = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.6 随机展示行为说明
|
||||||
|
- **每次进入页面**:看到的是新的随机顺序
|
||||||
|
- **滚动加载过程中**:顺序可能跳变(因为每次都是独立随机)
|
||||||
|
- **刷新页面**:随机顺序重新生成,已浏览历史清空
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、待确认事项
|
||||||
|
|
||||||
|
1. **material_type 枚举值**:badge/poster/original,后续按需扩展
|
||||||
|
2. **每页数量上限**:当前设为 20(移动端优化),是否合适?
|
||||||
|
3. **是否需要缓存**:热门 star_id 的数据可以考虑 Redis 缓存
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、变更记录
|
||||||
|
|
||||||
|
| 日期 | 变更内容 |
|
||||||
|
|------|---------|
|
||||||
|
| 2026-04-29 | 重构随机查询逻辑,明确使用 ORDER BY RANDOM() 实现随机展示;添加大数据量扩展说明 |
|
||||||
|
| 2026-04-29 | 针对移动端优化:默认每页数量从 20 调整为 10,最大每页数量从 50 调整为 20 |
|
||||||
|
| 2026-04-29 | 补充大数据量优化方案:增加"区间采样"方案及 RandomStrategy 扩展接口设计 |
|
||||||
|
| 2026-04-29 | 移除方案一(随机起点),只保留 ORDER BY RANDOM() 和区间采样两个方案 |
|
||||||
|
| 2026-04-29 | 明确使用 PostgreSQL 数据库,ORDER BY RANDOM() 语法与 MySQL 相同 |
|
||||||
|
| 2026-04-29 | 改用随机 offset 方案(方案 B),每次请求都是独立随机,数据变化无影响 |
|
||||||
|
| 2026-04-29 | 新增双向滚动支持:向右加载新数据,向左加载历史数据,后端维护会话级缓存 |
|
||||||
|
| 2026-04-29 | 修复:修正重复的 10.4 章节、RangeSamplingStrategy 签名、游标结构说明 |
|
||||||
781
docs/superpowers/specs/2026-04-27-my-assets-design.md
Normal file
781
docs/superpowers/specs/2026-04-27-my-assets-design.md
Normal file
@ -0,0 +1,781 @@
|
|||||||
|
# 我的作品统计(点赞/展出)设计文档
|
||||||
|
|
||||||
|
> **创建日期:** 2026-04-27
|
||||||
|
> **项目:** TopFans 我的作品统计
|
||||||
|
> **服务:** socialService / galleryService
|
||||||
|
> **状态:** 设计中
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、设计目标
|
||||||
|
|
||||||
|
提供用户查看自己点赞过的作品和展出过的作品的统计接口,返回实时点赞数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、数据来源
|
||||||
|
|
||||||
|
### 2.1 我点赞的作品
|
||||||
|
|
||||||
|
**主表:** asset_likes(点赞记录表)
|
||||||
|
|
||||||
|
**关联表:**
|
||||||
|
- assets(资产表)- 用于获取藏品信息
|
||||||
|
- exhibitions(展品展示表)- 用于过滤展出中且未过期的藏品
|
||||||
|
- exhibition_revenue_records(收益记录表)- 用于获取当前可领取收益
|
||||||
|
|
||||||
|
**筛选条件:**
|
||||||
|
- `user_id = ?` (当前用户)
|
||||||
|
- `star_id = ?` (当前 star_id)
|
||||||
|
- `assets.deleted_at IS NULL` (藏品未删除)
|
||||||
|
- `assets.is_active = true` (藏品已激活)
|
||||||
|
- `exhibitions.deleted_at IS NULL` (展出记录未删除)
|
||||||
|
- `exhibitions.expire_at > now` (展出未过期)
|
||||||
|
|
||||||
|
### 2.2 我展出的作品
|
||||||
|
|
||||||
|
**主表:** exhibitions(展品展示表)
|
||||||
|
|
||||||
|
**关联表:**
|
||||||
|
- assets(资产表)- 用于获取藏品信息
|
||||||
|
- exhibition_revenue_records(收益记录表)- 用于获取当前可领取收益
|
||||||
|
|
||||||
|
**筛选条件:**
|
||||||
|
- `occupier_uid = ?` (当前用户)
|
||||||
|
- `occupier_star_id = ?` (当前 star_id)
|
||||||
|
- `deleted_at IS NULL` (未删除)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、API 设计
|
||||||
|
|
||||||
|
### 3.1 获取我点赞的作品列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/me/liked-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query 参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| page | int | 否 | 1 | 页码 |
|
||||||
|
| page_size | int | 否 | 20 | 每页数量(最大100) |
|
||||||
|
|
||||||
|
**HTTP 响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"asset_id": 123,
|
||||||
|
"name": "藏品名称",
|
||||||
|
"cover_url": "https://xxx.com/cover.png",
|
||||||
|
"like_count": 100,
|
||||||
|
"liked_at": 1714214400000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total": 50,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| asset_id | int64 | 资产ID |
|
||||||
|
| name | string | 藏品名称 |
|
||||||
|
| cover_url | string | 封面图URL |
|
||||||
|
| like_count | int32 | 实时点赞数(来自 assets 表) |
|
||||||
|
| liked_at | int64 | 用户点赞该作品的时间(毫秒时间戳) |
|
||||||
|
| earnings | int64 | 当前可领取收益(status='claimable' 的 crystal_amount 汇总) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 获取我展出的作品列表
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/me/exhibited-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query 参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| page | int | 否 | 1 | 页码 |
|
||||||
|
| page_size | int | 否 | 20 | 每页数量(最大100) |
|
||||||
|
|
||||||
|
**HTTP 响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"asset_id": 123,
|
||||||
|
"name": "藏品名称",
|
||||||
|
"cover_url": "https://xxx.com/cover.png",
|
||||||
|
"like_count": 100,
|
||||||
|
"exhibited_at": 1714214400000,
|
||||||
|
"expire_at": 1714278400000,
|
||||||
|
"earnings": 500
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total": 10,
|
||||||
|
"has_more": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| asset_id | int64 | 资产ID |
|
||||||
|
| name | string | 藏品名称 |
|
||||||
|
| cover_url | string | 封面图URL |
|
||||||
|
| like_count | int32 | 实时点赞数(来自 assets 表) |
|
||||||
|
| exhibited_at | int64 | 展出开始时间(毫秒时间戳) |
|
||||||
|
| expire_at | int64 | 展出过期时间(毫秒时间戳) |
|
||||||
|
| earnings | int64 | 当前可领取收益(status='claimable' 的 crystal_amount 汇总) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 获取我今日点赞的作品(暂不实现)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/me/today-liked-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query 参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| page | int | 否 | 1 | 页码 |
|
||||||
|
| page_size | int | 否 | 20 | 每页数量(最大100) |
|
||||||
|
|
||||||
|
**HTTP 响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"asset_id": 123,
|
||||||
|
"name": "藏品名称",
|
||||||
|
"cover_url": "https://xxx.com/cover.png",
|
||||||
|
"like_count": 100,
|
||||||
|
"liked_at": 1714214400000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total": 50,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| asset_id | int64 | 资产ID |
|
||||||
|
| name | string | 藏品名称 |
|
||||||
|
| cover_url | string | 封面图URL |
|
||||||
|
| like_count | int32 | 实时点赞数(来自 assets 表) |
|
||||||
|
| liked_at | int64 | 用户点赞该作品的时间(毫秒时间戳) |
|
||||||
|
|
||||||
|
> **状态:暂不实现**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.4 获取我本周点赞的作品(暂不实现)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/me/week-liked-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query 参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| page | int | 否 | 1 | 页码 |
|
||||||
|
| page_size | int | 否 | 20 | 每页数量(最大100) |
|
||||||
|
|
||||||
|
**HTTP 响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"asset_id": 123,
|
||||||
|
"name": "藏品名称",
|
||||||
|
"cover_url": "https://xxx.com/cover.png",
|
||||||
|
"like_count": 100,
|
||||||
|
"liked_at": 1714214400000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total": 50,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| asset_id | int64 | 资产ID |
|
||||||
|
| name | string | 藏品名称 |
|
||||||
|
| cover_url | string | 封面图URL |
|
||||||
|
| like_count | int32 | 实时点赞数(来自 assets 表) |
|
||||||
|
| liked_at | int64 | 用户点赞该作品的时间(毫秒时间戳) |
|
||||||
|
|
||||||
|
> **状态:暂不实现**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.5 获取他人点赞的作品列表(暂不实现)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/users/{user_id}/liked-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
**Path 参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| user_id | int64 | 是 | 他人用户ID |
|
||||||
|
|
||||||
|
**Query 参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| page | int | 否 | 1 | 页码 |
|
||||||
|
| page_size | int | 否 | 20 | 每页数量(最大100) |
|
||||||
|
|
||||||
|
**HTTP 响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"asset_id": 123,
|
||||||
|
"name": "藏品名称",
|
||||||
|
"cover_url": "https://xxx.com/cover.png",
|
||||||
|
"like_count": 100,
|
||||||
|
"liked_at": 1714214400000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total": 50,
|
||||||
|
"has_more": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| asset_id | int64 | 资产ID |
|
||||||
|
| name | string | 藏品名称 |
|
||||||
|
| cover_url | string | 封面图URL |
|
||||||
|
| like_count | int32 | 实时点赞数(来自 assets 表) |
|
||||||
|
| liked_at | int64 | 用户点赞该作品的时间(毫秒时间戳) |
|
||||||
|
|
||||||
|
> **状态:暂不实现**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.6 获取他人展出的作品列表(暂不实现)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/users/{user_id}/exhibited-assets
|
||||||
|
```
|
||||||
|
|
||||||
|
**Path 参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| user_id | int64 | 是 | 他人用户ID |
|
||||||
|
|
||||||
|
**Query 参数:**
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| page | int | 否 | 1 | 页码 |
|
||||||
|
| page_size | int | 否 | 20 | 每页数量(最大100) |
|
||||||
|
|
||||||
|
**HTTP 响应:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"message": "ok",
|
||||||
|
"data": {
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"asset_id": 123,
|
||||||
|
"name": "藏品名称",
|
||||||
|
"cover_url": "https://xxx.com/cover.png",
|
||||||
|
"like_count": 100,
|
||||||
|
"exhibited_at": 1714214400000,
|
||||||
|
"expire_at": 1714278400000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"page": 1,
|
||||||
|
"page_size": 20,
|
||||||
|
"total": 10,
|
||||||
|
"has_more": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**字段说明:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| asset_id | int64 | 资产ID |
|
||||||
|
| name | string | 藏品名称 |
|
||||||
|
| cover_url | string | 封面图URL |
|
||||||
|
| like_count | int32 | 实时点赞数(来自 assets 表) |
|
||||||
|
| exhibited_at | int64 | 展出开始时间(毫秒时间戳) |
|
||||||
|
| expire_at | int64 | 展出过期时间(毫秒时间戳) |
|
||||||
|
|
||||||
|
> **状态:暂不实现**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.7 错误码
|
||||||
|
|
||||||
|
| code | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 200 | 成功 |
|
||||||
|
| 401 | 用户认证失败 |
|
||||||
|
| 500 | 服务器内部错误 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、Proto 定义
|
||||||
|
|
||||||
|
### 4.1 我点赞的作品
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// 获取我点赞的作品列表请求
|
||||||
|
message GetMyLikedAssetsRequest {
|
||||||
|
int32 page = 1; // 页码(默认1)
|
||||||
|
int32 page_size = 2; // 每页数量(默认20,最大100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取我点赞的作品列表响应
|
||||||
|
message GetMyLikedAssetsResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
LikedAssetsData data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点赞作品数据
|
||||||
|
message LikedAssetsData {
|
||||||
|
repeated LikedAssetItem items = 1; // 作品列表
|
||||||
|
int32 page = 2; // 当前页码
|
||||||
|
int32 page_size = 3; // 每页数量
|
||||||
|
int64 total = 4; // 总数量
|
||||||
|
bool has_more = 5; // 是否有更多
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点赞作品项
|
||||||
|
message LikedAssetItem {
|
||||||
|
int64 asset_id = 1; // 资产ID
|
||||||
|
string name = 2; // 藏品名称
|
||||||
|
string cover_url = 3; // 封面图URL
|
||||||
|
int32 like_count = 4; // 实时点赞数
|
||||||
|
int64 liked_at = 5; // 点赞时间(毫秒时间戳)
|
||||||
|
int64 earnings = 6; // 当前可领取收益
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 我展出的作品
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// 获取我展出的作品列表请求
|
||||||
|
message GetMyExhibitedAssetsRequest {
|
||||||
|
int32 page = 1; // 页码(默认1)
|
||||||
|
int32 page_size = 2; // 每页数量(默认20,最大100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取我展出的作品列表响应
|
||||||
|
message GetMyExhibitedAssetsResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
ExhibitedAssetsData data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展出作品数据
|
||||||
|
message ExhibitedAssetsData {
|
||||||
|
repeated ExhibitedAssetItem items = 1; // 作品列表
|
||||||
|
int32 page = 2; // 当前页码
|
||||||
|
int32 page_size = 3; // 每页数量
|
||||||
|
int64 total = 4; // 总数量
|
||||||
|
bool has_more = 5; // 是否有更多
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展出作品项
|
||||||
|
message ExhibitedAssetItem {
|
||||||
|
int64 asset_id = 1; // 资产ID
|
||||||
|
string name = 2; // 藏品名称
|
||||||
|
string cover_url = 3; // 封面图URL
|
||||||
|
int32 like_count = 4; // 实时点赞数
|
||||||
|
int64 exhibited_at = 5; // 展出开始时间(毫秒时间戳)
|
||||||
|
int64 expire_at = 6; // 展出过期时间(毫秒时间戳)
|
||||||
|
int64 earnings = 7; // 当前可领取收益
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 我今日/本周点赞的作品(暂不实现)
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// 获取我今日点赞的作品列表请求
|
||||||
|
message GetMyTodayLikedAssetsRequest {
|
||||||
|
int32 page = 1; // 页码(默认1)
|
||||||
|
int32 page_size = 2; // 每页数量(默认20,最大100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取我今日点赞的作品列表响应
|
||||||
|
message GetMyTodayLikedAssetsResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
LikedAssetsData data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取我本周点赞的作品列表请求
|
||||||
|
message GetMyWeekLikedAssetsRequest {
|
||||||
|
int32 page = 1; // 页码(默认1)
|
||||||
|
int32 page_size = 2; // 每页数量(默认20,最大100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取我本周点赞的作品列表响应
|
||||||
|
message GetMyWeekLikedAssetsResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
LikedAssetsData data = 2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **状态:暂不实现**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.4 他人点赞/展出的作品(暂不实现)
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// 获取他人点赞的作品列表请求
|
||||||
|
message GetUserLikedAssetsRequest {
|
||||||
|
int64 user_id = 1; // 他人用户ID
|
||||||
|
int32 page = 2; // 页码(默认1)
|
||||||
|
int32 page_size = 3; // 每页数量(默认20,最大100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取他人点赞的作品列表响应
|
||||||
|
message GetUserLikedAssetsResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
LikedAssetsData data = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取他人展出的作品列表请求
|
||||||
|
message GetUserExhibitedAssetsRequest {
|
||||||
|
int64 user_id = 1; // 他人用户ID
|
||||||
|
int32 page = 2; // 页码(默认1)
|
||||||
|
int32 page_size = 3; // 每页数量(默认20,最大100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取他人展出的作品列表响应
|
||||||
|
message GetUserExhibitedAssetsResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
ExhibitedAssetsData data = 2;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **状态:暂不实现**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.5 Service 方法
|
||||||
|
|
||||||
|
在 SocialService 中新增方法:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// 社交服务
|
||||||
|
service SocialService {
|
||||||
|
// ... 现有方法 ...
|
||||||
|
|
||||||
|
// 获取我点赞的作品列表
|
||||||
|
rpc GetMyLikedAssets(GetMyLikedAssetsRequest) returns (GetMyLikedAssetsResponse) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/v1/me/liked-assets"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取我今日点赞的作品列表(暂不实现)
|
||||||
|
rpc GetMyTodayLikedAssets(GetMyTodayLikedAssetsRequest) returns (GetMyTodayLikedAssetsResponse) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/v1/me/today-liked-assets"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取我本周点赞的作品列表(暂不实现)
|
||||||
|
rpc GetMyWeekLikedAssets(GetMyWeekLikedAssetsRequest) returns (GetMyWeekLikedAssetsResponse) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/v1/me/week-liked-assets"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取他人点赞的作品列表(暂不实现)
|
||||||
|
rpc GetUserLikedAssets(GetUserLikedAssetsRequest) returns (GetUserLikedAssetsResponse) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/v1/users/{user_id}/liked-assets"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 GalleryService 中新增方法:
|
||||||
|
|
||||||
|
```protobuf
|
||||||
|
// 展馆服务
|
||||||
|
service GalleryService {
|
||||||
|
// ... 现有方法 ...
|
||||||
|
|
||||||
|
// 获取我展出的作品列表
|
||||||
|
rpc GetMyExhibitedAssets(GetMyExhibitedAssetsRequest) returns (GetMyExhibitedAssetsResponse) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/v1/me/exhibited-assets"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取他人展出的作品列表(暂不实现)
|
||||||
|
rpc GetUserExhibitedAssets(GetUserExhibitedAssetsRequest) returns (GetUserExhibitedAssetsResponse) {
|
||||||
|
option (google.api.http) = {
|
||||||
|
get: "/api/v1/users/{user_id}/exhibited-assets"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、核心逻辑
|
||||||
|
|
||||||
|
### 5.1 查询我点赞的作品(只返回展出中且未过期的)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
al.asset_id,
|
||||||
|
a.name,
|
||||||
|
a.cover_url,
|
||||||
|
a.like_count,
|
||||||
|
al.created_at as liked_at,
|
||||||
|
COALESCE(SUM(err.crystal_amount), 0) as earnings
|
||||||
|
FROM asset_likes al
|
||||||
|
JOIN assets a ON al.asset_id = a.id
|
||||||
|
JOIN exhibitions e ON e.asset_id = a.id
|
||||||
|
LEFT JOIN exhibition_revenue_records err ON err.asset_id = a.id AND err.status = 'claimable'
|
||||||
|
WHERE al.user_id = ?
|
||||||
|
AND al.star_id = ?
|
||||||
|
AND a.deleted_at IS NULL
|
||||||
|
AND a.is_active = true
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
AND e.expire_at > ?
|
||||||
|
GROUP BY al.asset_id, a.name, a.cover_url, a.like_count, al.created_at
|
||||||
|
ORDER BY al.created_at DESC
|
||||||
|
LIMIT ? OFFSET ?;
|
||||||
|
|
||||||
|
-- 计数
|
||||||
|
SELECT COUNT(DISTINCT al.asset_id)
|
||||||
|
FROM asset_likes al
|
||||||
|
JOIN assets a ON al.asset_id = a.id
|
||||||
|
JOIN exhibitions e ON e.asset_id = a.id
|
||||||
|
WHERE al.user_id = ?
|
||||||
|
AND al.star_id = ?
|
||||||
|
AND a.deleted_at IS NULL
|
||||||
|
AND a.is_active = true
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
AND e.expire_at > ?;
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明:**
|
||||||
|
- `? = user_id` (当前用户)
|
||||||
|
- `? = star_id` (当前 star_id)
|
||||||
|
- `? = now` (当前时间戳,只显示展出中且未过期的)
|
||||||
|
- `? = page_size`
|
||||||
|
- `? = (page - 1) * page_size`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 查询我展出的作品(只返回展出中的)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
e.asset_id,
|
||||||
|
a.name,
|
||||||
|
a.cover_url,
|
||||||
|
a.like_count,
|
||||||
|
e.start_time as exhibited_at,
|
||||||
|
e.expire_at,
|
||||||
|
COALESCE(SUM(err.crystal_amount), 0) as earnings
|
||||||
|
FROM exhibitions e
|
||||||
|
JOIN assets a ON e.asset_id = a.id
|
||||||
|
LEFT JOIN exhibition_revenue_records err ON err.asset_id = a.id AND err.status = 'claimable'
|
||||||
|
WHERE e.occupier_uid = ?
|
||||||
|
AND e.occupier_star_id = ?
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
AND e.expire_at > ? -- 只返回未过期的
|
||||||
|
GROUP BY e.asset_id, a.name, a.cover_url, a.like_count, e.start_time, e.expire_at
|
||||||
|
ORDER BY e.start_time DESC
|
||||||
|
LIMIT ? OFFSET ?;
|
||||||
|
|
||||||
|
-- 计数
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM exhibitions e
|
||||||
|
WHERE e.occupier_uid = ?
|
||||||
|
AND e.occupier_star_id = ?
|
||||||
|
AND e.deleted_at IS NULL
|
||||||
|
AND e.expire_at > ?; -- 只返回未过期的
|
||||||
|
```
|
||||||
|
|
||||||
|
**参数说明:**
|
||||||
|
- `? = user_id` (当前用户)
|
||||||
|
- `? = star_id` (当前 star_id)
|
||||||
|
- `? = now` (当前时间戳,只显示未过期的)
|
||||||
|
- `? = page_size`
|
||||||
|
- `? = (page - 1) * page_size`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、数据模型
|
||||||
|
|
||||||
|
### 6.1 asset_likes 表(已有字段)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type AssetLike struct {
|
||||||
|
ID int64 `gorm:"primaryKey"`
|
||||||
|
AssetID int64 `gorm:"not null;uniqueIndex:uk_asset_likes_user_asset"`
|
||||||
|
UserID int64 `gorm:"not null;uniqueIndex:uk_asset_likes_user_asset"`
|
||||||
|
StarID int64 `gorm:"not null;index"`
|
||||||
|
CreatedAt int64 `gorm:"not null;index"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 exhibitions 表(已有字段)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Exhibition struct {
|
||||||
|
ID int64 `gorm:"primaryKey"`
|
||||||
|
AssetID int64 `gorm:"not null"`
|
||||||
|
SlotID int64 `gorm:"not null"`
|
||||||
|
HostProfileID int64 `gorm:"not null"`
|
||||||
|
OccupierUID int64 `gorm:"not null;index"`
|
||||||
|
OccupierStarID int64 `gorm:"not null;index"`
|
||||||
|
StartTime int64 `gorm:"not null"`
|
||||||
|
ExpireAt int64 `gorm:"not null;index"`
|
||||||
|
CreatedAt int64 `gorm:"not null"`
|
||||||
|
UpdatedAt int64 `gorm:"not null"`
|
||||||
|
DeletedAt *int64 `gorm:"index"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 assets 表(已有字段)
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Asset struct {
|
||||||
|
ID int64 `gorm:"primaryKey"`
|
||||||
|
Name string `gorm:"type:varchar(100);not null"`
|
||||||
|
CoverURL string `gorm:"type:varchar(500);not null"`
|
||||||
|
LikeCount int32 `gorm:"not null;default:0"`
|
||||||
|
Status int32 `gorm:"not null;default:0"`
|
||||||
|
DeletedAt *int64 `gorm:"index"`
|
||||||
|
IsActive bool `gorm:"default:true;not null"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、项目文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── proto/
|
||||||
|
│ ├── social.proto # 修改:新增 GetMyLikedAssets 方法
|
||||||
|
│ └── gallery.proto # 修改:新增 GetMyExhibitedAssets 方法
|
||||||
|
│
|
||||||
|
├── pkg/proto/
|
||||||
|
│ ├── social/
|
||||||
|
│ │ ├── social.pb.go # 重新生成
|
||||||
|
│ │ └── social.triple.go # 重新生成
|
||||||
|
│ └── gallery/
|
||||||
|
│ ├── gallery.pb.go # 重新生成
|
||||||
|
│ └── gallery.triple.go # 重新生成
|
||||||
|
│
|
||||||
|
├── services/socialService/
|
||||||
|
│ ├── repository/
|
||||||
|
│ │ └── asset_like_repository.go # 修改:新增查询方法
|
||||||
|
│ │
|
||||||
|
│ ├── service/
|
||||||
|
│ │ └── asset_like_service.go # 修改:新增 GetMyLikedAssets 方法
|
||||||
|
│ │
|
||||||
|
│ └── provider/
|
||||||
|
│ └── social_provider.go # 修改:新增 GetMyLikedAssets Handler
|
||||||
|
│
|
||||||
|
├── services/galleryService/
|
||||||
|
│ ├── repository/
|
||||||
|
│ │ └── gallery_repository.go # 修改:新增 GetExhibitionsByOccupier 方法
|
||||||
|
│ │
|
||||||
|
│ ├── service/
|
||||||
|
│ │ └── exhibition_service.go # 修改:新增 GetMyExhibitedAssets 方法
|
||||||
|
│ │
|
||||||
|
│ └── provider/
|
||||||
|
│ └── gallery_provider.go # 修改:新增 GetMyExhibitedAssets Handler
|
||||||
|
│
|
||||||
|
└── gateway/
|
||||||
|
├── controller/
|
||||||
|
│ ├── social_controller.go # 修改:新增 /api/v1/me/liked-assets 路由
|
||||||
|
│ └── gallery_controller.go # 修改:新增 /api/v1/me/exhibited-assets 路由
|
||||||
|
│
|
||||||
|
└── router/
|
||||||
|
└── router.go # 修改:新增路由配置
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、已确认事项
|
||||||
|
|
||||||
|
1. **只显示展出中的作品** — 通过 `expire_at > now` 过滤
|
||||||
|
2. **排序方式** — 按展出时间倒序(start_time DESC)
|
||||||
|
3. **分页大小** — 默认 20,最大 100
|
||||||
|
4. **点赞作品也只显示展出中且未过期的** — 通过 JOIN exhibitions 并过滤 `expire_at > now`
|
||||||
|
5. **今日/本周点赞暂不实现** — API 和 Proto 已定义,但代码实现待后续
|
||||||
|
6. **每个藏品返回当前可领取收益** — 关联 exhibition_revenue_records 表,汇总 `status='claimable'` 的 `crystal_amount`
|
||||||
|
7. **他人点赞/展出的作品列表暂不实现** — API 和 Proto 已定义,但代码实现待后续
|
||||||
@ -2,7 +2,7 @@
|
|||||||
"name" : "TopFans",
|
"name" : "TopFans",
|
||||||
"appid" : "__UNI__F199FF4",
|
"appid" : "__UNI__F199FF4",
|
||||||
"description" : "",
|
"description" : "",
|
||||||
"versionName" : "1.0.0",
|
"versionName" : "1.0.1",
|
||||||
"versionCode" : "100",
|
"versionCode" : "100",
|
||||||
"transformPx" : false,
|
"transformPx" : false,
|
||||||
/* 5+App特有相关 */
|
/* 5+App特有相关 */
|
||||||
|
|||||||
554
frontend/pages/components/AssetSelector.vue
Normal file
554
frontend/pages/components/AssetSelector.vue
Normal file
@ -0,0 +1,554 @@
|
|||||||
|
<template>
|
||||||
|
<view v-if="visible" class="asset-selector-mask" @tap="closeModal">
|
||||||
|
<view class="asset-selector-modal" @tap.stop :class="{ 'show': animated }">
|
||||||
|
<!-- 背景图片 -->
|
||||||
|
<image class="modal-background" src="/static/background/starbook.jpg" mode="aspectFill"></image>
|
||||||
|
|
||||||
|
<!-- 内容包装器 -->
|
||||||
|
<view class="modal-content">
|
||||||
|
<!-- 顶部拖动区域 -->
|
||||||
|
<view
|
||||||
|
class="modal-drag-area"
|
||||||
|
@touchstart="handleTouchStart"
|
||||||
|
@touchmove="handleTouchMove"
|
||||||
|
@touchend="handleTouchEnd"
|
||||||
|
>
|
||||||
|
<view class="modal-handle"></view>
|
||||||
|
<text class="modal-title">选择要展出的藏品</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 类型Tab -->
|
||||||
|
<view class="modal-tabs">
|
||||||
|
<view
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="modal-tab-item"
|
||||||
|
:class="{ active: currentType === tab.key }"
|
||||||
|
@tap="switchType(tab.key)"
|
||||||
|
>
|
||||||
|
<text>{{ tab.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<view v-if="loading" class="modal-loading">
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-else-if="!hasData" class="modal-empty">
|
||||||
|
<text class="empty-text">暂无藏品</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 藏品列表 -->
|
||||||
|
<scroll-view v-else class="modal-scroll" scroll-y :show-scrollbar="false">
|
||||||
|
<!-- 原创藏品:按 grade 分组 -->
|
||||||
|
<template v-if="currentType === 'regular'">
|
||||||
|
<view v-for="gradeItem in regularGrades" :key="gradeItem.grade" class="grade-section">
|
||||||
|
<view class="grade-header">
|
||||||
|
<text class="grade-title">{{ formatGrade(gradeItem.grade) }}</text>
|
||||||
|
</view>
|
||||||
|
<scroll-view class="asset-row" scroll-x :show-scrollbar="false" :enable-flex="true">
|
||||||
|
<view class="asset-row-content">
|
||||||
|
<view
|
||||||
|
v-for="item in gradeItem.items"
|
||||||
|
:key="item.asset_id"
|
||||||
|
class="asset-item"
|
||||||
|
@tap="selectAsset(item)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="asset-image"
|
||||||
|
:src="item.coverUrl || '/static/nft/collection.png'"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view class="status-badge" :class="item.display_status === 1 ? 'badge-active' : 'badge-pending'">
|
||||||
|
<text class="status-text">{{ item.display_status === 1 ? '已展示' : '待展示' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="asset-info">
|
||||||
|
<text class="asset-name">{{ item.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 典藏/活动藏品:直接列表 -->
|
||||||
|
<template v-else>
|
||||||
|
<view class="grade-section">
|
||||||
|
<scroll-view class="asset-row" scroll-x :show-scrollbar="false" :enable-flex="true">
|
||||||
|
<view class="asset-row-content">
|
||||||
|
<view
|
||||||
|
v-for="item in currentItems"
|
||||||
|
:key="item.asset_id"
|
||||||
|
class="asset-item"
|
||||||
|
@tap="selectAsset(item)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
class="asset-image"
|
||||||
|
:src="item.coverUrl || '/static/nft/collection.png'"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view class="status-badge" :class="item.display_status === 1 ? 'badge-active' : 'badge-pending'">
|
||||||
|
<text class="status-text">{{ item.display_status === 1 ? '已展示' : '待展示' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="asset-info">
|
||||||
|
<text class="asset-name">{{ item.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import { getMyAssetsApi } from '@/utils/api.js';
|
||||||
|
import { getAssetCoverRealUrl } from '@/utils/assetImageHelper.js';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
// 替换模式:传入要被替换的藏品信息
|
||||||
|
replaceAsset: {
|
||||||
|
type: Object,
|
||||||
|
default: null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['close', 'select']);
|
||||||
|
|
||||||
|
// Tab配置
|
||||||
|
const tabs = [
|
||||||
|
{ key: 'regular', label: '原创' },
|
||||||
|
{ key: 'collection', label: '典藏' },
|
||||||
|
{ key: 'activity', label: '活动' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentType = ref('regular');
|
||||||
|
const loading = ref(false);
|
||||||
|
const assetsGroups = ref([]);
|
||||||
|
const animated = ref(false);
|
||||||
|
|
||||||
|
// 监听visible变化,控制动画
|
||||||
|
watch(() => props.visible, (newVal) => {
|
||||||
|
if (newVal) {
|
||||||
|
setTimeout(() => {
|
||||||
|
animated.value = true;
|
||||||
|
}, 50);
|
||||||
|
if (assetsGroups.value.length === 0) {
|
||||||
|
loadAssets();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
animated.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 判断当前类型是否有数据
|
||||||
|
const hasData = computed(() => {
|
||||||
|
const group = assetsGroups.value.find(g => g.type === currentType.value);
|
||||||
|
if (!group) return false;
|
||||||
|
if (currentType.value === 'regular') {
|
||||||
|
return group.grades && group.grades.some(g => g.items && g.items.length > 0);
|
||||||
|
}
|
||||||
|
return group.items && group.items.length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取原创藏品的等级分组
|
||||||
|
const regularGrades = computed(() => {
|
||||||
|
const group = assetsGroups.value.find(g => g.type === 'regular');
|
||||||
|
return group && group.grades ? group.grades : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取典藏藏品列表
|
||||||
|
const collectionItems = computed(() => {
|
||||||
|
const group = assetsGroups.value.find(g => g.type === 'collection');
|
||||||
|
return group && group.items ? group.items : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取活动藏品列表
|
||||||
|
const activityItems = computed(() => {
|
||||||
|
const group = assetsGroups.value.find(g => g.type === 'activity');
|
||||||
|
return group && group.items ? group.items : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// 当前类型的藏品列表
|
||||||
|
const currentItems = computed(() => {
|
||||||
|
if (currentType.value === 'collection') return collectionItems.value;
|
||||||
|
if (currentType.value === 'activity') return activityItems.value;
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// grade 中文转换
|
||||||
|
const gradeMap = { 1: '一', 2: '二', 3: '三', 4: '四', 5: '五' };
|
||||||
|
const formatGrade = (grade) => `等级${gradeMap[grade] || grade}`;
|
||||||
|
|
||||||
|
// 切换类型
|
||||||
|
const switchType = (type) => {
|
||||||
|
currentType.value = type;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载藏品列表
|
||||||
|
const loadAssets = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await getMyAssetsApi(1, 20);
|
||||||
|
if (response.code === 200 && response.data && response.data.data.groups) {
|
||||||
|
// 收集所有需要处理的藏品项
|
||||||
|
const allItems = [];
|
||||||
|
const itemRefs = [];
|
||||||
|
|
||||||
|
// 处理分组数据
|
||||||
|
const processedGroups = [];
|
||||||
|
for (const group of response.data.data.groups) {
|
||||||
|
const processedGroup = {
|
||||||
|
type: group.type,
|
||||||
|
category: group.category,
|
||||||
|
category_name: group.category_name,
|
||||||
|
total_count: group.total_count,
|
||||||
|
has_more: group.has_more,
|
||||||
|
grades: [],
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理 grades
|
||||||
|
if (group.grades) {
|
||||||
|
for (const grade of group.grades) {
|
||||||
|
const processedGrade = {
|
||||||
|
grade: grade.grade,
|
||||||
|
total_count: grade.total_count,
|
||||||
|
has_more: grade.has_more,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
|
for (const item of grade.items || []) {
|
||||||
|
allItems.push(item);
|
||||||
|
itemRefs.push({ type: 'grade', parent: processedGrade, grade: item.grade, category: null });
|
||||||
|
}
|
||||||
|
processedGroup.grades.push(processedGrade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 items
|
||||||
|
if (group.items) {
|
||||||
|
for (const item of group.items) {
|
||||||
|
allItems.push(item);
|
||||||
|
itemRefs.push({ type: 'item', parent: processedGroup, grade: null, category: item.category });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processedGroups.push(processedGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 并行处理所有封面URL
|
||||||
|
const coverUrlPromises = allItems.map(item => getAssetCoverRealUrl(item.cover_url_signed));
|
||||||
|
const coverUrls = await Promise.all(coverUrlPromises);
|
||||||
|
|
||||||
|
// 将处理好的封面URL填回数据结构
|
||||||
|
for (let i = 0; i < allItems.length; i++) {
|
||||||
|
const item = allItems[i];
|
||||||
|
const ref = itemRefs[i];
|
||||||
|
const processedItem = {
|
||||||
|
asset_id: item.asset_id,
|
||||||
|
name: item.name,
|
||||||
|
coverUrl: coverUrls[i],
|
||||||
|
display_status: item.display_status || 0,
|
||||||
|
like_count: item.like_count,
|
||||||
|
grade: ref.grade,
|
||||||
|
category: ref.category
|
||||||
|
};
|
||||||
|
ref.parent.items.push(processedItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsGroups.value = processedGroups;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取藏品列表失败:', error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 选择藏品
|
||||||
|
const selectAsset = (asset) => {
|
||||||
|
emit('select', {
|
||||||
|
asset,
|
||||||
|
isReplace: !!props.replaceAsset,
|
||||||
|
oldAsset: props.replaceAsset
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 关闭弹窗
|
||||||
|
const closeModal = () => {
|
||||||
|
animated.value = false;
|
||||||
|
setTimeout(() => {
|
||||||
|
emit('close');
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 弹窗滑动关闭
|
||||||
|
const touchStartY = ref(0);
|
||||||
|
const touchStartTime = ref(0);
|
||||||
|
|
||||||
|
const handleTouchStart = (e) => {
|
||||||
|
touchStartY.value = e.touches[0].clientY;
|
||||||
|
touchStartTime.value = Date.now();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (e) => {
|
||||||
|
const currentY = e.touches[0].clientY;
|
||||||
|
const deltaY = currentY - touchStartY.value;
|
||||||
|
if (deltaY > 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e) => {
|
||||||
|
const currentY = e.changedTouches[0].clientY;
|
||||||
|
const deltaY = currentY - touchStartY.value;
|
||||||
|
const deltaTime = Date.now() - touchStartTime.value;
|
||||||
|
|
||||||
|
if (deltaY > 0) {
|
||||||
|
const velocity = deltaY / deltaTime;
|
||||||
|
if (deltaY > 100 || velocity > 0.5) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.asset-selector-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 9998;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-selector-modal {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 80vh;
|
||||||
|
border-top-left-radius: 40rpx;
|
||||||
|
border-top-right-radius: 40rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease-out;
|
||||||
|
background: #0d0820;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-selector-modal.show {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-background {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 20rpx 30rpx 30rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-drag-area {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-drag-area:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-handle {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 4rpx;
|
||||||
|
margin: 0 auto 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #e6e6e6;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
margin-top: 30rpx;
|
||||||
|
font-family: 'ZaoZiGongFangJianHei-1', sans-serif;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-tabs {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 40rpx;
|
||||||
|
padding: 20rpx 30rpx;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-tab-item {
|
||||||
|
padding: 12rpx 30rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
border-bottom: 4rpx solid transparent;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-tab-item.active {
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-loading,
|
||||||
|
.modal-empty {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 200rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.empty-text {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-scroll {
|
||||||
|
height: calc(80vh - 200rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-scroll {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-section {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-header {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
padding-bottom: 10rpx;
|
||||||
|
border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grade-title {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-row {
|
||||||
|
width: 100%;
|
||||||
|
height: 288rpx;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-row::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-row {
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-row-content {
|
||||||
|
display: inline-block;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-left: 24rpx;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-item {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
vertical-align: top;
|
||||||
|
margin-right: 32rpx;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-image {
|
||||||
|
width: 192rpx;
|
||||||
|
height: 224rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 8rpx;
|
||||||
|
right: 8rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
padding: 4rpx 8rpx;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-active {
|
||||||
|
background: linear-gradient(135deg, #FFD700, #FFA500);
|
||||||
|
box-shadow: 0 0 12rpx rgba(255, 215, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-pending {
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 18rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-info {
|
||||||
|
padding: 12rpx 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.asset-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -27,7 +27,7 @@
|
|||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="exhibition-card"
|
class="exhibition-card"
|
||||||
:class="index % 2 === 0 ? 'card-tilt-left' : 'card-tilt-right'"
|
:class="index % 2 === 0 ? 'card-tilt-left' : 'card-tilt-right'"
|
||||||
@tap="goToAssetDetail(item.id)"
|
@tap="handleExhibitionCardTap(item, index)"
|
||||||
>
|
>
|
||||||
<image class="card-image" :src="item.cover_url || '/static/nft/placeholder.png'" mode="aspectFill"></image>
|
<image class="card-image" :src="item.cover_url || '/static/nft/placeholder.png'" mode="aspectFill"></image>
|
||||||
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
|
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
|
||||||
@ -42,14 +42,29 @@
|
|||||||
<view class="card-income-row" :class="index % 2 === 0 ? 'income-tilt-right' : 'income-tilt-left'">
|
<view class="card-income-row" :class="index % 2 === 0 ? 'income-tilt-right' : 'income-tilt-left'">
|
||||||
<image class="topfans-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
<image class="topfans-icon" src="/static/icon/crystal.png" mode="aspectFit"></image>
|
||||||
<view class="card-income-text-wrap">
|
<view class="card-income-text-wrap">
|
||||||
<text class="card-income-text">{{ item.rate || '0' }}/H</text>
|
<text class="card-income-text">{{ item.earnings || 0 }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 空状态占位 -->
|
<!-- 空状态占位:显示剩余空展位卡片 -->
|
||||||
<view v-if="exhibitionWorks.length === 0" class="empty-exhibition">
|
<view v-if="exhibitionWorks.length < 2" class="empty-exhibition">
|
||||||
<text class="empty-text">暂无在展作品</text>
|
<!-- 根据已展出数量决定显示几个空卡片 -->
|
||||||
|
<view v-if="exhibitionWorks.length === 0" class="empty-card empty-card-left" @tap="openAssetSelector(0)">
|
||||||
|
<image class="empty-cover" src="/static/nft/placeholder.png" mode="aspectFill"></image>
|
||||||
|
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
|
||||||
|
<view class="empty-add-btn">
|
||||||
|
<text class="empty-add-icon">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="empty-card empty-card-right" @tap="openAssetSelector(1)">
|
||||||
|
<image class="empty-cover" src="/static/nft/placeholder.png" mode="aspectFill"></image>
|
||||||
|
<image class="card-frame" src="/static/square/gerenzhongxincangpinkuang.png" mode="aspectFill"></image>
|
||||||
|
<view class="empty-add-btn">
|
||||||
|
<text class="empty-add-icon">+</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -110,19 +125,140 @@
|
|||||||
|
|
||||||
<!-- <view style="height: 60rpx;"></view> -->
|
<!-- <view style="height: 60rpx;"></view> -->
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 藏品选择器组件 -->
|
||||||
|
<AssetSelector
|
||||||
|
:visible="showAssetSelector"
|
||||||
|
:replace-asset="assetToReplace"
|
||||||
|
@close="closeAssetSelector"
|
||||||
|
@select="handleAssetSelect"
|
||||||
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
|
import { getMyExhibitedAssetsApi, getMyLikedAssetsApi, getMyGalleriesApi, placeAssetToGalleryApi } from '@/utils/api.js';
|
||||||
|
import AssetSelector from '../components/AssetSelector.vue';
|
||||||
|
import { onShow } from '@dcloudio/uni-app';
|
||||||
|
import { doubleTapLike } from '@/utils/likeHelper.js';
|
||||||
|
|
||||||
const goBack = () => {
|
const goBack = () => {
|
||||||
uni.navigateBack();
|
uni.navigateBack();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const goToCastlove = () => {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: '/pages/castlove/mall'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 藏品选择器相关
|
||||||
|
const showAssetSelector = ref(false);
|
||||||
|
const assetToReplace = ref(null);
|
||||||
|
const currentSlotIndex = ref(0);
|
||||||
|
|
||||||
|
const openAssetSelector = (slotIndex = 0) => {
|
||||||
|
currentSlotIndex.value = slotIndex;
|
||||||
|
showAssetSelector.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAssetSelector = () => {
|
||||||
|
showAssetSelector.value = false;
|
||||||
|
assetToReplace.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssetSelect = async ({ asset, isReplace, oldAsset }) => {
|
||||||
|
console.log('选中藏品:', asset, '替换模式:', isReplace, '槽位:', currentSlotIndex.value);
|
||||||
|
|
||||||
|
uni.showLoading({ title: '加载中...' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const galleriesRes = await getMyGalleriesApi();
|
||||||
|
console.log('展馆API返回:', galleriesRes);
|
||||||
|
|
||||||
|
const slots = galleriesRes.data?.slots || [];
|
||||||
|
const ownerId = galleriesRes.data?.gallery_owner_id;
|
||||||
|
console.log('槽位列表:', slots, 'ownerId:', ownerId);
|
||||||
|
|
||||||
|
// 过滤出可操作的槽位(can_operate: true)
|
||||||
|
const operatableSlots = slots.filter(s => s.can_operate);
|
||||||
|
console.log('可操作槽位:', operatableSlots);
|
||||||
|
|
||||||
|
if (operatableSlots.length === 0 || !ownerId) {
|
||||||
|
uni.showToast({ title: '暂无可用展馆', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetSlotId = null;
|
||||||
|
|
||||||
|
if (isReplace && oldAsset) {
|
||||||
|
const slot = slots.find(s => s.asset_id === oldAsset.asset_id);
|
||||||
|
targetSlotId = slot?.slot_id;
|
||||||
|
} else {
|
||||||
|
// 使用 currentSlotIndex 对应可操作槽位列表中的槽位
|
||||||
|
const targetSlot = operatableSlots[currentSlotIndex.value];
|
||||||
|
targetSlotId = targetSlot?.slot_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetSlotId) {
|
||||||
|
uni.showToast({ title: '展馆已满', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('调用展出接口: asset_id=', asset.asset_id, 'ownerId=', ownerId, 'slotId=', targetSlotId);
|
||||||
|
await placeAssetToGalleryApi(asset.asset_id, ownerId, targetSlotId);
|
||||||
|
|
||||||
|
uni.showToast({ title: '展出成功', icon: 'success' });
|
||||||
|
await loadExhibitedAssets();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('展出失败:', err);
|
||||||
|
uni.showToast({ title: err.message || '展出失败', icon: 'none' });
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const goToAssetDetail = (id) => {
|
const goToAssetDetail = (id) => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
uni.navigateTo({ url: `/pages/asset-detail/asset-detail?id=${id}` });
|
uni.navigateTo({ url: `/pages/asset-detail/asset-detail?asset_id=${id}` });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 双击点赞处理
|
||||||
|
const cardTapTimers = {};
|
||||||
|
|
||||||
|
const handleExhibitionCardTap = (item, index) => {
|
||||||
|
if (cardTapTimers[item.id]) {
|
||||||
|
// 第二次点击,双击点赞
|
||||||
|
clearTimeout(cardTapTimers[item.id]);
|
||||||
|
delete cardTapTimers[item.id];
|
||||||
|
doubleTapLike(item.id, (success) => {
|
||||||
|
if (success) {
|
||||||
|
// 更新在展作品的点赞数
|
||||||
|
exhibitionWorks.value[index].like_count = (exhibitionWorks.value[index].like_count || 0) + 1;
|
||||||
|
// 将作品添加到今日点赞列表
|
||||||
|
const likedItem = {
|
||||||
|
id: item.id,
|
||||||
|
cover_url: item.cover_url,
|
||||||
|
like_count: exhibitionWorks.value[index].like_count,
|
||||||
|
earnings: item.earnings,
|
||||||
|
name: item.name,
|
||||||
|
status_text: '潜力待挖',
|
||||||
|
score: exhibitionWorks.value[index].like_count,
|
||||||
|
reward: 0,
|
||||||
|
};
|
||||||
|
likedWorks.value.unshift(likedItem);
|
||||||
|
uni.showToast({ title: '点赞成功', icon: 'success' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 第一次点击,单击跳转
|
||||||
|
cardTapTimers[item.id] = setTimeout(() => {
|
||||||
|
delete cardTapTimers[item.id];
|
||||||
|
goToAssetDetail(item.id);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const rankIcons = [
|
const rankIcons = [
|
||||||
@ -142,24 +278,56 @@ const exhibitionWorks = ref([]);
|
|||||||
// 今日点赞作品列表
|
// 今日点赞作品列表
|
||||||
const likedWorks = ref([]);
|
const likedWorks = ref([]);
|
||||||
|
|
||||||
// 模拟数据(实际接入API时替换)
|
// 加载我的展出作品
|
||||||
const loadMockData = () => {
|
const loadExhibitedAssets = async () => {
|
||||||
exhibitionWorks.value = [
|
try {
|
||||||
{ id: '1', cover_url: '/static/sucai/image-08.png', owner_name: 'u585', like_count: 1234, rate: '0.7' },
|
const res = await getMyExhibitedAssetsApi(1, 20);
|
||||||
{ id: '2', cover_url: '/static/sucai/image-11.png', owner_name: 'u585', like_count: 856, rate: '0.6' },
|
if (res.data && res.data.items) {
|
||||||
];
|
exhibitionWorks.value = res.data.items.map(item => ({
|
||||||
|
id: item.asset_id,
|
||||||
|
cover_url: item.cover_url,
|
||||||
|
like_count: item.like_count,
|
||||||
|
earnings: item.earnings,
|
||||||
|
exhibited_at: item.exhibited_at,
|
||||||
|
expire_at: item.expire_at,
|
||||||
|
name: item.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载展出作品失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
likedWorks.value = [
|
// 加载我的点赞作品
|
||||||
{ id: '1', cover_url: '/static/sucai/image-03.png', status_text: '排名破100', score: 1354321, reward: 20 },
|
const loadLikedAssets = async () => {
|
||||||
{ id: '2', cover_url: '/static/sucai/image-04.png', status_text: '排名破300', score: 354321, reward: 17 },
|
try {
|
||||||
{ id: '3', cover_url: '/static/sucai/image-05.png', status_text: '热度飙升中', score: 14321, reward: 15 },
|
const res = await getMyLikedAssetsApi(1, 20);
|
||||||
{ id: '4', cover_url: '/static/sucai/image-06.png', status_text: '潜力待挖中', score: 321, reward: 8 },
|
if (res.data && res.data.items) {
|
||||||
{ id: '5', cover_url: '/static/sucai/image-07.png', status_text: '潜力待挖中', score: 89, reward: 3 },
|
likedWorks.value = res.data.items.map((item, index) => ({
|
||||||
];
|
id: item.asset_id,
|
||||||
|
cover_url: item.cover_url,
|
||||||
|
like_count: item.like_count,
|
||||||
|
earnings: item.earnings,
|
||||||
|
liked_at: item.liked_at,
|
||||||
|
name: item.name,
|
||||||
|
// 暂时用排名模拟状态文字
|
||||||
|
status_text: index < 3 ? '排名进榜' : '潜力待挖',
|
||||||
|
score: item.like_count,
|
||||||
|
reward: Math.floor(item.earnings || 0),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载点赞作品失败:', err);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadMockData();
|
loadExhibitedAssets();
|
||||||
|
loadLikedAssets();
|
||||||
|
});
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadLikedAssets();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -413,11 +581,61 @@ onMounted(() => {
|
|||||||
|
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
.empty-exhibition {
|
.empty-exhibition {
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 60rpx 0;
|
padding: 80rpx 0;
|
||||||
|
gap: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-card {
|
||||||
|
width: 248rpx;
|
||||||
|
height: 380rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-card-left {
|
||||||
|
transform: rotate(-4deg) translateY(10rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-card-right {
|
||||||
|
transform: rotate(4deg) translateY(10rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-cover {
|
||||||
|
width: 88%;
|
||||||
|
height: 92%;
|
||||||
|
border-radius: 80rpx;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
padding: 16rpx;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片内的添加按钮 */
|
||||||
|
.empty-add-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
background: linear-gradient(135deg, #F0E4B1 0%, #F08399 50%, #B94E73 100%);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(185, 78, 115, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-add-icon {
|
||||||
|
font-size: 48rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-text {
|
.empty-text {
|
||||||
|
|||||||
@ -30,6 +30,19 @@
|
|||||||
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
|
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 光波动画层 - 外层 -->
|
||||||
|
<view
|
||||||
|
class="wf-like-wave wf-like-wave-outer"
|
||||||
|
:class="{ 'wf-like-wave-active': likingMap[card.id] }"
|
||||||
|
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
|
||||||
|
/>
|
||||||
|
<!-- 光波动画层 - 内层 -->
|
||||||
|
<view
|
||||||
|
class="wf-like-wave wf-like-wave-inner"
|
||||||
|
:class="{ 'wf-like-wave-active': likingMap[card.id] }"
|
||||||
|
:style="{ width: card.w + 'px', height: card.h + 'px', borderRadius: card.radius + 'px' }"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 底部点赞数 -->
|
<!-- 底部点赞数 -->
|
||||||
<view class="wf-card-footer">
|
<view class="wf-card-footer">
|
||||||
<image class="wf-heart" src="/static/icon/heart-icon.png" mode="aspectFit" />
|
<image class="wf-heart" src="/static/icon/heart-icon.png" mode="aspectFit" />
|
||||||
@ -45,6 +58,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { getRandomUsersApi, getOssPresignedUrlApi } from '@/utils/api.js'
|
import { getRandomUsersApi, getOssPresignedUrlApi } from '@/utils/api.js'
|
||||||
|
import { doubleTapLike } from '@/utils/likeHelper.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
screenWidth: { type: Number, default: 375 },
|
screenWidth: { type: Number, default: 375 },
|
||||||
@ -69,6 +83,7 @@ const cards = ref([])
|
|||||||
const allUsers = ref([])
|
const allUsers = ref([])
|
||||||
const totalWidth = ref(0)
|
const totalWidth = ref(0)
|
||||||
const scrollLeft = ref(0)
|
const scrollLeft = ref(0)
|
||||||
|
const likingMap = ref({}) // 记录正在播放点赞动画的卡片ID
|
||||||
let currentScrollLeft = 0
|
let currentScrollLeft = 0
|
||||||
let idCounter = 0
|
let idCounter = 0
|
||||||
|
|
||||||
@ -133,7 +148,7 @@ const scrollStyle = computed(() => ({
|
|||||||
width: props.screenWidth + 'px',
|
width: props.screenWidth + 'px',
|
||||||
height: (props.screenHeight - props.bannerBottom) + 'px',
|
height: (props.screenHeight - props.bannerBottom) + 'px',
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
overflow: 'hidden',
|
// overflow: 'hidden',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// ========== 布局引擎 ==========
|
// ========== 布局引擎 ==========
|
||||||
@ -267,7 +282,7 @@ const cardStyle = (card) => ({
|
|||||||
width: card.w + 'px',
|
width: card.w + 'px',
|
||||||
height: card.h + 'px',
|
height: card.h + 'px',
|
||||||
borderRadius: card.radius + 'px',
|
borderRadius: card.radius + 'px',
|
||||||
overflow: 'hidden',
|
// overflow: 'hidden',
|
||||||
background: card.coverUrl ? 'transparent' : PALETTES[Math.abs(card.id) % PALETTES.length],
|
background: card.coverUrl ? 'transparent' : PALETTES[Math.abs(card.id) % PALETTES.length],
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -355,10 +370,37 @@ const appendMore = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 点击处理 ==========
|
// ========== 点击处理(双击点赞,单击跳转) ==========
|
||||||
|
const cardTapTimers = {};
|
||||||
|
|
||||||
const handleCardClick = (card) => {
|
const handleCardClick = (card) => {
|
||||||
if (userInteracting) return
|
|
||||||
emit('cardClick', card)
|
// if (cardTapTimers[card.id]) {
|
||||||
|
// 第二次点击,双击点赞
|
||||||
|
clearTimeout(cardTapTimers[card.id]);
|
||||||
|
delete cardTapTimers[card.id];
|
||||||
|
console.log('双击,触发动画');
|
||||||
|
|
||||||
|
// 触发动画
|
||||||
|
likingMap.value = { ...likingMap.value, [card.id]: true };
|
||||||
|
setTimeout(() => {
|
||||||
|
likingMap.value = { ...likingMap.value, [card.id]: false };
|
||||||
|
}, 600);
|
||||||
|
|
||||||
|
doubleTapLike(card.id, (success) => {
|
||||||
|
if (success) {
|
||||||
|
card.likes = (card.likes || 0) + 1;
|
||||||
|
uni.showToast({ title: '点赞成功', icon: 'success' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// } else {
|
||||||
|
// // 第一次点击,单击跳转
|
||||||
|
// console.log('单击,等待跳转');
|
||||||
|
// cardTapTimers[card.id] = setTimeout(() => {
|
||||||
|
// delete cardTapTimers[card.id];
|
||||||
|
// uni.navigateTo({ url: `/pages/asset-detail/asset-detail?asset_id=${card.id}` });
|
||||||
|
// }, 300);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== 初始化 ==========
|
// ========== 初始化 ==========
|
||||||
@ -399,6 +441,8 @@ watch(() => [props.screenHeight, props.bannerBottom], () => {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
transform-origin: top center;
|
transform-origin: top center;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wf-card:active {
|
.wf-card:active {
|
||||||
@ -454,4 +498,44 @@ watch(() => [props.screenHeight, props.bannerBottom], () => {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 光波动画 - 粉色发光边框扩散 */
|
||||||
|
.wf-like-wave {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 6rpx solid #ff6b9d;
|
||||||
|
box-shadow: 0 0 16rpx 4rpx #ff6b9d, inset 0 0 16rpx 4rpx rgba(255, 107, 157, 0.6);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-like-wave-outer {
|
||||||
|
top: -20rpx;
|
||||||
|
left: -20rpx;
|
||||||
|
border-width: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-like-wave-inner {
|
||||||
|
top: -4rpx;
|
||||||
|
left: -4rpx;
|
||||||
|
border-width: 6rpx;
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wf-like-wave-active {
|
||||||
|
animation: likeWave 1s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes likeWave {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.25);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -95,10 +95,7 @@ const bannerBottomPx = computed(() => Math.round(screenWidth.value / 750 * 716))
|
|||||||
|
|
||||||
// ========== Handlers ==========
|
// ========== Handlers ==========
|
||||||
const handleCardClick = (card) => {
|
const handleCardClick = (card) => {
|
||||||
if (!card.userId) return
|
// WaterfallGrid 组件内部已处理单击跳转和双击点赞
|
||||||
uni.navigateTo({
|
|
||||||
url: `/pages/exhibition/exhibition?target_uid=${card.userId}`,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleActivityClick = (item) => {
|
const handleActivityClick = (item) => {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 764 KiB After Width: | Height: | Size: 450 KiB |
BIN
frontend/static/sucai/image-17.png
Normal file
BIN
frontend/static/sucai/image-17.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/static/sucai/image-18.png
Normal file
BIN
frontend/static/sucai/image-18.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 572 KiB |
BIN
frontend/static/sucai/image-19.png
Normal file
BIN
frontend/static/sucai/image-19.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@ -1,6 +1,6 @@
|
|||||||
// API 基础配置
|
// API 基础配置
|
||||||
// const baseURL = 'http://101.132.250.62:8080'
|
const baseURL = 'http://101.132.250.62:8080'
|
||||||
const baseURL = 'http://192.168.110.60:8080'
|
// const baseURL = 'http://192.168.110.60:8080'
|
||||||
// const baseURL = 'http://localhost:8080'
|
// const baseURL = 'http://localhost:8080'
|
||||||
|
|
||||||
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
// 是否使用模拟数据(开发调试时设为 true,后端API准备好后改为 false)
|
||||||
@ -588,4 +588,22 @@ export function getUserExhibitedAssetsApi(userId, page = 1, pageSize = 20) {
|
|||||||
// url: `/api/v1/users/${userId}/exhibited-assets?page=${page}&page_size=${pageSize}`,
|
// url: `/api/v1/users/${userId}/exhibited-assets?page=${page}&page_size=${pageSize}`,
|
||||||
// method: 'GET'
|
// method: 'GET'
|
||||||
// })
|
// })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 我的作品统计接口 ====================
|
||||||
|
|
||||||
|
// 获取我展出的作品列表
|
||||||
|
export function getMyExhibitedAssetsApi(page = 1, pageSize = 20) {
|
||||||
|
return request({
|
||||||
|
url: `/api/v1/me/exhibited-assets?page=${page}&page_size=${pageSize}`,
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取我点赞的作品列表
|
||||||
|
export function getMyLikedAssetsApi(page = 1, pageSize = 20) {
|
||||||
|
return request({
|
||||||
|
url: `/api/v1/me/liked-assets?page=${page}&page_size=${pageSize}`,
|
||||||
|
method: 'GET'
|
||||||
|
})
|
||||||
}
|
}
|
||||||
88
frontend/utils/likeHelper.js
Normal file
88
frontend/utils/likeHelper.js
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
// 双击点赞工具函数
|
||||||
|
|
||||||
|
import { likeAssetApi } from './api.js';
|
||||||
|
|
||||||
|
// 存储已点赞的作品,key: assetId, value: 点赞日期 (YYYY-MM-DD)
|
||||||
|
const LIKE_STORAGE_KEY = 'liked_assets_daily';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取今天日期字符串
|
||||||
|
*/
|
||||||
|
function getTodayStr() {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查作品今天是否已点赞
|
||||||
|
*/
|
||||||
|
function hasLikedToday(assetId) {
|
||||||
|
try {
|
||||||
|
const storage = uni.getStorageSync(LIKE_STORAGE_KEY) || {};
|
||||||
|
const today = getTodayStr();
|
||||||
|
return storage[assetId] === today;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录作品今天已点赞
|
||||||
|
*/
|
||||||
|
function markLikedToday(assetId) {
|
||||||
|
try {
|
||||||
|
const storage = uni.getStorageSync(LIKE_STORAGE_KEY) || {};
|
||||||
|
storage[assetId] = getTodayStr();
|
||||||
|
uni.setStorageSync(LIKE_STORAGE_KEY, storage);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('存储点赞记录失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除过期的点赞记录(昨天及之前)
|
||||||
|
*/
|
||||||
|
function cleanExpiredLikes() {
|
||||||
|
try {
|
||||||
|
const storage = uni.getStorageSync(LIKE_STORAGE_KEY) || {};
|
||||||
|
const today = getTodayStr();
|
||||||
|
const keys = Object.keys(storage);
|
||||||
|
keys.forEach(key => {
|
||||||
|
if (storage[key] !== today) {
|
||||||
|
delete storage[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
uni.setStorageSync(LIKE_STORAGE_KEY, storage);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('清除过期点赞记录失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 双击点赞处理(每天每个作品只能点赞一次)
|
||||||
|
* @param {string|number} assetId - 藏品ID
|
||||||
|
* @param {Function} callback - 回调函数,参数为是否成功
|
||||||
|
*/
|
||||||
|
export function doubleTapLike(assetId, callback) {
|
||||||
|
// 清理过期记录
|
||||||
|
cleanExpiredLikes();
|
||||||
|
|
||||||
|
// 检查今天是否已点赞
|
||||||
|
if (hasLikedToday(assetId)) {
|
||||||
|
uni.showToast({ title: '今日已点赞', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
likeAssetApi(assetId).then(res => {
|
||||||
|
console.log('点赞成功', res);
|
||||||
|
markLikedToday(assetId);
|
||||||
|
if (callback) callback(true);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('点赞失败:', err);
|
||||||
|
// 如果是"已点赞"错误,也更新本地记录
|
||||||
|
if (err.message && err.message.includes('already liked')) {
|
||||||
|
markLikedToday(assetId);
|
||||||
|
}
|
||||||
|
if (callback) callback(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
BIN
squearbj.png
Normal file
BIN
squearbj.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 412 KiB |
Loading…
Reference in New Issue
Block a user