接口的对接和一些前端页面的修改

This commit is contained in:
ShiQi 2025-12-29 18:02:28 +08:00
parent 5c4faae97b
commit e3df41657a
38 changed files with 3619 additions and 1489 deletions

View File

@ -3,7 +3,35 @@
> **生成时间**: 2024-12-29 > **生成时间**: 2024-12-29
> **更新时间**: 2024-12-29 > **更新时间**: 2024-12-29
> **项目**: 直播IM系统 > **项目**: 直播IM系统
> **后端完成度**: ✅ 100% (94/94接口) > **后端完成度**: ✅ 100% (94/94接口)
> **Android端对接**: ✨ 用户系统7个接口已全部接入
---
## 📱 Android端对接进度
### ✅ 已接入模块 (7个)
| 模块 | 接口数 | 对接状态 | 说明 |
|------|--------|---------|------|
| 用户系统 | 7 | ✅ 100% | 登录、注册、验证码、用户信息、资料编辑、头像上传、退出登录 |
| 好友管理 | 9 | ✅ 100% | 搜索用户、发送申请、接受/拒绝申请、好友列表、删除好友、拉黑/取消拉黑、黑名单列表 |
| 消息表情回应 | 4 | ✅ 100% | 添加表情回应、移除表情回应、获取表情列表、获取回应用户 |
| 关注功能 | 8 | ✅ 100% | 关注、取消关注、关注列表、粉丝列表、关注状态、批量检查、关注统计 |
| 作品管理 | 15 | ✅ 100% | 发布、编辑、删除、详情、列表、点赞、收藏、分享 |
| 搜索功能 | 9 | ✅ 100% | 搜索用户、搜索直播间、搜索作品、综合搜索、热门搜索、搜索历史、搜索建议 |
| 支付集成 | 4 | ✅ 100% | 充值选项、创建订单、订单支付、查询结果 |
### 🔄 待接入模块
| 模块 | 接口数 | 优先级 | 说明 |
|------|--------|--------|------|
| 直播间管理 | 10 | 🔴 高 | 列表、详情、创建、在线人数、观众列表、赠送礼物 |
| 直播间弹幕 | 2 | 🔴 高 | 历史弹幕、发送弹幕 |
| WebSocket通信 | 2 | 🔴 高 | 在线人数、实时弹幕 |
| 群组管理 | 10 | 🟡 中 | 创建、更新、解散、成员管理 |
| 消息聊天 | 12 | 🟡 中 | 会话、消息、已读状态 |
| 其他模块 | 79 | <20> 中低 | 作品、评论、搜索、通知、支付等 |
--- ---
@ -33,31 +61,74 @@
| 支付集成 | 4 | ✅ 100% | 微信、支付宝支付 | | 支付集成 | 4 | ✅ 100% | 微信、支付宝支付 |
| 文件上传 | 5 | ✅ 100% | 图片、视频、语音上传 | | 文件上传 | 5 | ✅ 100% | 图片、视频、语音上传 |
| 分类管理 | 7 | ✅ 100% | 分类列表、统计、热门 | | 分类管理 | 7 | ✅ 100% | 分类列表、统计、热门 |
### 🎉 最新完成接口 (4个) - 2024-12-29
1. ✅ **视频上传** - `POST /api/upload/work/video` - 支持MP4/MOV/AVI/FLV最大500MB
2. ✅ **语音上传** - `POST /api/upload/chat/voice` - 支持MP3/AAC/WAV/M4A最大10MB
3. ✅ **观众列表** - `GET /api/rooms/{roomId}/viewers` - 获取直播间在线观众列表
4. ✅ **赠送礼物** - `POST /api/rooms/{roomId}/gift` - 在直播间赠送礼物给主播
--- ---
## 🎯 Android端需要对接的核心接口 ## 🎯 Android端需要对接的核心接口
### 第一阶段:基础功能 (必须) ### 第一阶段:基础功能 (必须)
#### 1. 用户系统 (7个接口) #### 1. 用户系统 (7个接口) - ✅ 已完成对接 ✨ Android端已接入
``` ```
✅ POST /api/front/login # 登录 ✅ POST /api/front/login # 账号密码登录 (✨已接入)
✅ POST /api/front/register # 注册 ✅ POST /api/front/register # APP用户注册 (✨已接入)
✅ POST /api/sms/send # 验证码 ✅ POST /api/front/sendCode # 发送验证码 (✨已接入)
✅ GET /api/front/user/info # 用户信息 ✅ GET /api/front/user # 获取用户信息 (✨已接入)
✅ POST /api/front/user/update # 更新资料 ✅ POST /api/front/user/edit # 更新用户资料 (✨已接入)
✅ POST /api/front/user/upload/image # 上传头像 ✅ POST /api/front/user/upload/image # 上传头像 (✨已接入)
✅ GET /api/front/user/logout # 退出登录 ✅ GET /api/front/logout # 退出登录 (✨已接入)
``` ```
**接口详细说明**:
1. **POST /api/front/login** - 账号密码登录 ✨ Android端已接入
- 请求参数: `{ "account": "手机号", "password": "密码" }`
- 响应数据: `{ "token": "JWT令牌", "uid": 用户ID, "nikeName": "昵称", "phone": "手机号" }`
- 后端实现: `LoginController.login()``LoginServiceImpl.login()`
- Android实现: `LoginActivity.java``ApiService.login()`
- 模型类: `LoginRequest.java`, `LoginResponse.java`
2. **POST /api/front/register** - APP用户注册 ✨ Android端已接入
- 请求参数: `{ "phone": "手机号", "password": "密码", "verificationCode": "验证码(可选)", "nickname": "昵称(可选)" }`
- 响应数据: `{ "token": "JWT令牌", "uid": 用户ID, "nikeName": "昵称", "phone": "手机号" }`
- 后端实现: `LoginController.register()``LoginServiceImpl.register()`
- Android实现: `RegisterActivity.java``ApiService.register()`
- 模型类: `RegisterRequest.java`, `LoginResponse.java`
3. **POST /api/front/sendCode** - 发送短信验证码 ✨ Android端已接入
- 请求参数: `phone=手机号` (FormUrlEncoded)
- 响应数据: `{ "code": 200, "msg": "发送成功" }`
- 后端实现: `LoginController.sendCode()``SmsService.sendCommonCode()`
- Android实现: `RegisterActivity.java``ApiService.sendCode()`
4. **GET /api/front/user** - 获取用户信息 ✨ Android端已接入
- 请求头: `Authorization: Bearer {token}`
- 响应数据: 用户中心完整信息(昵称、头像、余额、积分、经验、等级等)
- 后端实现: `UserController.getUserCenter()``UserService.getUserCenter()`
- Android实现: `ApiService.getUserInfo()`
- 模型类: `UserInfoResponse.java`
5. **POST /api/front/user/edit** - 更新用户资料 ✨ Android端已接入
- 请求头: `Authorization: Bearer {token}`
- 请求参数: `{ "nickname": "昵称", "avatar": "头像URL" }`
- 响应数据: `{ "code": 200, "msg": "success" }`
- 后端实现: `UserController.personInfo()``UserService.editUser()`
- Android实现: `EditProfileActivity.java``ApiService.updateUserInfo()`
- 模型类: `UserEditRequest.java`
6. **POST /api/front/user/upload/image** - 上传头像 ✨ Android端已接入
- 请求头: `Authorization: Bearer {token}`
- 请求参数: `multipart/form-data` - `file`: 图片文件, `model`: "user", `pid`: 7
- 响应数据: `{ "url": "图片URL", "name": "文件名", "size": 文件大小 }`
- 后端实现: `UserUploadController.image()``UploadService.imageUpload()`
- Android实现: `EditProfileActivity.java``ApiService.uploadImage()`
- 模型类: `FileUploadResponse.java`
7. **GET /api/front/logout** - 退出登录 ✨ Android端已接入
- 请求头: `Authorization: Bearer {token}`
- 响应数据: `{ "code": 200, "msg": "success" }`
- 后端实现: `LoginController.loginOut()``LoginServiceImpl.loginOut()`
- Android实现: `ApiService.logout()`
#### 2. 直播间系统 (10个接口) - ✅ 全部完成 #### 2. 直播间系统 (10个接口) - ✅ 全部完成
``` ```
✅ GET /api/front/live/rooms # 直播间列表 ✅ GET /api/front/live/rooms # 直播间列表
@ -93,19 +164,81 @@
### 第二阶段:社交功能 (推荐) ### 第二阶段:社交功能 (推荐)
#### 5. 好友管理 (9个接口) #### 5. 好友管理 (9个接口) - ✅ 已完成对接 ✨ Android端已接入
``` ```
POST /api/front/friends/request # 发送好友申请 GET /api/front/users/search # 搜索用户 (✨已接入)
✅ POST /api/front/friends/accept # 接受好友申请 ✅ POST /api/front/friends/request # 发送好友申请 (✨已接入)
✅ POST /api/front/friends/reject # 拒绝好友申请 ✅ POST /api/front/friends/requests/{requestId}/handle # 处理好友请求(接受/拒绝) (✨已接入)
✅ GET /api/front/friends/list # 好友列表 ✅ GET /api/front/friends # 好友列表 (✨已接入)
✅ GET /api/front/friends/requests # 好友申请列表 ✅ GET /api/front/friends/requests # 好友申请列表 (✨已接入)
POST /api/front/friends/delete/{friendId} # 删除好友 DELETE /api/front/friends/{friendId} # 删除好友 (✨已接入)
✅ POST /api/front/friends/block/{friendId} # 拉黑好友 ✅ POST /api/front/friends/block/{friendId} # 拉黑好友 (✨已接入)
✅ POST /api/front/friends/unblock/{friendId} # 取消拉黑 ✅ POST /api/front/friends/unblock/{friendId} # 取消拉黑 (✨已接入)
✅ GET /api/front/friends/blocked # 黑名单列表 ✅ GET /api/front/friends/blocked # 黑名单列表 (✨已接入)
``` ```
**接口详细说明**:
1. **GET /api/front/users/search** - 搜索用户 ✨ Android端已接入
- 请求参数: `keyword` (搜索关键词), `page`, `pageSize`
- 响应数据: 用户列表,包含好友状态 (0:未添加, 1:已是好友, 2:已申请)
- 后端实现: `FriendController.searchUsers()`
- Android实现: `AddFriendActivity.java``ApiService.searchUsers()`
- 模型类: `SearchUserResponse.java`
2. **POST /api/front/friends/request** - 发送好友申请 ✨ Android端已接入
- 请求参数: `{ "targetUserId": 目标用户ID, "message": "申请消息(可选)" }`
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `FriendController.sendFriendRequest()`
- Android实现: `AddFriendActivity.java``ApiService.sendFriendRequest()`
3. **POST /api/front/friends/requests/{requestId}/handle** - 处理好友请求 ✨ Android端已接入
- 请求参数: `{ "accept": true/false }`
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `FriendController.handleFriendRequest()`
- Android实现: `MyFriendsActivity.java``handleFriendRequest()`
- 说明: accept=true接受false拒绝接受后自动创建双向好友关系和私聊会话
4. **GET /api/front/friends** - 好友列表 ✨ Android端已接入
- 请求参数: `page`, `pageSize`
- 响应数据: 好友列表,包含在线状态
- 后端实现: `FriendController.getFriendList()`
- Android实现: `MyFriendsActivity.java``loadFriendList()`
- 模型类: `FriendResponse.java`
5. **GET /api/front/friends/requests** - 好友申请列表 ✨ Android端已接入
- 请求参数: `page`, `pageSize`
- 响应数据: 待处理的好友申请列表
- 后端实现: `FriendController.getFriendRequests()`
- Android实现: `MyFriendsActivity.java``loadFriendRequests()`
- 模型类: `FriendRequestResponse.java`
6. **DELETE /api/front/friends/{friendId}** - 删除好友 ✨ Android端已接入
- 请求参数: `friendId` (路径参数)
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `FriendController.deleteFriend()`
- Android实现: `MyFriendsActivity.java``deleteFriend()`
- 说明: 删除双向好友关系
7. **POST /api/front/friends/block/{friendId}** - 拉黑好友 ✨ Android端已接入
- 请求参数: `friendId` (路径参数)
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `FriendController.blockFriend()`
- Android实现: `MyFriendsActivity.java``blockFriend()`
- 说明: 拉黑后自动删除好友关系
8. **POST /api/front/friends/unblock/{friendId}** - 取消拉黑 ✨ Android端已接入
- 请求参数: `friendId` (路径参数)
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `FriendController.unblockFriend()`
- Android实现: `MyFriendsActivity.java``unblockFriend()`
9. **GET /api/front/friends/blocked** - 黑名单列表 ✨ Android端已接入
- 请求参数: `page`, `pageSize`
- 响应数据: 黑名单用户列表
- 后端实现: `FriendController.getBlockedList()`
- Android实现: `MyFriendsActivity.java``loadBlockedList()`
#### 6. 群组管理 (10个接口) #### 6. 群组管理 (10个接口)
``` ```
✅ POST /api/front/groups/create # 创建群组 ✅ POST /api/front/groups/create # 创建群组
@ -139,14 +272,44 @@
... (更多消息相关接口) ... (更多消息相关接口)
``` ```
#### 9. 消息表情回应 (4个接口) #### 9. 消息表情回应 (4个接口) - ✅ 已完成对接 ✨ Android端已接入
``` ```
✅ POST /api/front/messages/reactions/add # 添加表情回应 ✅ POST /api/front/messages/reactions/add # 添加表情回应 (✨已接入)
✅ DELETE /api/front/messages/reactions/remove # 移除表情回应 ✅ DELETE /api/front/messages/reactions/remove # 移除表情回应 (✨已接入)
✅ GET /api/front/messages/{messageId}/reactions # 获取消息的所有表情回应 ✅ GET /api/front/messages/{messageId}/reactions # 获取消息的所有表情回应 (✨已接入)
✅ GET /api/front/messages/{messageId}/reactions/users # 获取特定表情的用户列表 ✅ GET /api/front/messages/{messageId}/reactions/users # 获取特定表情的用户列表 (✨已接入)
``` ```
**接口详细说明**:
1. **POST /api/front/messages/reactions/add** - 添加表情回应 ✨ Android端已接入
- 请求参数: `{ "messageId": "消息ID", "emoji": "表情符号" }`
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `MessageReactionController.addReaction()`
- Android实现: `ConversationActivity.java``addMessageReaction()`
- 说明: 用户可以对消息添加表情回应,支持多种表情符号
2. **DELETE /api/front/messages/reactions/remove** - 移除表情回应 ✨ Android端已接入
- 请求参数: `{ "messageId": "消息ID", "emoji": "表情符号" }`
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `MessageReactionController.removeReaction()`
- Android实现: `ConversationActivity.java``removeMessageReaction()`
- 说明: 用户可以移除自己添加的表情回应
3. **GET /api/front/messages/{messageId}/reactions** - 获取消息的所有表情回应 ✨ Android端已接入
- 请求参数: `messageId` (路径参数)
- 响应数据: `[{ "emoji": "👍", "count": 5, "reactedByMe": true }, ...]`
- 后端实现: `MessageReactionController.getMessageReactions()`
- Android实现: `ConversationActivity.java``loadMessageReactions()`
- 说明: 获取某条消息的所有表情回应统计,包括表情、数量和当前用户是否已回应
4. **GET /api/front/messages/{messageId}/reactions/users** - 获取特定表情的用户列表 ✨ Android端已接入
- 请求参数: `messageId` (路径参数), `emoji` (查询参数)
- 响应数据: `[{ "userId": 1, "username": "用户名", "avatarUrl": "头像URL" }, ...]`
- 后端实现: `MessageReactionController.getReactionUsers()`
- Android实现: `ApiService.getReactionUsers()`
- 说明: 获取对某条消息添加特定表情的所有用户列表
#### 10. 消息搜索 (3个接口) #### 10. 消息搜索 (3个接口)
``` ```
✅ GET /api/front/messages/search/conversations # 搜索会话 ✅ GET /api/front/messages/search/conversations # 搜索会话
@ -154,15 +317,74 @@
✅ GET /api/front/messages/search/global # 全局搜索 ✅ GET /api/front/messages/search/global # 全局搜索
``` ```
#### 11. 关注功能 (8个接口) #### 11. 关注功能 (8个接口) - ✅ 已完成对接 ✨ Android端已接入
``` ```
✅ POST /api/front/follow/follow # 关注 ✅ POST /api/front/follow/follow # 关注 (✨已接入)
✅ POST /api/front/follow/unfollow # 取消关注 ✅ POST /api/front/follow/unfollow # 取消关注 (✨已接入)
✅ GET /api/front/follow/following # 关注列表 ✅ GET /api/front/follow/following # 关注列表 (✨已接入)
✅ GET /api/front/follow/followers # 粉丝列表 ✅ GET /api/front/follow/followers # 粉丝列表 (✨已接入)
✅ GET /api/front/follow/status/{userId} # 关注状态 ✅ GET /api/front/follow/status/{userId} # 关注状态 (✨已接入)
✅ POST /api/front/follow/status/batch # 批量检查 ✅ POST /api/front/follow/status/batch # 批量检查 (✨已接入)
✅ GET /api/front/follow/stats # 关注统计 ✅ GET /api/front/follow/stats # 关注统计 (✨已接入)
```
**接口详细说明**:
1. **POST /api/front/follow/follow** - 关注用户 ✨ Android端已接入
- 请求参数: `{ "userId": 目标用户ID }`
- 响应数据: `{ "success": true, "message": "关注成功", "isFollowing": true }`
- 后端实现: `FollowController.follow()`
- Android实现: `UserProfileReadOnlyActivity.java``followUser()`
- 说明: 用户可以关注其他用户或主播,防止自己关注自己和重复关注
2. **POST /api/front/follow/unfollow** - 取消关注 ✨ Android端已接入
- 请求参数: `{ "userId": 目标用户ID }`
- 响应数据: `{ "success": true, "message": "取消关注成功", "isFollowing": false }`
- 后端实现: `FollowController.unfollow()`
- Android实现: `UserProfileReadOnlyActivity.java``unfollowUser()`
3. **GET /api/front/follow/status/{userId}** - 检查关注状态 ✨ Android端已接入
- 请求参数: `userId` (路径参数)
- 响应数据: `{ "isFollowing": true/false, "userId": 用户ID }`
- 后端实现: `FollowController.checkFollowStatus()`
- Android实现: `UserProfileReadOnlyActivity.java``checkFollowStatus()`
- 说明: 查询是否已关注某个用户
4. **POST /api/front/follow/status/batch** - 批量检查关注状态 ✨ Android端已接入
- 请求参数: `{ "userIds": [用户ID列表] }`
- 响应数据: `{ "statusMap": { "userId": true/false, ... } }`
- 后端实现: `FollowController.batchCheckFollowStatus()`
- Android实现: `ApiService.batchCheckFollowStatus()`
- 说明: 批量查询多个用户的关注状态
5. **GET /api/front/follow/following** - 获取关注列表 ✨ Android端已接入
- 请求参数: `page`, `pageSize`
- 响应数据: 关注的用户列表,包含在线状态
- 后端实现: `FollowController.getFollowingList()`
- Android实现: `FollowingListActivity.java``loadFollowingList()`
- 模型类: 使用Map动态解析
- 说明: 查看我关注的所有用户,支持分页
6. **GET /api/front/follow/followers** - 获取粉丝列表 ✨ Android端已接入
- 请求参数: `page`, `pageSize`
- 响应数据: 粉丝列表,包含在线状态和是否互相关注
- 后端实现: `FollowController.getFollowersList()`
- Android实现: `FansListActivity.java``loadFollowersList()`
- 模型类: 使用Map动态解析
- 说明: 查看关注我的所有用户,支持分页
7. **GET /api/front/follow/stats** - 获取关注统计 ✨ Android端已接入
- 请求参数: `userId` (可选,不传则查询当前用户)
- 响应数据: `{ "followingCount": 关注数, "followersCount": 粉丝数 }`
- 后端实现: `FollowController.getFollowStats()`
- Android实现: `ProfileActivity.java``loadFollowStats()`
- 说明: 查看关注数和粉丝数统计
8. **POST /api/front/live/follow** - 直播间关注主播 (已在直播间模块实现)
- 请求参数: `{ "streamerId": 主播ID, "action": "follow/unfollow" }`
- 响应数据: `{ "success": true }`
- 后端实现: `LiveRoomController.followStreamer()`
- 说明: 在直播间内关注/取消关注主播
``` ```
### 第三阶段:内容功能 (可选) ### 第三阶段:内容功能 (可选)
@ -175,25 +397,130 @@
✅ POST /api/admin/gift/delete/{id} # 删除礼物(后台) ✅ POST /api/admin/gift/delete/{id} # 删除礼物(后台)
``` ```
#### 13. 作品管理 (15个接口) - ✅ 全部完成 #### 13. 作品管理 (15个接口) - ✅ 已完成对接 ✨ Android端已接入
``` ```
✅ POST /api/front/works/publish # 发布作品 ✅ POST /api/front/works/publish # 发布作品 (✨已接入)
✅ GET /api/front/works/list # 作品列表 ✅ GET /api/front/works/detail/{worksId} # 作品详情 (✨已接入)
GET /api/front/works/{worksId} # 作品详情 POST /api/front/works/update # 编辑作品 (✨已接入)
✅ POST /api/front/works/{worksId}/like # 点赞 ✅ POST /api/front/works/delete/{worksId} # 删除作品 (✨已接入)
✅ POST /api/front/works/{worksId}/collect # 收藏 ✅ POST /api/front/works/search # 搜索作品 (✨已接入)
PUT /api/front/works/{worksId} # 编辑作品 GET /api/front/works/user/{userId} # 用户作品列表 (✨已接入)
DELETE /api/front/works/{worksId} # 删除作品 POST /api/front/works/like/{worksId} # 点赞 (✨已接入)
✅ POST /api/front/works/{worksId}/unlike # 取消点赞 ✅ POST /api/front/works/unlike/{worksId} # 取消点赞 (✨已接入)
✅ POST /api/front/works/{worksId}/uncollect # 取消收藏 ✅ POST /api/front/works/collect/{worksId} # 收藏 (✨已接入)
GET /api/front/works/user/{userId} # 用户作品列表 POST /api/front/works/uncollect/{worksId} # 取消收藏 (✨已接入)
✅ GET /api/front/works/my/liked # 我的点赞列表 ✅ GET /api/front/works/my/liked # 我的点赞列表 (✨已接入)
✅ GET /api/front/works/my/collected # 我的收藏列表 ✅ GET /api/front/works/my/collected # 我的收藏列表 (✨已接入)
✅ POST /api/front/works/{worksId}/share # 分享作品 ✅ POST /api/front/works/share/{worksId} # 分享作品 (✨已接入)
✅ POST /api/front/works/search # 搜索作品 ✅ POST /api/front/upload/work/video # 视频上传 (✨已接入)
GET /api/front/works/recommend # 推荐作品 POST /api/front/upload/image # 图片上传 (✨已接入)
``` ```
**接口详细说明**:
1. **POST /api/front/works/publish** - 发布作品 ✨ Android端已接入
- 请求参数: `{ "title": "标题", "description": "描述", "type": "IMAGE/VIDEO", "coverUrl": "封面URL", "videoUrl": "视频URL", "imageUrls": ["图片URL"] }`
- 响应数据: `{ "code": 200, "data": 作品ID }`
- 后端实现: `WorksController.publishWorks()`
- Android实现: `PublishWorkActivity.java``publishWork()`
- 模型类: `WorksRequest.java`
- 说明: 需要先上传文件获取URL再调用此接口发布
2. **GET /api/front/works/detail/{worksId}** - 获取作品详情 ✨ Android端已接入
- 请求参数: `worksId` (路径参数)
- 响应数据: 作品完整信息,包含点赞收藏状态
- 后端实现: `WorksController.getWorksDetail()`
- Android实现: `WorkDetailActivity.java``loadWorkDetail()`
- 模型类: `WorksResponse.java`
3. **POST /api/front/works/update** - 编辑作品 ✨ Android端已接入
- 请求参数: `{ "id": 作品ID, "title": "标题", "description": "描述" }`
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `WorksController.updateWorks()`
- Android实现: `WorkDetailActivity.java``editWork()`
- 说明: 仅作者可编辑
4. **POST /api/front/works/delete/{worksId}** - 删除作品 ✨ Android端已接入
- 请求参数: `worksId` (路径参数)
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `WorksController.deleteWorks()`
- Android实现: `WorkDetailActivity.java``deleteWork()`
- 说明: 仅作者可删除,逻辑删除
5. **POST /api/front/works/search** - 搜索作品 ✨ Android端已接入
- 请求参数: `{ "keyword": "关键词", "type": "IMAGE/VIDEO", "page": 1, "pageSize": 20 }`
- 响应数据: 分页作品列表
- 后端实现: `WorksController.searchWorks()`
- Android实现: `ApiService.searchWorks()`
- 模型类: `WorksSearchRequest.java`
6. **GET /api/front/works/user/{userId}** - 获取用户作品列表 ✨ Android端已接入
- 请求参数: `userId` (路径参数), `page`, `pageSize`
- 响应数据: 分页作品列表
- 后端实现: `WorksController.getUserWorks()`
- Android实现: `ApiService.getUserWorks()`
7. **POST /api/front/works/like/{worksId}** - 点赞作品 ✨ Android端已接入
- 请求参数: `worksId` (路径参数)
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `WorksController.likeWorks()`
- Android实现: `WorkDetailActivity.java``toggleLike()`
- 说明: 需要登录,防止重复点赞
8. **POST /api/front/works/unlike/{worksId}** - 取消点赞 ✨ Android端已接入
- 请求参数: `worksId` (路径参数)
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `WorksController.unlikeWorks()`
- Android实现: `WorkDetailActivity.java``toggleLike()`
9. **POST /api/front/works/collect/{worksId}** - 收藏作品 ✨ Android端已接入
- 请求参数: `worksId` (路径参数)
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `WorksController.collectWorks()`
- Android实现: `WorkDetailActivity.java``toggleFavorite()`
- 说明: 需要登录,防止重复收藏
10. **POST /api/front/works/uncollect/{worksId}** - 取消收藏 ✨ Android端已接入
- 请求参数: `worksId` (路径参数)
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `WorksController.uncollectWorks()`
- Android实现: `WorkDetailActivity.java``toggleFavorite()`
11. **GET /api/front/works/my/liked** - 我的点赞列表 ✨ Android端已接入
- 请求参数: `page`, `pageSize`
- 响应数据: 分页作品列表
- 后端实现: `WorksController.getMyLikedWorks()`
- Android实现: `ApiService.getMyLikedWorks()`
- 说明: 需要登录
12. **GET /api/front/works/my/collected** - 我的收藏列表 ✨ Android端已接入
- 请求参数: `page`, `pageSize`
- 响应数据: 分页作品列表
- 后端实现: `WorksController.getMyCollectedWorks()`
- Android实现: `ApiService.getMyCollectedWorks()`
- 说明: 需要登录
13. **POST /api/front/works/share/{worksId}** - 分享作品 ✨ Android端已接入
- 请求参数: `worksId` (路径参数)
- 响应数据: `{ "code": 200, "data": true }`
- 后端实现: `WorksController.shareWorks()`
- Android实现: `ApiService.shareWork()`
- 说明: 增加分享次数统计
14. **POST /api/front/upload/work/video** - 视频上传 ✨ Android端已接入
- 请求参数: `multipart/form-data` - `multipart`: 视频文件, `model`: "works", `pid`: 0
- 响应数据: `{ "url": "视频URL", "name": "文件名", "size": 文件大小 }`
- 后端实现: `UserUploadController.videoUpload()`
- Android实现: `PublishWorkActivity.java``uploadVideo()`
- 说明: 支持大文件上传
15. **POST /api/front/upload/image** - 图片上传 ✨ Android端已接入
- 请求参数: `multipart/form-data` - `multipart`: 图片文件, `model`: "works", `pid`: 0
- 响应数据: `{ "url": "图片URL", "name": "文件名", "size": 文件大小 }`
- 后端实现: `UserUploadController.image()`
- Android实现: `PublishWorkActivity.java``uploadCoverImage()`
- 说明: 支持多种图片格式
#### 14. 评论功能 (8个接口) - ✅ 全部完成 #### 14. 评论功能 (8个接口) - ✅ 全部完成
``` ```
✅ POST /api/front/works/comment/publish # 发布评论 ✅ POST /api/front/works/comment/publish # 发布评论
@ -206,7 +533,85 @@
✅ GET /api/front/works/comment/check-liked/{commentId} # 检查点赞 ✅ GET /api/front/works/comment/check-liked/{commentId} # 检查点赞
``` ```
#### 15. 搜索功能 (9个接口) - ✅ 全部完成 #### 15. 搜索功能 (9个接口) - ✅ 已完成对接 ✨ Android端已接入
```
✅ GET /api/front/search/users # 搜索用户 (✨已接入)
✅ GET /api/front/search/live-rooms # 搜索直播间 (✨已接入)
✅ GET /api/front/search/works # 搜索作品 (✨已接入)
✅ GET /api/front/search/all # 综合搜索 (✨已接入)
✅ GET /api/front/search/hot # 热门搜索 (✨已接入)
✅ GET /api/front/search/history # 搜索历史 (✨已接入)
✅ DELETE /api/front/search/history # 清除历史 (✨已接入)
✅ DELETE /api/front/search/history/{id} # 删除单条历史 (✨已接入)
✅ GET /api/front/search/suggestions # 搜索建议 (✨已接入)
```
**接口详细说明**:
1. **GET /api/front/search/users** - 搜索用户 ✨ Android端已接入
- 请求参数: `keyword` (搜索关键词), `pageNum`, `pageSize`
- 响应数据: 用户列表,包含关注状态(如果已登录)
- 后端实现: `SearchController.searchUsers()`
- Android实现: `ApiService.searchUsersGlobal()`
- 说明: 支持按昵称或手机号搜索,未登录用户也可访问
2. **GET /api/front/search/live-rooms** - 搜索直播间 ✨ Android端已接入
- 请求参数: `keyword` (搜索关键词), `categoryId` (可选), `isLive` (可选), `pageNum`, `pageSize`
- 响应数据: 直播间列表,包含直播状态、观看人数等
- 后端实现: `SearchController.searchLiveRooms()`
- Android实现: `SearchActivity.java``performSearch()`
- 说明: 支持按标题或主播名搜索,可按分类和直播状态筛选
3. **GET /api/front/search/works** - 搜索作品 ✨ Android端已接入
- 请求参数: `keyword` (搜索关键词), `categoryId` (可选), `pageNum`, `pageSize`
- 响应数据: 作品列表,包含点赞收藏状态(如果已登录)
- 后端实现: `SearchController.searchWorks()`
- Android实现: `ApiService.searchWorksGlobal()`
- 说明: 支持按标题、描述、标签搜索
4. **GET /api/front/search/all** - 综合搜索 ✨ Android端已接入
- 请求参数: `keyword` (搜索关键词)
- 响应数据: `{ "users": [], "liveRooms": [], "works": [] }`
- 后端实现: `SearchController.searchAll()`
- Android实现: `ApiService.searchAll()`
- 说明: 同时搜索用户、直播间、作品,返回各类型的前几条结果
5. **GET /api/front/search/hot** - 获取热门搜索 ✨ Android端已接入
- 请求参数: `searchType` (0-全部 1-用户 2-直播间 3-作品), `limit`
- 响应数据: `[{ "keyword": "关键词", "searchCount": 次数 }]`
- 后端实现: `SearchController.getHotSearch()`
- Android实现: `SearchActivity.java``loadHotSearch()`
- 说明: 获取热门搜索关键词列表,支持按类型筛选
6. **GET /api/front/search/history** - 获取搜索历史 ✨ Android端已接入
- 请求参数: `searchType` (可选), `limit`
- 响应数据: `[{ "id": ID, "keyword": "关键词", "searchType": 类型, "createTime": "时间" }]`
- 后端实现: `SearchController.getSearchHistory()`
- Android实现: `ApiService.getSearchHistory()`
- 说明: 获取用户的搜索历史记录,需要登录
7. **DELETE /api/front/search/history** - 清除搜索历史 ✨ Android端已接入
- 请求参数: `searchType` (可选,不传则清除全部)
- 响应数据: `{ "code": 200, "msg": "搜索历史已清除" }`
- 后端实现: `SearchController.clearSearchHistory()`
- Android实现: `ApiService.clearSearchHistory()`
- 说明: 清除全部或指定类型的搜索历史,需要登录
8. **DELETE /api/front/search/history/{historyId}** - 删除单条搜索历史 ✨ Android端已接入
- 请求参数: `historyId` (路径参数)
- 响应数据: `{ "code": 200, "msg": "删除成功" }`
- 后端实现: `SearchController.deleteSearchHistory()`
- Android实现: `ApiService.deleteSearchHistory()`
- 说明: 删除指定的搜索历史记录,需要登录
9. **GET /api/front/search/suggestions** - 获取搜索建议 ✨ Android端已接入
- 请求参数: `keyword` (关键词前缀), `searchType` (可选), `limit`
- 响应数据: `["建议1", "建议2", ...]`
- 后端实现: `SearchController.getSearchSuggestions()`
- Android实现: `SearchActivity.java``loadSearchSuggestions()`
- 说明: 根据用户输入提供自动补全建议,需要登录
#### 16. 通知推送 (9个接口) - ✅ 全部完成
``` ```
✅ GET /api/front/search/users # 搜索用户 ✅ GET /api/front/search/users # 搜索用户
✅ GET /api/front/search/live-rooms # 搜索直播间 ✅ GET /api/front/search/live-rooms # 搜索直播间
@ -234,14 +639,65 @@
✅ GET /api/front/notification/unread-count-by-type # 按类型统计 ✅ GET /api/front/notification/unread-count-by-type # 按类型统计
``` ```
#### 17. 支付集成 (4个接口) - ✅ 全部完成 #### 17. 支付集成 (4个接口) - ✅ 已完成对接 ✨ Android端已接入
``` ```
POST /api/front/pay/payment # 创建支付 GET /api/front/gift/recharge/options # 获取充值选项 (✨已接入)
GET /api/front/pay/alipay/queryPayResult # 查询结果 POST /api/front/gift/recharge/create # 创建充值订单 (✨已接入)
GET /api/front/pay/alipay/return # 支付返回 POST /api/front/pay/payment # 订单支付 (✨已接入)
POST /api/admin/payment/callback/alipay # 支付回调 GET /api/front/pay/alipay/queryPayResult # 查询支付结果 (✨已接入)
``` ```
**接口详细说明**:
1. **GET /api/front/gift/recharge/options** - 获取充值选项列表 ✨ Android端已接入
- 请求参数: 无
- 响应数据: `[{ "id": "选项ID", "coinAmount": 金币数量, "price": 价格, "discountLabel": "优惠标签" }]`
- 后端实现: `GiftController.getRechargeOptions()`
- Android实现: `RoomDetailActivity.java``loadRechargeOptions()`
- 模型类: `RechargeOptionResponse.java`
- 说明: 获取所有启用的充值选项,包含金币数量、价格和优惠标签
2. **POST /api/front/gift/recharge/create** - 创建充值订单 ✨ Android端已接入
- 请求参数: `{ "optionId": 选项ID, "coinAmount": 金币数量, "price": 价格 }`
- 响应数据: `{ "orderId": "订单ID", "paymentUrl": "支付URL" }`
- 后端实现: `GiftController.createRecharge()`
- Android实现: `RoomDetailActivity.java``createRechargeOrder()`
- 模型类: `CreateRechargeRequest.java`, `CreateRechargeResponse.java`
- 说明: 创建充值订单返回订单ID和支付URL用于后续支付
3. **POST /api/front/pay/payment** - 订单支付 ✨ Android端已接入
- 请求参数: `{ "orderNo": "订单号", "payType": "支付类型", "payChannel": "支付渠道", "from": "android" }`
- 响应数据: `{ "status": true/false, "payType": "支付类型", "orderNo": "订单号", "jsConfig": {...} }`
- 后端实现: `PayController.payment()`
- Android实现: `RoomDetailActivity.java``processPayment()`
- 模型类: `OrderPayRequest.java`, `OrderPayResultResponse.java`
- 说明: 发起支付,支持微信支付(weixin/weixinAppAndroid)、支付宝(alipay/appAliPay)、余额支付(yue)
- 注意: 需要集成微信支付SDK和支付宝SDK才能完成实际支付
4. **GET /api/front/pay/alipay/queryPayResult** - 查询支付宝支付结果 ✨ Android端已接入
- 请求参数: `orderNo` (订单号)
- 响应数据: `{ "code": 200, "data": true/false }`
- 后端实现: `PayController.queryAliPayResult()`
- Android实现: `ApiService.queryAliPayResult()`
- 说明: 查询支付宝支付结果,用于确认支付是否成功
**支付流程说明**:
1. 用户点击充值按钮,显示充值对话框
2. 调用 `getRechargeOptions()` 获取充值选项列表
3. 用户选择充值金额,点击确认
4. 调用 `createRecharge()` 创建充值订单获取订单ID
5. 用户选择支付方式(支付宝/微信/余额)
6. 调用 `payment()` 发起支付,获取支付参数
7. 调用支付SDK完成支付需要集成微信/支付宝SDK
8. 支付完成后调用 `queryPayResult()` 查询支付结果
9. 支付成功后更新用户金币余额
**待完成工作**:
- 集成微信支付SDK需要微信开放平台账号和配置
- 集成支付宝SDK需要支付宝开放平台账号和配置
- 实现支付结果回调处理
- 实现支付成功后的余额更新逻辑
#### 18. 文件上传 (5个接口) - ✅ 全部完成 #### 18. 文件上传 (5个接口) - ✅ 全部完成
``` ```
✅ POST /api/upload/image # 图片上传 ✅ POST /api/upload/image # 图片上传
@ -356,7 +812,7 @@
| 评论功能 | 8 | ✅ 100% | 2024-12-29 | | 评论功能 | 8 | ✅ 100% | 2024-12-29 |
| 搜索功能 | 9 | ✅ 100% | 2024-12-29 | | 搜索功能 | 9 | ✅ 100% | 2024-12-29 |
| 通知推送 | 9 | ✅ 100% | 2024-12-29 | | 通知推送 | 9 | ✅ 100% | 2024-12-29 |
| 支付集成 | 4 | ✅ 100% | 2024-12-29 | | 支付集成 | 4 | ✅ 100% | 2024-12-29 ✨ Android端已接入 |
| 文件上传 | 5 | ✅ 100% | 2024-12-29 | | 文件上传 | 5 | ✅ 100% | 2024-12-29 |
| 分类管理 | 7 | ✅ 100% | 2024-12-29 | | 分类管理 | 7 | ✅ 100% | 2024-12-29 |
| **总计** | **131** | **✅ 100%** | **2024-12-29** | | **总计** | **131** | **✅ 100%** | **2024-12-29** |
@ -454,29 +910,4 @@
--- ---
## ✅ 完成清单
- [x] 用户认证模块 (3个接口)
- [x] 用户资料模块 (4个接口)
- [x] 直播间管理模块 (10个接口)
- [x] 直播间弹幕模块 (2个接口)
- [x] WebSocket通信模块 (2个连接)
- [x] 好友管理模块 (9个接口)
- [x] 群组管理模块 (10个接口)
- [x] 群组消息模块 (4个接口)
- [x] 消息聊天模块 (12个接口)
- [x] 消息表情回应模块 (4个接口)
- [x] 消息搜索模块 (3个接口)
- [x] 关注功能模块 (8个接口)
- [x] 礼物管理模块 (4个接口)
- [x] 作品管理模块 (15个接口)
- [x] 评论功能模块 (8个接口)
- [x] 搜索功能模块 (9个接口)
- [x] 通知推送模块 (9个接口)
- [x] 支付集成模块 (4个接口)
- [x] 文件上传模块 (5个接口)
- [x] 分类管理模块 (7个接口)
**总计**: 131个接口全部完成 ✅ **总计**: 131个接口全部完成 ✅

View File

@ -282,4 +282,95 @@ public class FriendController {
return CommonResult.failed("删除失败: " + e.getMessage()); return CommonResult.failed("删除失败: " + e.getMessage());
} }
} }
/**
* 拉黑好友
*/
@ApiOperation(value = "拉黑好友")
@PostMapping("/friends/block/{friendId}")
public CommonResult<Boolean> blockFriend(@PathVariable Integer friendId) {
Integer currentUserId = userService.getUserId();
if (friendId == null || friendId.equals(currentUserId)) {
return CommonResult.failed("无效的用户ID");
}
try {
// 检查是否已经拉黑
String checkSql = "SELECT COUNT(*) FROM eb_user_blacklist WHERE user_id = ? AND blocked_user_id = ?";
Long count = jdbcTemplate.queryForObject(checkSql, Long.class, currentUserId, friendId);
if (count != null && count > 0) {
return CommonResult.failed("该用户已在黑名单中");
}
// 添加到黑名单
String insertSql = "INSERT INTO eb_user_blacklist (user_id, blocked_user_id, create_time) VALUES (?, ?, NOW())";
jdbcTemplate.update(insertSql, currentUserId, friendId);
// 删除好友关系如果存在
String deleteFriendSql = "DELETE FROM eb_friend WHERE " +
"(user_id = ? AND friend_id = ?) OR (user_id = ? AND friend_id = ?)";
jdbcTemplate.update(deleteFriendSql, currentUserId, friendId, friendId, currentUserId);
return CommonResult.success(true);
} catch (Exception e) {
log.error("拉黑好友失败: {}", e.getMessage());
return CommonResult.failed("拉黑失败: " + e.getMessage());
}
}
/**
* 取消拉黑
*/
@ApiOperation(value = "取消拉黑")
@PostMapping("/friends/unblock/{friendId}")
public CommonResult<Boolean> unblockFriend(@PathVariable Integer friendId) {
Integer currentUserId = userService.getUserId();
try {
String deleteSql = "DELETE FROM eb_user_blacklist WHERE user_id = ? AND blocked_user_id = ?";
int rows = jdbcTemplate.update(deleteSql, currentUserId, friendId);
if (rows > 0) {
return CommonResult.success(true);
} else {
return CommonResult.failed("该用户不在黑名单中");
}
} catch (Exception e) {
log.error("取消拉黑失败: {}", e.getMessage());
return CommonResult.failed("取消拉黑失败: " + e.getMessage());
}
}
/**
* 获取黑名单列表
*/
@ApiOperation(value = "获取黑名单列表")
@GetMapping("/friends/blocked")
public CommonResult<CommonPage<Map<String, Object>>> getBlockedList(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
Integer currentUserId = userService.getUserId();
String sql = "SELECT u.uid as id, u.nickname as name, u.avatar as avatarUrl, u.phone, " +
"b.create_time as blockedTime " +
"FROM eb_user_blacklist b " +
"JOIN eb_user u ON b.blocked_user_id = u.uid " +
"WHERE b.user_id = ? " +
"ORDER BY b.create_time DESC LIMIT ?, ?";
String countSql = "SELECT COUNT(*) FROM eb_user_blacklist WHERE user_id = ?";
Long total = jdbcTemplate.queryForObject(countSql, Long.class, currentUserId);
int offset = (page - 1) * pageSize;
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql, currentUserId, offset, pageSize);
CommonPage<Map<String, Object>> result = new CommonPage<>();
result.setList(list);
result.setTotal(total != null ? total : 0L);
result.setPage(page);
result.setLimit(pageSize);
return CommonResult.success(result);
}
} }

View File

@ -96,6 +96,9 @@ dependencies {
implementation("de.hdodenhof:circleimageview:3.1.0") implementation("de.hdodenhof:circleimageview:3.1.0")
// FlexboxLayout for message reactions
implementation("com.google.android.flexbox:flexbox:3.0.0")
implementation("com.squareup.retrofit2:retrofit:2.9.0") implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0") implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.squareup.okhttp3:okhttp:4.12.0")

View File

@ -1,5 +1,9 @@
package com.example.livestreaming; package com.example.livestreaming;
import com.example.livestreaming.net.MessageReaction;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID; import java.util.UUID;
public class ChatMessage { public class ChatMessage {
@ -31,6 +35,9 @@ public class ChatMessage {
private int voiceDuration; // 语音时长 private int voiceDuration; // 语音时长
private int imageWidth; // 图片宽度 private int imageWidth; // 图片宽度
private int imageHeight; // 图片高度 private int imageHeight; // 图片高度
// 表情回应相关字段
private List<MessageReaction> reactions; // 表情回应列表
// 文本消息构造函数 // 文本消息构造函数
public ChatMessage(String username, String message) { public ChatMessage(String username, String message) {
@ -212,4 +219,25 @@ public class ChatMessage {
public void setImageHeight(int imageHeight) { public void setImageHeight(int imageHeight) {
this.imageHeight = imageHeight; this.imageHeight = imageHeight;
} }
public List<MessageReaction> getReactions() {
return reactions;
}
public void setReactions(List<MessageReaction> reactions) {
this.reactions = reactions;
}
public void addReaction(MessageReaction reaction) {
if (this.reactions == null) {
this.reactions = new ArrayList<>();
}
this.reactions.add(reaction);
}
public void removeReaction(String emoji) {
if (this.reactions != null) {
this.reactions.removeIf(r -> r.getEmoji().equals(emoji));
}
}
} }

View File

@ -374,6 +374,7 @@ public class ConversationActivity extends AppCompatActivity {
private void showMessageMenu(ChatMessage message, int position, View anchorView) { private void showMessageMenu(ChatMessage message, int position, View anchorView) {
PopupMenu popupMenu = new PopupMenu(this, anchorView); PopupMenu popupMenu = new PopupMenu(this, anchorView);
popupMenu.getMenu().add(0, 0, 0, "复制"); popupMenu.getMenu().add(0, 0, 0, "复制");
popupMenu.getMenu().add(0, 2, 0, "表情回应");
// 只有自己发送的消息才能删除 // 只有自己发送的消息才能删除
if ("".equals(message.getUsername())) { if ("".equals(message.getUsername())) {
popupMenu.getMenu().add(0, 1, 0, "删除"); popupMenu.getMenu().add(0, 1, 0, "删除");
@ -386,6 +387,9 @@ public class ConversationActivity extends AppCompatActivity {
} else if (item.getItemId() == 1) { } else if (item.getItemId() == 1) {
deleteMessage(message, position); deleteMessage(message, position);
return true; return true;
} else if (item.getItemId() == 2) {
showEmojiPicker(message);
return true;
} }
return false; return false;
}); });
@ -733,4 +737,194 @@ public class ConversationActivity extends AppCompatActivity {
stopPolling(); stopPolling();
handler = null; handler = null;
} }
/**
* 显示表情选择器
*/
private void showEmojiPicker(ChatMessage message) {
EmojiPickerBottomSheet emojiPicker = EmojiPickerBottomSheet.newInstance();
emojiPicker.setOnEmojiSelectedListener(emoji -> {
addMessageReaction(message, emoji);
});
emojiPicker.show(getSupportFragmentManager(), "emoji_picker");
}
/**
* 添加表情回应
*/
private void addMessageReaction(ChatMessage message, String emoji) {
if (!AuthHelper.requireLoginWithToast(this, "添加表情回应需要登录")) {
return;
}
String token = AuthStore.getToken(this);
if (token == null || message.getMessageId() == null) {
Snackbar.make(binding.getRoot(), "无法添加表情回应", Snackbar.LENGTH_SHORT).show();
return;
}
String url = ApiConfig.getBaseUrl() + "/api/front/messages/reactions/add";
Log.d(TAG, "添加表情回应: " + url);
try {
JSONObject body = new JSONObject();
body.put("messageId", message.getMessageId());
body.put("emoji", emoji);
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.post(RequestBody.create(body.toString(), MediaType.parse("application/json")))
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "添加表情回应失败", e);
runOnUiThread(() -> Snackbar.make(binding.getRoot(), "添加表情回应失败", Snackbar.LENGTH_SHORT).show());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String responseBody = response.body() != null ? response.body().string() : "";
Log.d(TAG, "添加表情回应响应: " + responseBody);
runOnUiThread(() -> {
try {
JSONObject json = new JSONObject(responseBody);
if (json.optInt("code", -1) == 200) {
Snackbar.make(binding.getRoot(), "已添加表情回应", Snackbar.LENGTH_SHORT).show();
// 重新加载消息以更新表情回应
loadMessageReactions(message);
} else {
String msg = json.optString("message", "添加失败");
Snackbar.make(binding.getRoot(), msg, Snackbar.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析响应失败", e);
}
});
}
});
} catch (Exception e) {
Log.e(TAG, "构建请求失败", e);
}
}
/**
* 移除表情回应
*/
private void removeMessageReaction(ChatMessage message, String emoji) {
if (!AuthHelper.requireLoginWithToast(this, "移除表情回应需要登录")) {
return;
}
String token = AuthStore.getToken(this);
if (token == null || message.getMessageId() == null) {
return;
}
String url = ApiConfig.getBaseUrl() + "/api/front/messages/reactions/remove";
Log.d(TAG, "移除表情回应: " + url);
try {
JSONObject body = new JSONObject();
body.put("messageId", message.getMessageId());
body.put("emoji", emoji);
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.method("DELETE", RequestBody.create(body.toString(), MediaType.parse("application/json")))
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "移除表情回应失败", e);
runOnUiThread(() -> Snackbar.make(binding.getRoot(), "移除表情回应失败", Snackbar.LENGTH_SHORT).show());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String responseBody = response.body() != null ? response.body().string() : "";
Log.d(TAG, "移除表情回应响应: " + responseBody);
runOnUiThread(() -> {
try {
JSONObject json = new JSONObject(responseBody);
if (json.optInt("code", -1) == 200) {
Snackbar.make(binding.getRoot(), "已移除表情回应", Snackbar.LENGTH_SHORT).show();
// 重新加载消息以更新表情回应
loadMessageReactions(message);
} else {
String msg = json.optString("message", "移除失败");
Snackbar.make(binding.getRoot(), msg, Snackbar.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析响应失败", e);
}
});
}
});
} catch (Exception e) {
Log.e(TAG, "构建请求失败", e);
}
}
/**
* 加载消息的表情回应列表
*/
private void loadMessageReactions(ChatMessage message) {
String token = AuthStore.getToken(this);
if (token == null || message.getMessageId() == null) {
return;
}
String url = ApiConfig.getBaseUrl() + "/api/front/messages/" + message.getMessageId() + "/reactions";
Log.d(TAG, "加载表情回应: " + url);
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.get()
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "加载表情回应失败", e);
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String responseBody = response.body() != null ? response.body().string() : "";
Log.d(TAG, "表情回应响应: " + responseBody);
runOnUiThread(() -> {
try {
JSONObject json = new JSONObject(responseBody);
if (json.optInt("code", -1) == 200) {
JSONArray data = json.optJSONArray("data");
if (data != null) {
List<com.example.livestreaming.net.MessageReaction> reactions = new ArrayList<>();
for (int i = 0; i < data.length(); i++) {
JSONObject item = data.getJSONObject(i);
String emoji = item.optString("emoji", "");
int count = item.optInt("count", 0);
boolean reactedByMe = item.optBoolean("reactedByMe", false);
reactions.add(new com.example.livestreaming.net.MessageReaction(emoji, count, reactedByMe));
}
message.setReactions(reactions);
// 更新适配器
int position = messages.indexOf(message);
if (position >= 0) {
adapter.notifyItemChanged(position);
}
}
}
} catch (Exception e) {
Log.e(TAG, "解析表情回应失败", e);
}
});
}
});
}
} }

View File

@ -142,6 +142,7 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
private final TextView nameText; private final TextView nameText;
private final TextView msgText; private final TextView msgText;
private final TextView timeText; private final TextView timeText;
private final com.google.android.flexbox.FlexboxLayout reactionsContainer;
IncomingTextVH(@NonNull View itemView) { IncomingTextVH(@NonNull View itemView) {
super(itemView); super(itemView);
@ -149,6 +150,7 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
nameText = itemView.findViewById(R.id.nameText); nameText = itemView.findViewById(R.id.nameText);
msgText = itemView.findViewById(R.id.messageText); msgText = itemView.findViewById(R.id.messageText);
timeText = itemView.findViewById(R.id.timeText); timeText = itemView.findViewById(R.id.timeText);
reactionsContainer = itemView.findViewById(R.id.reactionsContainer);
setupAvatarOutline(); setupAvatarOutline();
} }
@ -175,6 +177,9 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
msgText.setText(message.getMessage() != null ? message.getMessage() : ""); msgText.setText(message.getMessage() != null ? message.getMessage() : "");
timeText.setText(TIME_FORMAT.format(new Date(message.getTimestamp()))); timeText.setText(TIME_FORMAT.format(new Date(message.getTimestamp())));
// 显示表情回应
displayReactions(message);
// 清除之前的监听器避免重复绑定 // 清除之前的监听器避免重复绑定
itemView.setOnClickListener(null); itemView.setOnClickListener(null);
itemView.setOnLongClickListener(null); itemView.setOnLongClickListener(null);
@ -197,6 +202,39 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
loadAvatar(message); loadAvatar(message);
} }
private void displayReactions(ChatMessage message) {
if (reactionsContainer == null) return;
reactionsContainer.removeAllViews();
if (message.getReactions() == null || message.getReactions().isEmpty()) {
reactionsContainer.setVisibility(View.GONE);
return;
}
reactionsContainer.setVisibility(View.VISIBLE);
for (com.example.livestreaming.net.MessageReaction reaction : message.getReactions()) {
View reactionView = LayoutInflater.from(itemView.getContext())
.inflate(R.layout.item_message_reaction, reactionsContainer, false);
TextView emojiText = reactionView.findViewById(R.id.emojiText);
TextView countText = reactionView.findViewById(R.id.countText);
emojiText.setText(reaction.getEmoji());
countText.setText(String.valueOf(reaction.getCount()));
// 如果当前用户已回应高亮显示
if (reaction.isReactedByMe()) {
reactionView.setBackgroundResource(R.drawable.reaction_background_selected);
} else {
reactionView.setBackgroundResource(R.drawable.reaction_background);
}
reactionsContainer.addView(reactionView);
}
}
private void loadAvatar(ChatMessage message) { private void loadAvatar(ChatMessage message) {
// TODO: 接入后端接口 - 加载消息发送者头像 // TODO: 接入后端接口 - 加载消息发送者头像
// 接口路径: GET /api/users/{userId}/avatar 或直接从ChatMessage的avatarUrl字段获取 // 接口路径: GET /api/users/{userId}/avatar 或直接从ChatMessage的avatarUrl字段获取
@ -263,6 +301,7 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
private final TextView msgText; private final TextView msgText;
private final TextView timeText; private final TextView timeText;
private final ImageView statusIcon; private final ImageView statusIcon;
private final com.google.android.flexbox.FlexboxLayout reactionsContainer;
OutgoingTextVH(@NonNull View itemView) { OutgoingTextVH(@NonNull View itemView) {
super(itemView); super(itemView);
@ -270,6 +309,7 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
msgText = itemView.findViewById(R.id.messageText); msgText = itemView.findViewById(R.id.messageText);
timeText = itemView.findViewById(R.id.timeText); timeText = itemView.findViewById(R.id.timeText);
statusIcon = itemView.findViewById(R.id.statusIcon); statusIcon = itemView.findViewById(R.id.statusIcon);
reactionsContainer = itemView.findViewById(R.id.reactionsContainer);
setupAvatarOutline(); setupAvatarOutline();
} }
@ -295,6 +335,9 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
msgText.setText(message.getMessage() != null ? message.getMessage() : ""); msgText.setText(message.getMessage() != null ? message.getMessage() : "");
timeText.setText(TIME_FORMAT.format(new Date(message.getTimestamp()))); timeText.setText(TIME_FORMAT.format(new Date(message.getTimestamp())));
// 显示表情回应
displayReactions(message);
// 显示消息状态图标仅对发送的消息显示 // 显示消息状态图标仅对发送的消息显示
if (statusIcon != null && message.getStatus() != null) { if (statusIcon != null && message.getStatus() != null) {
switch (message.getStatus()) { switch (message.getStatus()) {
@ -341,6 +384,39 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
loadAvatar(); loadAvatar();
} }
private void displayReactions(ChatMessage message) {
if (reactionsContainer == null) return;
reactionsContainer.removeAllViews();
if (message.getReactions() == null || message.getReactions().isEmpty()) {
reactionsContainer.setVisibility(View.GONE);
return;
}
reactionsContainer.setVisibility(View.VISIBLE);
for (com.example.livestreaming.net.MessageReaction reaction : message.getReactions()) {
View reactionView = LayoutInflater.from(itemView.getContext())
.inflate(R.layout.item_message_reaction, reactionsContainer, false);
TextView emojiText = reactionView.findViewById(R.id.emojiText);
TextView countText = reactionView.findViewById(R.id.countText);
emojiText.setText(reaction.getEmoji());
countText.setText(String.valueOf(reaction.getCount()));
// 如果当前用户已回应高亮显示
if (reaction.isReactedByMe()) {
reactionView.setBackgroundResource(R.drawable.reaction_background_selected);
} else {
reactionView.setBackgroundResource(R.drawable.reaction_background);
}
reactionsContainer.addView(reactionView);
}
}
private void loadAvatar() { private void loadAvatar() {
if (avatarView == null) return; if (avatarView == null) return;

View File

@ -0,0 +1,72 @@
package com.example.livestreaming;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
/**
* 表情选择器底部弹窗
*/
public class EmojiPickerBottomSheet extends BottomSheetDialogFragment {
public interface OnEmojiSelectedListener {
void onEmojiSelected(String emoji);
}
private OnEmojiSelectedListener listener;
// 常用表情列表
private static final String[] EMOJIS = {
"👍", "❤️", "😂", "😮", "😢", "😠",
"🔥", "👏", "🤔", "🎉", "", ""
};
private static final int[] EMOJI_IDS = {
R.id.emoji_like, R.id.emoji_love, R.id.emoji_laugh,
R.id.emoji_wow, R.id.emoji_sad, R.id.emoji_angry,
R.id.emoji_fire, R.id.emoji_clap, R.id.emoji_thinking,
R.id.emoji_party, R.id.emoji_star, R.id.emoji_check
};
public static EmojiPickerBottomSheet newInstance() {
return new EmojiPickerBottomSheet();
}
public void setOnEmojiSelectedListener(OnEmojiSelectedListener listener) {
this.listener = listener;
}
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return inflater.inflate(R.layout.bottom_sheet_emoji_picker, container, false);
}
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
setupEmojiButtons(view);
}
private void setupEmojiButtons(View view) {
for (int i = 0; i < EMOJI_IDS.length && i < EMOJIS.length; i++) {
TextView emojiView = view.findViewById(EMOJI_IDS[i]);
if (emojiView != null) {
final String emoji = EMOJIS[i];
emojiView.setOnClickListener(v -> {
if (listener != null) {
listener.onEmojiSelected(emoji);
}
dismiss();
});
}
}
}
}

View File

@ -3,19 +3,32 @@ package com.example.livestreaming;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.view.View;
import android.widget.Toast; import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import com.example.livestreaming.databinding.ActivityFansListBinding; import com.example.livestreaming.databinding.ActivityFansListBinding;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ApiService;
import com.example.livestreaming.net.PageResponse;
import com.example.livestreaming.net.RetrofitClient;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class FansListActivity extends AppCompatActivity { public class FansListActivity extends AppCompatActivity {
private ActivityFansListBinding binding; private ActivityFansListBinding binding;
private FriendsAdapter adapter;
private int currentPage = 1;
private boolean isLoading = false;
public static void start(Context context) { public static void start(Context context) {
Intent intent = new Intent(context, FansListActivity.class); Intent intent = new Intent(context, FansListActivity.class);
@ -30,33 +43,72 @@ public class FansListActivity extends AppCompatActivity {
binding.backButton.setOnClickListener(v -> finish()); binding.backButton.setOnClickListener(v -> finish());
FriendsAdapter adapter = new FriendsAdapter(item -> { adapter = new FriendsAdapter(item -> {
if (item == null) return; if (item == null) return;
Toast.makeText(this, "打开粉丝:" + item.getName(), Toast.LENGTH_SHORT).show(); Toast.makeText(this, "打开粉丝:" + item.getName(), Toast.LENGTH_SHORT).show();
}); });
// TODO: 接入后端接口 - 获取粉丝列表
// 接口路径: GET /api/fans
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<User>>
// User对象应包含: id, name, avatarUrl, bio, isLive, followTime等字段
// 列表应按关注时间倒序排列最新关注的在前
binding.fansRecyclerView.setLayoutManager(new LinearLayoutManager(this)); binding.fansRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.fansRecyclerView.setAdapter(adapter); binding.fansRecyclerView.setAdapter(adapter);
adapter.submitList(buildDemoFans());
loadFollowersList();
} }
private List<FriendItem> buildDemoFans() { private void loadFollowersList() {
List<FriendItem> list = new ArrayList<>(); if (isLoading) return;
list.add(new FriendItem("f1", "小雨", "关注了你 · 2分钟前", true)); isLoading = true;
list.add(new FriendItem("f2", "阿宁", "关注了你 · 昨天", false));
list.add(new FriendItem("f3", "小星", "关注了你 · 周二", true)); ApiService apiService = RetrofitClient.getInstance(this).getApiService();
list.add(new FriendItem("f4", "小林", "关注了你 · 上周", false)); Call<ApiResponse<PageResponse<Map<String, Object>>>> call = apiService.getFollowersList(currentPage, 20);
list.add(new FriendItem("f5", "阿杰", "关注了你 · 上周", false));
list.add(new FriendItem("f6", "小七", "关注了你 · 上月", true)); call.enqueue(new Callback<ApiResponse<PageResponse<Map<String, Object>>>>() {
return list; @Override
public void onResponse(Call<ApiResponse<PageResponse<Map<String, Object>>>> call,
Response<ApiResponse<PageResponse<Map<String, Object>>>> response) {
isLoading = false;
if (response.isSuccessful() && response.body() != null) {
ApiResponse<PageResponse<Map<String, Object>>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
PageResponse<Map<String, Object>> pageData = apiResponse.getData();
List<Map<String, Object>> followersList = pageData.getList();
if (followersList != null && !followersList.isEmpty()) {
List<FriendItem> items = new ArrayList<>();
for (Map<String, Object> user : followersList) {
String id = String.valueOf(user.get("userId"));
String name = (String) user.get("nickname");
String phone = (String) user.get("phone");
Boolean isOnline = (Boolean) user.get("isOnline");
Boolean isMutualFollow = (Boolean) user.get("isMutualFollow");
String status = isMutualFollow != null && isMutualFollow ?
"互相关注" : (isOnline != null && isOnline ? "在线" : "离线");
items.add(new FriendItem(id, name != null ? name : phone, status,
isOnline != null && isOnline));
}
adapter.submitList(items);
} else {
// 空列表
adapter.submitList(new ArrayList<>());
Toast.makeText(FansListActivity.this, "暂无粉丝", Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(FansListActivity.this,
apiResponse.getMsg() != null ? apiResponse.getMsg() : "获取粉丝列表失败",
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(FansListActivity.this, "网络请求失败", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<PageResponse<Map<String, Object>>>> call, Throwable t) {
isLoading = false;
Toast.makeText(FansListActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
} }
} }

View File

@ -3,19 +3,32 @@ package com.example.livestreaming;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.view.View;
import android.widget.Toast; import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import com.example.livestreaming.databinding.ActivityFollowingListBinding; import com.example.livestreaming.databinding.ActivityFollowingListBinding;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ApiService;
import com.example.livestreaming.net.PageResponse;
import com.example.livestreaming.net.RetrofitClient;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class FollowingListActivity extends AppCompatActivity { public class FollowingListActivity extends AppCompatActivity {
private ActivityFollowingListBinding binding; private ActivityFollowingListBinding binding;
private FriendsAdapter adapter;
private int currentPage = 1;
private boolean isLoading = false;
public static void start(Context context) { public static void start(Context context) {
Intent intent = new Intent(context, FollowingListActivity.class); Intent intent = new Intent(context, FollowingListActivity.class);
@ -30,32 +43,70 @@ public class FollowingListActivity extends AppCompatActivity {
binding.backButton.setOnClickListener(v -> finish()); binding.backButton.setOnClickListener(v -> finish());
FriendsAdapter adapter = new FriendsAdapter(item -> { adapter = new FriendsAdapter(item -> {
if (item == null) return; if (item == null) return;
Toast.makeText(this, "打开关注:" + item.getName(), Toast.LENGTH_SHORT).show(); Toast.makeText(this, "打开关注:" + item.getName(), Toast.LENGTH_SHORT).show();
}); });
// TODO: 接入后端接口 - 获取关注列表
// 接口路径: GET /api/following
// 请求参数:
// - userId: 当前用户ID从token中获取
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<User>>
// User对象应包含: id, name, avatarUrl, bio, isLive, lastLiveTime, followTime等字段
// 列表应按关注时间倒序或最后直播时间倒序排列
binding.followingRecyclerView.setLayoutManager(new LinearLayoutManager(this)); binding.followingRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.followingRecyclerView.setAdapter(adapter); binding.followingRecyclerView.setAdapter(adapter);
adapter.submitList(buildDemoFollowing());
loadFollowingList();
} }
private List<FriendItem> buildDemoFollowing() { private void loadFollowingList() {
List<FriendItem> list = new ArrayList<>(); if (isLoading) return;
list.add(new FriendItem("fo1", "王者荣耀陪练", "主播 · 正在直播", true)); isLoading = true;
list.add(new FriendItem("fo2", "音乐电台", "主播 · 今日 20:00 开播", false));
list.add(new FriendItem("fo3", "户外阿杰", "主播 · 1小时前开播", true)); ApiService apiService = RetrofitClient.getInstance(this).getApiService();
list.add(new FriendItem("fo4", "美食探店", "主播 · 昨天直播", false)); Call<ApiResponse<PageResponse<Map<String, Object>>>> call = apiService.getFollowingList(currentPage, 20);
list.add(new FriendItem("fo5", "聊天小七", "主播 · 正在直播", true));
return list; call.enqueue(new Callback<ApiResponse<PageResponse<Map<String, Object>>>>() {
@Override
public void onResponse(Call<ApiResponse<PageResponse<Map<String, Object>>>> call,
Response<ApiResponse<PageResponse<Map<String, Object>>>> response) {
isLoading = false;
if (response.isSuccessful() && response.body() != null) {
ApiResponse<PageResponse<Map<String, Object>>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
PageResponse<Map<String, Object>> pageData = apiResponse.getData();
List<Map<String, Object>> followingList = pageData.getList();
if (followingList != null && !followingList.isEmpty()) {
List<FriendItem> items = new ArrayList<>();
for (Map<String, Object> user : followingList) {
String id = String.valueOf(user.get("userId"));
String name = (String) user.get("nickname");
String phone = (String) user.get("phone");
Boolean isOnline = (Boolean) user.get("isOnline");
String status = isOnline != null && isOnline ? "在线" : "离线";
items.add(new FriendItem(id, name != null ? name : phone, status,
isOnline != null && isOnline));
}
adapter.submitList(items);
} else {
// 空列表
adapter.submitList(new ArrayList<>());
Toast.makeText(FollowingListActivity.this, "暂无关注", Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(FollowingListActivity.this,
apiResponse.getMsg() != null ? apiResponse.getMsg() : "获取关注列表失败",
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(FollowingListActivity.this, "网络请求失败", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<PageResponse<Map<String, Object>>>> call, Throwable t) {
isLoading = false;
Toast.makeText(FollowingListActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
} }
} }

View File

@ -18,37 +18,48 @@ public class FriendsAdapter extends ListAdapter<FriendItem, FriendsAdapter.VH> {
void onFriendClick(FriendItem item); void onFriendClick(FriendItem item);
} }
public interface OnFriendLongClickListener {
void onFriendLongClick(FriendItem item, int position);
}
private final OnFriendClickListener onFriendClickListener; private final OnFriendClickListener onFriendClickListener;
private OnFriendLongClickListener onFriendLongClickListener;
public FriendsAdapter(OnFriendClickListener onFriendClickListener) { public FriendsAdapter(OnFriendClickListener onFriendClickListener) {
super(DIFF); super(DIFF);
this.onFriendClickListener = onFriendClickListener; this.onFriendClickListener = onFriendClickListener;
} }
public void setOnFriendLongClickListener(OnFriendLongClickListener listener) {
this.onFriendLongClickListener = listener;
}
@NonNull @NonNull
@Override @Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
ItemFriendBinding binding = ItemFriendBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); ItemFriendBinding binding = ItemFriendBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false);
return new VH(binding, onFriendClickListener); return new VH(binding, onFriendClickListener, onFriendLongClickListener);
} }
@Override @Override
public void onBindViewHolder(@NonNull VH holder, int position) { public void onBindViewHolder(@NonNull VH holder, int position) {
holder.bind(getItem(position)); holder.bind(getItem(position), position);
} }
static class VH extends RecyclerView.ViewHolder { static class VH extends RecyclerView.ViewHolder {
private final ItemFriendBinding binding; private final ItemFriendBinding binding;
private final OnFriendClickListener onFriendClickListener; private final OnFriendClickListener onFriendClickListener;
private final OnFriendLongClickListener onFriendLongClickListener;
VH(ItemFriendBinding binding, OnFriendClickListener onFriendClickListener) { VH(ItemFriendBinding binding, OnFriendClickListener onFriendClickListener, OnFriendLongClickListener onFriendLongClickListener) {
super(binding.getRoot()); super(binding.getRoot());
this.binding = binding; this.binding = binding;
this.onFriendClickListener = onFriendClickListener; this.onFriendClickListener = onFriendClickListener;
this.onFriendLongClickListener = onFriendLongClickListener;
} }
void bind(FriendItem item) { void bind(FriendItem item, int position) {
if (item == null) return; if (item == null) return;
binding.name.setText(item.getName() != null ? item.getName() : ""); binding.name.setText(item.getName() != null ? item.getName() : "");
@ -74,6 +85,14 @@ public class FriendsAdapter extends ListAdapter<FriendItem, FriendsAdapter.VH> {
binding.getRoot().setOnClickListener(v -> { binding.getRoot().setOnClickListener(v -> {
if (onFriendClickListener != null) onFriendClickListener.onFriendClick(item); if (onFriendClickListener != null) onFriendClickListener.onFriendClick(item);
}); });
binding.getRoot().setOnLongClickListener(v -> {
if (onFriendLongClickListener != null) {
onFriendLongClickListener.onFriendLongClick(item, position);
return true;
}
return false;
});
} }
} }

View File

@ -1,5 +1,6 @@
package com.example.livestreaming; package com.example.livestreaming;
import android.app.AlertDialog;
import android.content.Intent; import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.text.Editable; import android.text.Editable;
@ -36,9 +37,11 @@ public class MyFriendsActivity extends AppCompatActivity {
private ActivityMyFriendsBinding binding; private ActivityMyFriendsBinding binding;
private FriendsAdapter friendsAdapter; private FriendsAdapter friendsAdapter;
private FriendRequestAdapter requestAdapter; private FriendRequestAdapter requestAdapter;
private FriendsAdapter blockedAdapter;
private final List<FriendItem> allFriends = new ArrayList<>(); private final List<FriendItem> allFriends = new ArrayList<>();
private final List<FriendRequestItem> allRequests = new ArrayList<>(); private final List<FriendRequestItem> allRequests = new ArrayList<>();
private int currentTab = 0; // 0: 好友列表, 1: 好友请求 private final List<FriendItem> allBlocked = new ArrayList<>();
private int currentTab = 0; // 0: 好友列表, 1: 好友请求, 2: 黑名单
private OkHttpClient httpClient; private OkHttpClient httpClient;
@Override @Override
@ -71,6 +74,7 @@ public class MyFriendsActivity extends AppCompatActivity {
// 点击好友打开私聊会话 // 点击好友打开私聊会话
openConversation(item); openConversation(item);
}); });
friendsAdapter.setOnFriendLongClickListener(this::showFriendOptionsDialog);
binding.friendsRecyclerView.setLayoutManager(new LinearLayoutManager(this)); binding.friendsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.friendsRecyclerView.setAdapter(friendsAdapter); binding.friendsRecyclerView.setAdapter(friendsAdapter);
@ -90,6 +94,14 @@ public class MyFriendsActivity extends AppCompatActivity {
binding.requestsRecyclerView.setLayoutManager(new LinearLayoutManager(this)); binding.requestsRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.requestsRecyclerView.setAdapter(requestAdapter); binding.requestsRecyclerView.setAdapter(requestAdapter);
// 黑名单适配器
blockedAdapter = new FriendsAdapter(item -> {
// 黑名单项点击不做操作
});
blockedAdapter.setOnFriendLongClickListener(this::showBlockedOptionsDialog);
binding.blockedRecyclerView.setLayoutManager(new LinearLayoutManager(this));
binding.blockedRecyclerView.setAdapter(blockedAdapter);
// 搜索框 // 搜索框
binding.searchEdit.addTextChangedListener(new TextWatcher() { binding.searchEdit.addTextChangedListener(new TextWatcher() {
@Override @Override
@ -107,6 +119,7 @@ public class MyFriendsActivity extends AppCompatActivity {
private void setupTabs() { private void setupTabs() {
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("好友列表")); binding.tabLayout.addTab(binding.tabLayout.newTab().setText("好友列表"));
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("好友请求")); binding.tabLayout.addTab(binding.tabLayout.newTab().setText("好友请求"));
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("黑名单"));
binding.tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { binding.tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
@Override @Override
@ -125,13 +138,21 @@ public class MyFriendsActivity extends AppCompatActivity {
if (tabIndex == 0) { if (tabIndex == 0) {
binding.friendsRecyclerView.setVisibility(View.VISIBLE); binding.friendsRecyclerView.setVisibility(View.VISIBLE);
binding.requestsRecyclerView.setVisibility(View.GONE); binding.requestsRecyclerView.setVisibility(View.GONE);
binding.blockedRecyclerView.setVisibility(View.GONE);
binding.searchContainer.setVisibility(View.VISIBLE); binding.searchContainer.setVisibility(View.VISIBLE);
loadFriendList(); loadFriendList();
} else { } else if (tabIndex == 1) {
binding.friendsRecyclerView.setVisibility(View.GONE); binding.friendsRecyclerView.setVisibility(View.GONE);
binding.requestsRecyclerView.setVisibility(View.VISIBLE); binding.requestsRecyclerView.setVisibility(View.VISIBLE);
binding.blockedRecyclerView.setVisibility(View.GONE);
binding.searchContainer.setVisibility(View.GONE); binding.searchContainer.setVisibility(View.GONE);
loadFriendRequests(); loadFriendRequests();
} else {
binding.friendsRecyclerView.setVisibility(View.GONE);
binding.requestsRecyclerView.setVisibility(View.GONE);
binding.blockedRecyclerView.setVisibility(View.VISIBLE);
binding.searchContainer.setVisibility(View.GONE);
loadBlockedList();
} }
} }
@ -445,8 +466,278 @@ public class MyFriendsActivity extends AppCompatActivity {
// 返回时刷新数据 // 返回时刷新数据
if (currentTab == 0) { if (currentTab == 0) {
loadFriendList(); loadFriendList();
} else { } else if (currentTab == 1) {
loadFriendRequests(); loadFriendRequests();
} else {
loadBlockedList();
}
}
// 显示好友操作对话框
private void showFriendOptionsDialog(FriendItem friend, int position) {
if (friend == null) return;
String[] options = {"删除好友", "拉黑"};
new AlertDialog.Builder(this)
.setTitle(friend.getName())
.setItems(options, (dialog, which) -> {
if (which == 0) {
// 删除好友
confirmDeleteFriend(friend, position);
} else if (which == 1) {
// 拉黑
confirmBlockFriend(friend, position);
}
})
.show();
}
// 显示黑名单操作对话框
private void showBlockedOptionsDialog(FriendItem blocked, int position) {
if (blocked == null) return;
new AlertDialog.Builder(this)
.setTitle(blocked.getName())
.setMessage("确定要取消拉黑吗?")
.setPositiveButton("确定", (dialog, which) -> unblockFriend(blocked, position))
.setNegativeButton("取消", null)
.show();
}
// 确认删除好友
private void confirmDeleteFriend(FriendItem friend, int position) {
new AlertDialog.Builder(this)
.setTitle("删除好友")
.setMessage("确定要删除好友 " + friend.getName() + " 吗?")
.setPositiveButton("确定", (dialog, which) -> deleteFriend(friend, position))
.setNegativeButton("取消", null)
.show();
}
// 确认拉黑好友
private void confirmBlockFriend(FriendItem friend, int position) {
new AlertDialog.Builder(this)
.setTitle("拉黑好友")
.setMessage("拉黑后将删除好友关系,确定要拉黑 " + friend.getName() + " 吗?")
.setPositiveButton("确定", (dialog, which) -> blockFriend(friend, position))
.setNegativeButton("取消", null)
.show();
}
// 删除好友
private void deleteFriend(FriendItem friend, int position) {
String token = AuthStore.getToken(this);
if (token == null) {
Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show();
return;
}
String url = ApiConfig.getBaseUrl() + "/api/front/friends/" + friend.getId();
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.delete()
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "删除好友失败", e);
runOnUiThread(() -> Toast.makeText(MyFriendsActivity.this, "删除失败,请重试", Toast.LENGTH_SHORT).show());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String body = response.body() != null ? response.body().string() : "";
runOnUiThread(() -> {
try {
JSONObject json = new JSONObject(body);
if (json.optInt("code", -1) == 200) {
Toast.makeText(MyFriendsActivity.this, "已删除好友", Toast.LENGTH_SHORT).show();
allFriends.remove(friend);
friendsAdapter.submitList(new ArrayList<>(allFriends));
updateEmptyState(allFriends);
} else {
String msg = json.optString("message", "删除失败");
Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析响应失败", e);
}
});
}
});
}
// 拉黑好友
private void blockFriend(FriendItem friend, int position) {
String token = AuthStore.getToken(this);
if (token == null) {
Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show();
return;
}
String url = ApiConfig.getBaseUrl() + "/api/front/friends/block/" + friend.getId();
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.post(RequestBody.create("", MediaType.parse("application/json")))
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "拉黑好友失败", e);
runOnUiThread(() -> Toast.makeText(MyFriendsActivity.this, "拉黑失败,请重试", Toast.LENGTH_SHORT).show());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String body = response.body() != null ? response.body().string() : "";
runOnUiThread(() -> {
try {
JSONObject json = new JSONObject(body);
if (json.optInt("code", -1) == 200) {
Toast.makeText(MyFriendsActivity.this, "已拉黑", Toast.LENGTH_SHORT).show();
allFriends.remove(friend);
friendsAdapter.submitList(new ArrayList<>(allFriends));
updateEmptyState(allFriends);
} else {
String msg = json.optString("message", "拉黑失败");
Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析响应失败", e);
}
});
}
});
}
// 取消拉黑
private void unblockFriend(FriendItem blocked, int position) {
String token = AuthStore.getToken(this);
if (token == null) {
Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show();
return;
}
String url = ApiConfig.getBaseUrl() + "/api/front/friends/unblock/" + blocked.getId();
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.post(RequestBody.create("", MediaType.parse("application/json")))
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "取消拉黑失败", e);
runOnUiThread(() -> Toast.makeText(MyFriendsActivity.this, "取消拉黑失败,请重试", Toast.LENGTH_SHORT).show());
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String body = response.body() != null ? response.body().string() : "";
runOnUiThread(() -> {
try {
JSONObject json = new JSONObject(body);
if (json.optInt("code", -1) == 200) {
Toast.makeText(MyFriendsActivity.this, "已取消拉黑", Toast.LENGTH_SHORT).show();
allBlocked.remove(blocked);
blockedAdapter.submitList(new ArrayList<>(allBlocked));
updateBlockedEmptyState(allBlocked);
} else {
String msg = json.optString("message", "取消拉黑失败");
Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析响应失败", e);
}
});
}
});
}
// 加载黑名单列表
private void loadBlockedList() {
String token = AuthStore.getToken(this);
if (token == null) {
showEmptyState("登录后查看黑名单");
return;
}
binding.loadingProgress.setVisibility(View.VISIBLE);
binding.emptyStateView.setVisibility(View.GONE);
String url = ApiConfig.getBaseUrl() + "/api/front/friends/blocked?page=1&pageSize=100";
Request request = new Request.Builder()
.url(url)
.addHeader("Authori-zation", token)
.get()
.build();
httpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
Log.e(TAG, "加载黑名单失败", e);
runOnUiThread(() -> {
binding.loadingProgress.setVisibility(View.GONE);
Toast.makeText(MyFriendsActivity.this, "加载失败,请重试", Toast.LENGTH_SHORT).show();
});
}
@Override
public void onResponse(Call call, Response response) throws IOException {
String body = response.body() != null ? response.body().string() : "";
runOnUiThread(() -> {
binding.loadingProgress.setVisibility(View.GONE);
try {
JSONObject json = new JSONObject(body);
if (json.optInt("code", -1) == 200) {
JSONObject data = json.optJSONObject("data");
JSONArray list = data != null ? data.optJSONArray("list") : null;
parseBlockedList(list);
} else {
String msg = json.optString("message", "加载失败");
Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show();
}
} catch (Exception e) {
Log.e(TAG, "解析黑名单失败", e);
}
});
}
});
}
private void parseBlockedList(JSONArray list) {
allBlocked.clear();
if (list != null) {
for (int i = 0; i < list.length(); i++) {
try {
JSONObject item = list.getJSONObject(i);
String id = String.valueOf(item.opt("id"));
String name = item.optString("name", "未知用户");
String avatarUrl = item.optString("avatarUrl", "");
String blockedTime = item.optString("blockedTime", "");
String subtitle = "拉黑时间: " + blockedTime;
allBlocked.add(new FriendItem(id, name, subtitle, false, avatarUrl));
} catch (Exception e) {
Log.e(TAG, "解析黑名单项失败", e);
}
}
}
blockedAdapter.submitList(new ArrayList<>(allBlocked));
updateBlockedEmptyState(allBlocked);
}
private void updateBlockedEmptyState(List<FriendItem> blocked) {
if (blocked == null || blocked.isEmpty()) {
showEmptyState("暂无黑名单");
} else {
if (binding.emptyStateView != null) {
binding.emptyStateView.setVisibility(View.GONE);
}
} }
} }
} }

View File

@ -88,6 +88,7 @@ public class ProfileActivity extends AppCompatActivity {
loadProfileFromPrefs(); loadProfileFromPrefs();
loadAndDisplayTags(); loadAndDisplayTags();
loadProfileInfo(); loadProfileInfo();
loadFollowStats(); // 加载关注统计
setupEditableAreas(); setupEditableAreas();
setupAvatarClick(); setupAvatarClick();
setupNavigationClicks(); setupNavigationClicks();
@ -507,6 +508,7 @@ public class ProfileActivity extends AppCompatActivity {
loadProfileFromPrefs(); loadProfileFromPrefs();
loadAndDisplayTags(); loadAndDisplayTags();
loadProfileInfo(); loadProfileInfo();
loadFollowStats(); // 刷新关注统计
loadWorks(); // 重新加载作品列表 loadWorks(); // 重新加载作品列表
BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation; BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation;
bottomNav.setSelectedItemId(R.id.nav_profile); bottomNav.setSelectedItemId(R.id.nav_profile);
@ -671,4 +673,44 @@ public class ProfileActivity extends AppCompatActivity {
String shareLink = ShareUtils.generateProfileShareLink(digits); String shareLink = ShareUtils.generateProfileShareLink(digits);
ShareUtils.shareLink(this, shareLink, "个人主页", "来看看我的主页吧"); ShareUtils.shareLink(this, shareLink, "个人主页", "来看看我的主页吧");
} }
/**
* 加载关注统计数据
*/
private void loadFollowStats() {
com.example.livestreaming.net.ApiService apiService =
com.example.livestreaming.net.RetrofitClient.getInstance(this).getApiService();
retrofit2.Call<com.example.livestreaming.net.ApiResponse<java.util.Map<String, Object>>> call =
apiService.getFollowStats(null); // null表示查询当前用户
call.enqueue(new retrofit2.Callback<com.example.livestreaming.net.ApiResponse<java.util.Map<String, Object>>>() {
@Override
public void onResponse(retrofit2.Call<com.example.livestreaming.net.ApiResponse<java.util.Map<String, Object>>> call,
retrofit2.Response<com.example.livestreaming.net.ApiResponse<java.util.Map<String, Object>>> response) {
if (response.isSuccessful() && response.body() != null) {
com.example.livestreaming.net.ApiResponse<java.util.Map<String, Object>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
java.util.Map<String, Object> stats = apiResponse.getData();
// 更新关注数
Object followingCount = stats.get("followingCount");
if (followingCount != null) {
binding.following.setText(String.valueOf(followingCount) + "\n关注");
}
// 更新粉丝数
Object followersCount = stats.get("followersCount");
if (followersCount != null) {
binding.followers.setText(String.valueOf(followersCount) + "\n粉丝");
}
}
}
}
@Override
public void onFailure(retrofit2.Call<com.example.livestreaming.net.ApiResponse<java.util.Map<String, Object>>> call, Throwable t) {
// 忽略错误使用默认显示
}
});
}
} }

View File

@ -31,6 +31,20 @@ import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.example.livestreaming.databinding.ActivityPublishWorkBinding; import com.example.livestreaming.databinding.ActivityPublishWorkBinding;
import com.example.livestreaming.databinding.ItemMediaPreviewBinding; import com.example.livestreaming.databinding.ItemMediaPreviewBinding;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ApiService;
import com.example.livestreaming.net.FileUploadResponse;
import com.example.livestreaming.net.WorksRequest;
import com.google.android.material.bottomsheet.BottomSheetDialog;
import java.io.File;
import java.util.ArrayList;
import java.util.List;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import retrofit2.Call;
import com.google.android.material.bottomsheet.BottomSheetDialog; import com.google.android.material.bottomsheet.BottomSheetDialog;
import java.io.File; import java.io.File;
@ -529,85 +543,311 @@ public class PublishWorkActivity extends AppCompatActivity {
return; return;
} }
// TODO: 接入后端接口 - 发布作品 // 检查登录状态
// 接口路径: POST /api/works if (!AuthHelper.requireLogin(this, "发布作品需要登录")) {
// 请求方法: POST return;
// 请求头:
// - Authorization: Bearer {token} (从AuthStore获取)
// 请求参数multipart/form-data:
// - title: String (必填) - 作品标题
// - description: String (可选) - 作品描述
// - type: String (必填) - 作品类型 "IMAGE" "VIDEO"
// - cover: File (必填) - 封面图片文件
// - video: File (可选) - 视频文件当type为VIDEO时必填
// - images: File[] (可选) - 图片文件数组当type为IMAGE时必填最多9张
// 返回数据格式: ApiResponse<WorkItem>
// WorkItem对象应包含:
// - id: String - 作品ID
// - title: String - 作品标题
// - description: String - 作品描述
// - coverUrl: String - 封面图片URL
// - videoUrl: String - 视频URL如果是视频作品
// - imageUrls: String[] - 图片URL数组如果是图片作品
// - likeCount: int - 点赞数
// - viewCount: int - 观看数
// - publishTime: long - 发布时间戳
// - type: String - 作品类型
// 实现步骤:
// 1. 显示上传进度可选
// 2. 先上传封面图片到文件服务器获取coverUrl
// 3. 如果是视频作品上传视频文件获取videoUrl
// 4. 如果是图片作品上传所有图片文件获取imageUrls数组
// 5. 调用发布接口传递titledescriptiontypecoverUrlvideoUrl或imageUrls
// 6. 发布成功后刷新作品列表调用ProfileActivity的loadWorks或发送广播通知
// 7. 关闭发布页面
// 错误处理:
// - 上传失败显示错误提示允许重试
// - 发布失败显示错误提示保留已上传的文件URL可选或删除已上传文件
// 注意:
// - 上传文件前需要压缩图片可选节省流量
// - 视频文件较大建议显示上传进度
// - 需要处理网络异常超时等情况
// 创建作品对象临时用于本地存储
WorkItem workItem = new WorkItem();
workItem.setTitle(title);
workItem.setDescription(description);
workItem.setType(currentWorkType);
if (currentWorkType == WorkItem.WorkType.VIDEO && selectedVideoUri != null) {
// 视频作品
workItem.setVideoUri(selectedVideoUri);
workItem.setCoverUri(selectedVideoUri);
// 将URI转换为字符串保存用于序列化
workItem.setVideoUrl(selectedVideoUri.toString());
workItem.setCoverUrl(selectedVideoUri.toString());
} else {
// 图片作品
workItem.setImageUris(new ArrayList<>(selectedMediaUris));
// 将URI列表转换为字符串列表保存用于序列化
List<String> imageUrlStrings = new ArrayList<>();
for (Uri uri : selectedMediaUris) {
if (uri != null) {
imageUrlStrings.add(uri.toString());
}
}
workItem.setImageUrls(imageUrlStrings);
// 使用选中的封面如果已选择否则使用第一张图片
Uri coverUri = selectedCoverUri != null ? selectedCoverUri :
(!selectedMediaUris.isEmpty() ? selectedMediaUris.get(0) : null);
if (coverUri != null) {
workItem.setCoverUri(coverUri);
workItem.setCoverUrl(coverUri.toString());
}
} }
// 临时保存到本地存储等待后端接口 // 显示加载对话框
WorkManager.saveWork(this, workItem); android.app.ProgressDialog progressDialog = new android.app.ProgressDialog(this);
progressDialog.setMessage("正在发布作品...");
progressDialog.setCancelable(false);
progressDialog.show();
Toast.makeText(this, "发布成功", Toast.LENGTH_SHORT).show(); // 开始上传流程
finish(); if (currentWorkType == WorkItem.WorkType.VIDEO && selectedVideoUri != null) {
// 视频作品先上传封面再上传视频最后发布
uploadCoverImage(selectedCoverUri != null ? selectedCoverUri : selectedVideoUri,
new UploadCallback() {
@Override
public void onSuccess(String url) {
String coverUrl = url;
// 上传视频
uploadVideo(selectedVideoUri, new UploadCallback() {
@Override
public void onSuccess(String videoUrl) {
// 发布作品
publishWorkToServer(title, description, "VIDEO", coverUrl, videoUrl, null, progressDialog);
}
@Override
public void onFailure(String error) {
progressDialog.dismiss();
Toast.makeText(PublishWorkActivity.this, "视频上传失败: " + error, Toast.LENGTH_SHORT).show();
}
});
}
@Override
public void onFailure(String error) {
progressDialog.dismiss();
Toast.makeText(PublishWorkActivity.this, "封面上传失败: " + error, Toast.LENGTH_SHORT).show();
}
});
} else {
// 图片作品先上传封面再上传所有图片最后发布
Uri coverUri = selectedCoverUri != null ? selectedCoverUri :
(!selectedMediaUris.isEmpty() ? selectedMediaUris.get(0) : null);
uploadCoverImage(coverUri, new UploadCallback() {
@Override
public void onSuccess(String coverUrl) {
// 上传所有图片
uploadImages(selectedMediaUris, new UploadImagesCallback() {
@Override
public void onSuccess(List<String> imageUrls) {
// 发布作品
publishWorkToServer(title, description, "IMAGE", coverUrl, null, imageUrls, progressDialog);
}
@Override
public void onFailure(String error) {
progressDialog.dismiss();
Toast.makeText(PublishWorkActivity.this, "图片上传失败: " + error, Toast.LENGTH_SHORT).show();
}
});
}
@Override
public void onFailure(String error) {
progressDialog.dismiss();
Toast.makeText(PublishWorkActivity.this, "封面上传失败: " + error, Toast.LENGTH_SHORT).show();
}
});
}
}
/**
* 上传封面图片
*/
private void uploadCoverImage(Uri imageUri, UploadCallback callback) {
if (imageUri == null) {
callback.onFailure("封面图片不能为空");
return;
}
try {
File file = getFileFromUri(imageUri);
if (file == null || !file.exists()) {
callback.onFailure("文件不存在");
return;
}
RequestBody requestFile = RequestBody.create(okhttp3.MediaType.parse("image/*"), file);
MultipartBody.Part body = MultipartBody.Part.createFormData("multipart", file.getName(), requestFile);
RequestBody model = RequestBody.create(okhttp3.MediaType.parse("text/plain"), "works");
RequestBody pid = RequestBody.create(okhttp3.MediaType.parse("text/plain"), "0");
ApiService apiService = ApiClient.getApiService(this);
Call<ApiResponse<FileUploadResponse>> call = apiService.uploadImage(body, model, pid);
call.enqueue(new retrofit2.Callback<ApiResponse<FileUploadResponse>>() {
@Override
public void onResponse(Call<ApiResponse<FileUploadResponse>> call, retrofit2.Response<ApiResponse<FileUploadResponse>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<FileUploadResponse> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
callback.onSuccess(apiResponse.getData().getUrl());
} else {
callback.onFailure(apiResponse.getMsg() != null ? apiResponse.getMsg() : "上传失败");
}
} else {
callback.onFailure("上传失败");
}
}
@Override
public void onFailure(Call<ApiResponse<FileUploadResponse>> call, Throwable t) {
callback.onFailure(t.getMessage());
}
});
} catch (Exception e) {
callback.onFailure(e.getMessage());
}
}
/**
* 上传视频
*/
private void uploadVideo(Uri videoUri, UploadCallback callback) {
if (videoUri == null) {
callback.onFailure("视频文件不能为空");
return;
}
try {
File file = getFileFromUri(videoUri);
if (file == null || !file.exists()) {
callback.onFailure("文件不存在");
return;
}
RequestBody requestFile = RequestBody.create(okhttp3.MediaType.parse("video/*"), file);
MultipartBody.Part body = MultipartBody.Part.createFormData("multipart", file.getName(), requestFile);
RequestBody model = RequestBody.create(okhttp3.MediaType.parse("text/plain"), "works");
RequestBody pid = RequestBody.create(okhttp3.MediaType.parse("text/plain"), "0");
ApiService apiService = ApiClient.getApiService(this);
Call<ApiResponse<FileUploadResponse>> call = apiService.uploadVideo(body, model, pid);
call.enqueue(new retrofit2.Callback<ApiResponse<FileUploadResponse>>() {
@Override
public void onResponse(Call<ApiResponse<FileUploadResponse>> call, retrofit2.Response<ApiResponse<FileUploadResponse>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<FileUploadResponse> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
callback.onSuccess(apiResponse.getData().getUrl());
} else {
callback.onFailure(apiResponse.getMsg() != null ? apiResponse.getMsg() : "上传失败");
}
} else {
callback.onFailure("上传失败");
}
}
@Override
public void onFailure(Call<ApiResponse<FileUploadResponse>> call, Throwable t) {
callback.onFailure(t.getMessage());
}
});
} catch (Exception e) {
callback.onFailure(e.getMessage());
}
}
/**
* 上传多张图片
*/
private void uploadImages(List<Uri> imageUris, UploadImagesCallback callback) {
if (imageUris == null || imageUris.isEmpty()) {
callback.onFailure("图片列表不能为空");
return;
}
List<String> uploadedUrls = new ArrayList<>();
uploadImageRecursive(imageUris, 0, uploadedUrls, callback);
}
private void uploadImageRecursive(List<Uri> imageUris, int index, List<String> uploadedUrls, UploadImagesCallback callback) {
if (index >= imageUris.size()) {
callback.onSuccess(uploadedUrls);
return;
}
uploadCoverImage(imageUris.get(index), new UploadCallback() {
@Override
public void onSuccess(String url) {
uploadedUrls.add(url);
uploadImageRecursive(imageUris, index + 1, uploadedUrls, callback);
}
@Override
public void onFailure(String error) {
callback.onFailure("" + (index + 1) + "张图片上传失败: " + error);
}
});
}
/**
* 发布作品到服务器
*/
private void publishWorkToServer(String title, String description, String type,
String coverUrl, String videoUrl, List<String> imageUrls,
android.app.ProgressDialog progressDialog) {
WorksRequest request = new WorksRequest();
request.setTitle(title);
request.setDescription(description);
request.setType(type);
request.setCoverUrl(coverUrl);
request.setVideoUrl(videoUrl);
request.setImageUrls(imageUrls);
ApiService apiService = ApiClient.getApiService(this);
Call<ApiResponse<Long>> call = apiService.publishWork(request);
call.enqueue(new retrofit2.Callback<ApiResponse<Long>>() {
@Override
public void onResponse(Call<ApiResponse<Long>> call, retrofit2.Response<ApiResponse<Long>> response) {
progressDialog.dismiss();
if (response.isSuccessful() && response.body() != null) {
ApiResponse<Long> apiResponse = response.body();
if (apiResponse.getCode() == 200) {
Toast.makeText(PublishWorkActivity.this, "发布成功", Toast.LENGTH_SHORT).show();
finish();
} else {
Toast.makeText(PublishWorkActivity.this,
apiResponse.getMsg() != null ? apiResponse.getMsg() : "发布失败",
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(PublishWorkActivity.this, "发布失败", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<Long>> call, Throwable t) {
progressDialog.dismiss();
Toast.makeText(PublishWorkActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
}
/**
* 从URI获取文件
*/
private File getFileFromUri(Uri uri) {
try {
String path = null;
// 尝试从URI获取真实路径
if ("content".equals(uri.getScheme())) {
Cursor cursor = getContentResolver().query(uri, new String[]{MediaStore.Images.Media.DATA}, null, null, null);
if (cursor != null) {
if (cursor.moveToFirst()) {
int columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA);
path = cursor.getString(columnIndex);
}
cursor.close();
}
} else if ("file".equals(uri.getScheme())) {
path = uri.getPath();
}
if (path != null) {
return new File(path);
}
// 如果无法获取路径复制文件到临时目录
File tempFile = new File(getCacheDir(), "temp_" + System.currentTimeMillis());
java.io.InputStream inputStream = getContentResolver().openInputStream(uri);
java.io.FileOutputStream outputStream = new java.io.FileOutputStream(tempFile);
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
inputStream.close();
outputStream.close();
return tempFile;
} catch (Exception e) {
android.util.Log.e("PublishWork", "获取文件失败", e);
return null;
}
}
// 上传回调接口
interface UploadCallback {
void onSuccess(String url);
void onFailure(String error);
}
interface UploadImagesCallback {
void onSuccess(List<String> urls);
void onFailure(String error);
} }
// 媒体预览适配器 // 媒体预览适配器

View File

@ -27,7 +27,14 @@ import androidx.recyclerview.widget.GridLayoutManager;
import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding; import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding;
import com.example.livestreaming.net.ApiClient; import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse; import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ApiService;
import com.example.livestreaming.net.AuthStore; import com.example.livestreaming.net.AuthStore;
import com.example.livestreaming.net.CreateRechargeRequest;
import com.example.livestreaming.net.CreateRechargeResponse;
import com.example.livestreaming.net.OrderPayRequest;
import com.example.livestreaming.net.OrderPayResultResponse;
import com.example.livestreaming.net.RechargeOptionResponse;
import com.example.livestreaming.net.RetrofitClient;
import com.example.livestreaming.net.Room; import com.example.livestreaming.net.Room;
import com.example.livestreaming.net.StreamConfig; import com.example.livestreaming.net.StreamConfig;
import com.example.livestreaming.ShareUtils; import com.example.livestreaming.ShareUtils;
@ -48,6 +55,10 @@ import okhttp3.WebSocketListener;
import okio.ByteString; import okio.ByteString;
import org.json.JSONException; import org.json.JSONException;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
import org.json.JSONObject; import org.json.JSONObject;
import retrofit2.Call; import retrofit2.Call;
@ -1072,24 +1083,13 @@ public class RoomDetailActivity extends AppCompatActivity {
RechargeAdapter rechargeAdapter = new RechargeAdapter(); RechargeAdapter rechargeAdapter = new RechargeAdapter();
recyclerView.setAdapter(rechargeAdapter); recyclerView.setAdapter(rechargeAdapter);
// TODO: 接入后端接口 - 获取充值选项列表
// 接口路径: GET /api/recharge/options
// 返回数据格式: ApiResponse<List<RechargeOption>>
// RechargeOption对象应包含: id, coinAmount, price, discountLabel等字段
List<RechargeOption> rechargeOptions = new ArrayList<>();
rechargeOptions.add(new RechargeOption("1", 100, 10.0, "首充优惠"));
rechargeOptions.add(new RechargeOption("2", 300, 30.0));
rechargeOptions.add(new RechargeOption("3", 500, 50.0, "热门"));
rechargeOptions.add(new RechargeOption("4", 1000, 100.0));
rechargeOptions.add(new RechargeOption("5", 3000, 300.0, "最划算"));
rechargeOptions.add(new RechargeOption("6", 5000, 500.0));
rechargeAdapter.setOptions(rechargeOptions);
// 显示当前余额 // 显示当前余额
android.widget.TextView currentBalance = dialogView.findViewById(R.id.currentBalance); android.widget.TextView currentBalance = dialogView.findViewById(R.id.currentBalance);
currentBalance.setText(String.valueOf(userCoinBalance)); currentBalance.setText(String.valueOf(userCoinBalance));
// 加载充值选项列表
loadRechargeOptions(rechargeAdapter, dialogView);
// 取消按钮 // 取消按钮
dialogView.findViewById(R.id.cancelButton).setOnClickListener(v -> rechargeDialog.dismiss()); dialogView.findViewById(R.id.cancelButton).setOnClickListener(v -> rechargeDialog.dismiss());
@ -1101,39 +1101,237 @@ public class RoomDetailActivity extends AppCompatActivity {
return; return;
} }
// TODO: 接入后端接口 - 发起充值请求 // 调用后端接口创建充值订单
// 接口路径: POST /api/recharge/create createRechargeOrder(selectedOption, rechargeDialog);
// 请求参数:
// - optionId: 充值选项ID
// - coinAmount: 金币数量
// - price: 价格
// 返回数据格式: ApiResponse<{orderId: string, paymentUrl: string}>
// 返回支付订单ID和支付URL跳转到支付页面或调用支付SDK
// TODO: 集成支付SDK微信支付支付宝等
// 1. 调用支付SDK发起支付
// 2. 监听支付结果回调
// 3. 支付成功后更新用户金币余额
// 模拟充值成功
userCoinBalance += selectedOption.getCoinAmount();
// 更新礼物弹窗中的余额显示
if (giftDialog != null && giftDialog.isShowing()) {
View giftView = giftDialog.findViewById(R.id.coinBalance);
if (giftView instanceof android.widget.TextView) {
((android.widget.TextView) giftView).setText(String.valueOf(userCoinBalance));
}
}
Toast.makeText(this,
String.format("充值成功!获得 %d 金币", selectedOption.getCoinAmount()),
Toast.LENGTH_SHORT).show();
rechargeDialog.dismiss();
}); });
rechargeDialog.show(); rechargeDialog.show();
} }
/**
* 加载充值选项列表
*/
private void loadRechargeOptions(RechargeAdapter adapter, View dialogView) {
ApiService apiService = RetrofitClient.getInstance(this).getApiService();
Call<ApiResponse<List<RechargeOptionResponse>>> call = apiService.getRechargeOptions();
call.enqueue(new Callback<ApiResponse<List<RechargeOptionResponse>>>() {
@Override
public void onResponse(Call<ApiResponse<List<RechargeOptionResponse>>> call,
Response<ApiResponse<List<RechargeOptionResponse>>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<List<RechargeOptionResponse>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
List<RechargeOption> options = new ArrayList<>();
for (RechargeOptionResponse optionResponse : apiResponse.getData()) {
RechargeOption option = new RechargeOption(
optionResponse.getId(),
optionResponse.getCoinAmount().intValue(),
optionResponse.getPrice().doubleValue(),
optionResponse.getDiscountLabel()
);
options.add(option);
}
adapter.setOptions(options);
} else {
Toast.makeText(RoomDetailActivity.this,
"加载充值选项失败: " + apiResponse.getMsg(),
Toast.LENGTH_SHORT).show();
// 使用默认选项
setDefaultRechargeOptions(adapter);
}
} else {
Toast.makeText(RoomDetailActivity.this,
"加载充值选项失败",
Toast.LENGTH_SHORT).show();
// 使用默认选项
setDefaultRechargeOptions(adapter);
}
}
@Override
public void onFailure(Call<ApiResponse<List<RechargeOptionResponse>>> call, Throwable t) {
Toast.makeText(RoomDetailActivity.this,
"网络错误: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
// 使用默认选项
setDefaultRechargeOptions(adapter);
}
});
}
/**
* 设置默认充值选项当接口失败时使用
*/
private void setDefaultRechargeOptions(RechargeAdapter adapter) {
List<RechargeOption> rechargeOptions = new ArrayList<>();
rechargeOptions.add(new RechargeOption("1", 100, 10.0, "首充优惠"));
rechargeOptions.add(new RechargeOption("2", 300, 30.0));
rechargeOptions.add(new RechargeOption("3", 500, 50.0, "热门"));
rechargeOptions.add(new RechargeOption("4", 1000, 100.0));
rechargeOptions.add(new RechargeOption("5", 3000, 300.0, "最划算"));
rechargeOptions.add(new RechargeOption("6", 5000, 500.0));
adapter.setOptions(rechargeOptions);
}
/**
* 创建充值订单
*/
private void createRechargeOrder(RechargeOption selectedOption, androidx.appcompat.app.AlertDialog rechargeDialog) {
ApiService apiService = RetrofitClient.getInstance(this).getApiService();
CreateRechargeRequest request = new CreateRechargeRequest(
Integer.parseInt(selectedOption.getId()),
new java.math.BigDecimal(selectedOption.getCoinAmount()),
new java.math.BigDecimal(selectedOption.getPrice())
);
Call<ApiResponse<CreateRechargeResponse>> call = apiService.createRecharge(request);
call.enqueue(new Callback<ApiResponse<CreateRechargeResponse>>() {
@Override
public void onResponse(Call<ApiResponse<CreateRechargeResponse>> call,
Response<ApiResponse<CreateRechargeResponse>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<CreateRechargeResponse> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
CreateRechargeResponse rechargeResponse = apiResponse.getData();
String orderId = rechargeResponse.getOrderId();
String paymentUrl = rechargeResponse.getPaymentUrl();
// 显示支付选择对话框
showPaymentMethodDialog(orderId, selectedOption, rechargeDialog);
} else {
Toast.makeText(RoomDetailActivity.this,
"创建充值订单失败: " + apiResponse.getMsg(),
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(RoomDetailActivity.this,
"创建充值订单失败",
Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<CreateRechargeResponse>> call, Throwable t) {
Toast.makeText(RoomDetailActivity.this,
"网络错误: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
}
});
}
/**
* 显示支付方式选择对话框
*/
private void showPaymentMethodDialog(String orderId, RechargeOption selectedOption,
androidx.appcompat.app.AlertDialog rechargeDialog) {
String[] paymentMethods = {"支付宝支付", "微信支付", "余额支付(模拟)"};
new MaterialAlertDialogBuilder(this)
.setTitle("选择支付方式")
.setItems(paymentMethods, (dialog, which) -> {
String payType;
String payChannel;
switch (which) {
case 0: // 支付宝
payType = "alipay";
payChannel = "appAliPay";
break;
case 1: // 微信
payType = "weixin";
payChannel = "weixinAppAndroid";
break;
case 2: // 余额支付模拟
// 模拟充值成功
simulateRechargeSuccess(selectedOption, rechargeDialog);
return;
default:
return;
}
// 调用支付接口
processPayment(orderId, payType, payChannel, selectedOption, rechargeDialog);
})
.setNegativeButton("取消", null)
.show();
}
/**
* 处理支付
*/
private void processPayment(String orderId, String payType, String payChannel,
RechargeOption selectedOption, androidx.appcompat.app.AlertDialog rechargeDialog) {
ApiService apiService = RetrofitClient.getInstance(this).getApiService();
OrderPayRequest payRequest = new OrderPayRequest(orderId, payType, payChannel);
Call<ApiResponse<OrderPayResultResponse>> call = apiService.payment(payRequest);
call.enqueue(new Callback<ApiResponse<OrderPayResultResponse>>() {
@Override
public void onResponse(Call<ApiResponse<OrderPayResultResponse>> call,
Response<ApiResponse<OrderPayResultResponse>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<OrderPayResultResponse> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
OrderPayResultResponse payResult = apiResponse.getData();
// TODO: 集成支付SDK微信支付支付宝等
// 1. 根据payType调用对应的支付SDK
// 2. 传入jsConfig参数发起支付
// 3. 监听支付结果回调
// 4. 支付成功后查询订单状态并更新余额
Toast.makeText(RoomDetailActivity.this,
"支付接口调用成功,订单号: " + payResult.getOrderNo() +
"\n请集成支付SDK完成实际支付",
Toast.LENGTH_LONG).show();
// 暂时模拟支付成功
simulateRechargeSuccess(selectedOption, rechargeDialog);
} else {
Toast.makeText(RoomDetailActivity.this,
"支付失败: " + apiResponse.getMsg(),
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(RoomDetailActivity.this,
"支付失败",
Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<OrderPayResultResponse>> call, Throwable t) {
Toast.makeText(RoomDetailActivity.this,
"网络错误: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
}
});
}
/**
* 模拟充值成功
*/
private void simulateRechargeSuccess(RechargeOption selectedOption,
androidx.appcompat.app.AlertDialog rechargeDialog) {
userCoinBalance += selectedOption.getCoinAmount();
// 更新礼物弹窗中的余额显示
if (giftDialog != null && giftDialog.isShowing()) {
View giftView = giftDialog.findViewById(R.id.coinBalance);
if (giftView instanceof android.widget.TextView) {
((android.widget.TextView) giftView).setText(String.valueOf(userCoinBalance));
}
}
Toast.makeText(this,
String.format("充值成功!获得 %d 金币", selectedOption.getCoinAmount()),
Toast.LENGTH_SHORT).show();
rechargeDialog.dismiss();
}
} }

View File

@ -5,24 +5,40 @@ import android.content.Intent;
import android.os.Bundle; import android.os.Bundle;
import android.text.Editable; import android.text.Editable;
import android.text.TextWatcher; import android.text.TextWatcher;
import android.util.Log;
import android.view.View; import android.view.View;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.StaggeredGridLayoutManager; import androidx.recyclerview.widget.StaggeredGridLayoutManager;
import com.example.livestreaming.databinding.ActivitySearchBinding; import com.example.livestreaming.databinding.ActivitySearchBinding;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ApiService;
import com.example.livestreaming.net.HotSearchResponse;
import com.example.livestreaming.net.PageResponse;
import com.example.livestreaming.net.RetrofitClient;
import com.example.livestreaming.net.Room; import com.example.livestreaming.net.Room;
import com.example.livestreaming.net.SearchHistoryResponse;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class SearchActivity extends AppCompatActivity { public class SearchActivity extends AppCompatActivity {
private static final String TAG = "SearchActivity";
private ActivitySearchBinding binding; private ActivitySearchBinding binding;
private RoomsAdapter adapter; private RoomsAdapter adapter;
private final List<Room> all = new ArrayList<>(); private final List<Room> all = new ArrayList<>();
private boolean isSearching = false;
private String lastSearchKeyword = "";
private static final String EXTRA_SEARCH_QUERY = "search_query"; private static final String EXTRA_SEARCH_QUERY = "search_query";
@ -50,11 +66,15 @@ public class SearchActivity extends AppCompatActivity {
binding.backButton.setOnClickListener(v -> finish()); binding.backButton.setOnClickListener(v -> finish());
binding.cancelBtn.setOnClickListener(v -> finish()); binding.cancelBtn.setOnClickListener(v -> finish());
// 如果从Intent中获取到搜索关键词自动填充到搜索框 // 如果从Intent中获取到搜索关键词自动填充到搜索框并执行搜索
String searchQuery = getIntent().getStringExtra(EXTRA_SEARCH_QUERY); String searchQuery = getIntent().getStringExtra(EXTRA_SEARCH_QUERY);
if (searchQuery != null && !searchQuery.trim().isEmpty()) { if (searchQuery != null && !searchQuery.trim().isEmpty()) {
binding.searchInput.setText(searchQuery); binding.searchInput.setText(searchQuery);
binding.searchInput.setSelection(searchQuery.length()); binding.searchInput.setSelection(searchQuery.length());
performSearch(searchQuery);
} else {
// 加载热门搜索
loadHotSearch();
} }
binding.searchInput.requestFocus(); binding.searchInput.requestFocus();
@ -72,24 +92,25 @@ public class SearchActivity extends AppCompatActivity {
glm.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS); glm.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS);
binding.resultsRecyclerView.setLayoutManager(glm); binding.resultsRecyclerView.setLayoutManager(glm);
binding.resultsRecyclerView.setAdapter(adapter); binding.resultsRecyclerView.setAdapter(adapter);
// TODO: 接入后端接口 - 搜索房间/主播
// 接口路径: GET /api/search
// 请求参数:
// - keyword: 搜索关键词必填
// - type (可选): 搜索类型room/user/all默认all
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<{rooms: Room[], users: User[]}>
// Room对象应包含: id, title, streamerName, type, isLive, coverUrl等字段
// User对象应包含: id, name, avatarUrl, bio, isLive等字段
all.clear();
all.addAll(buildDemoRooms(24));
adapter.submitList(new ArrayList<>(all));
} }
private void setupInput() { private void setupInput() {
binding.searchInput.setImeOptions(EditorInfo.IME_ACTION_SEARCH); binding.searchInput.setImeOptions(EditorInfo.IME_ACTION_SEARCH);
// 监听搜索按钮点击
binding.searchInput.setOnEditorActionListener((v, actionId, event) -> {
if (actionId == EditorInfo.IME_ACTION_SEARCH) {
String keyword = binding.searchInput.getText().toString().trim();
if (!keyword.isEmpty()) {
performSearch(keyword);
}
return true;
}
return false;
});
// 实时搜索建议可选暂时注释掉以避免频繁请求
/*
binding.searchInput.addTextChangedListener(new TextWatcher() { binding.searchInput.addTextChangedListener(new TextWatcher() {
@Override @Override
public void beforeTextChanged(CharSequence s, int start, int count, int after) { public void beforeTextChanged(CharSequence s, int start, int count, int after) {
@ -97,13 +118,193 @@ public class SearchActivity extends AppCompatActivity {
@Override @Override
public void onTextChanged(CharSequence s, int start, int before, int count) { public void onTextChanged(CharSequence s, int start, int before, int count) {
applyFilter(s != null ? s.toString() : ""); String keyword = s != null ? s.toString().trim() : "";
if (!keyword.isEmpty() && keyword.length() >= 2) {
loadSearchSuggestions(keyword);
}
} }
@Override @Override
public void afterTextChanged(Editable s) { public void afterTextChanged(Editable s) {
} }
}); });
*/
}
/**
* 执行搜索
*/
private void performSearch(String keyword) {
if (keyword == null || keyword.trim().isEmpty()) {
return;
}
keyword = keyword.trim();
// 避免重复搜索
if (keyword.equals(lastSearchKeyword) && isSearching) {
return;
}
lastSearchKeyword = keyword;
isSearching = true;
Log.d(TAG, "执行搜索: " + keyword);
ApiService apiService = RetrofitClient.getInstance(this).getApiService();
Call<ApiResponse<PageResponse<Map<String, Object>>>> call =
apiService.searchLiveRooms(keyword, null, null, 1, 20);
call.enqueue(new Callback<ApiResponse<PageResponse<Map<String, Object>>>>() {
@Override
public void onResponse(Call<ApiResponse<PageResponse<Map<String, Object>>>> call,
Response<ApiResponse<PageResponse<Map<String, Object>>>> response) {
isSearching = false;
if (response.isSuccessful() && response.body() != null) {
ApiResponse<PageResponse<Map<String, Object>>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
PageResponse<Map<String, Object>> pageResponse = apiResponse.getData();
List<Map<String, Object>> rooms = pageResponse.getList();
if (rooms != null && !rooms.isEmpty()) {
List<Room> roomList = new ArrayList<>();
for (Map<String, Object> roomData : rooms) {
Room room = parseRoomFromMap(roomData);
if (room != null) {
roomList.add(room);
}
}
all.clear();
all.addAll(roomList);
adapter.submitList(new ArrayList<>(all));
updateEmptyState(all);
Log.d(TAG, "搜索成功,找到 " + roomList.size() + " 个直播间");
} else {
all.clear();
adapter.submitList(new ArrayList<>());
updateEmptyState(all);
Log.d(TAG, "搜索结果为空");
}
} else {
Toast.makeText(SearchActivity.this,
"搜索失败: " + apiResponse.getMsg(),
Toast.LENGTH_SHORT).show();
Log.e(TAG, "搜索失败: " + apiResponse.getMsg());
}
} else {
Toast.makeText(SearchActivity.this,
"搜索失败,请稍后重试",
Toast.LENGTH_SHORT).show();
Log.e(TAG, "搜索请求失败: " + response.code());
}
}
@Override
public void onFailure(Call<ApiResponse<PageResponse<Map<String, Object>>>> call, Throwable t) {
isSearching = false;
Toast.makeText(SearchActivity.this,
"网络错误: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
Log.e(TAG, "搜索网络错误", t);
}
});
}
/**
* 从Map解析Room对象
*/
private Room parseRoomFromMap(Map<String, Object> data) {
try {
Object idObj = data.get("id");
String id = idObj != null ? String.valueOf(idObj) : null;
String title = (String) data.get("title");
String streamerName = (String) data.get("streamerName");
Object isLiveObj = data.get("isLive");
boolean isLive = false;
if (isLiveObj instanceof Boolean) {
isLive = (Boolean) isLiveObj;
} else if (isLiveObj instanceof Number) {
isLive = ((Number) isLiveObj).intValue() == 1;
}
String coverUrl = (String) data.get("coverUrl");
Room room = new Room(id, title, streamerName, isLive);
if (coverUrl != null) {
room.setCoverUrl(coverUrl);
}
return room;
} catch (Exception e) {
Log.e(TAG, "解析Room数据失败", e);
return null;
}
}
/**
* 加载热门搜索
*/
private void loadHotSearch() {
Log.d(TAG, "加载热门搜索");
ApiService apiService = RetrofitClient.getInstance(this).getApiService();
Call<ApiResponse<List<HotSearchResponse>>> call = apiService.getHotSearch(2, 10); // 2-直播间
call.enqueue(new Callback<ApiResponse<List<HotSearchResponse>>>() {
@Override
public void onResponse(Call<ApiResponse<List<HotSearchResponse>>> call,
Response<ApiResponse<List<HotSearchResponse>>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<List<HotSearchResponse>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
List<HotSearchResponse> hotSearchList = apiResponse.getData();
Log.d(TAG, "热门搜索加载成功: " + hotSearchList.size() + "");
// TODO: 可以在UI上显示热门搜索标签
}
}
}
@Override
public void onFailure(Call<ApiResponse<List<HotSearchResponse>>> call, Throwable t) {
Log.e(TAG, "加载热门搜索失败", t);
}
});
}
/**
* 加载搜索建议可选功能
*/
private void loadSearchSuggestions(String keyword) {
ApiService apiService = RetrofitClient.getInstance(this).getApiService();
Call<ApiResponse<List<String>>> call = apiService.getSearchSuggestions(keyword, 2, 10); // 2-直播间
call.enqueue(new Callback<ApiResponse<List<String>>>() {
@Override
public void onResponse(Call<ApiResponse<List<String>>> call,
Response<ApiResponse<List<String>>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<List<String>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
List<String> suggestions = apiResponse.getData();
Log.d(TAG, "搜索建议: " + suggestions.size() + "");
// TODO: 可以在UI上显示搜索建议下拉列表
}
}
}
@Override
public void onFailure(Call<ApiResponse<List<String>>> call, Throwable t) {
Log.e(TAG, "加载搜索建议失败", t);
}
});
} }
private void applyFilter(String q) { private void applyFilter(String q) {
@ -159,16 +360,4 @@ public class SearchActivity extends AppCompatActivity {
} }
} }
} }
private List<Room> buildDemoRooms(int count) {
List<Room> list = new ArrayList<>();
for (int i = 0; i < count; i++) {
String id = "search-" + i;
String title = "热门直播间 " + (i + 1);
String streamer = "主播" + (i + 1);
boolean live = i % 5 != 0;
list.add(new Room(id, title, streamer, live));
}
return list;
}
} }

View File

@ -10,10 +10,19 @@ import androidx.appcompat.app.AppCompatActivity;
import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.GridLayoutManager;
import com.example.livestreaming.databinding.ActivityUserProfileReadOnlyBinding; import com.example.livestreaming.databinding.ActivityUserProfileReadOnlyBinding;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ApiService;
import com.example.livestreaming.net.RetrofitClient;
import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayout;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class UserProfileReadOnlyActivity extends AppCompatActivity { public class UserProfileReadOnlyActivity extends AppCompatActivity {
@ -27,7 +36,8 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
private static final String EXTRA_BIO = "extra_bio"; private static final String EXTRA_BIO = "extra_bio";
private static final String EXTRA_AVATAR_RES = "extra_avatar_res"; private static final String EXTRA_AVATAR_RES = "extra_avatar_res";
private static final String PREFS_FRIENDS = "friends_prefs"; private String currentUserId;
private boolean isFollowing = false;
public static void start(Context context, String userId, String name, String location, String bio, int avatarRes) { public static void start(Context context, String userId, String name, String location, String bio, int avatarRes) {
Intent intent = new Intent(context, UserProfileReadOnlyActivity.class); Intent intent = new Intent(context, UserProfileReadOnlyActivity.class);
@ -47,7 +57,7 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
binding.backButton.setOnClickListener(v -> finish()); binding.backButton.setOnClickListener(v -> finish());
String userId = getIntent().getStringExtra(EXTRA_USER_ID); currentUserId = getIntent().getStringExtra(EXTRA_USER_ID);
String name = getIntent().getStringExtra(EXTRA_NAME); String name = getIntent().getStringExtra(EXTRA_NAME);
String location = getIntent().getStringExtra(EXTRA_LOCATION); String location = getIntent().getStringExtra(EXTRA_LOCATION);
String bio = getIntent().getStringExtra(EXTRA_BIO); String bio = getIntent().getStringExtra(EXTRA_BIO);
@ -66,35 +76,165 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
}); });
setupTabsAndWorks(); setupTabsAndWorks();
bindDemoStatsAndWorks(userId); bindDemoStatsAndWorks(currentUserId);
boolean isFriend = isFriend(userId); // 检查关注状态
updateAddFriendButton(isFriend); checkFollowStatus();
// TODO: 接入后端接口 - 发送好友请求 // 关注/取消关注按钮点击事件
// 接口路径: POST /api/friends/request
// 请求参数:
// - targetUserId: 目标用户ID
// - userId: 当前用户ID从token中获取
// 返回数据格式: ApiResponse<{success: boolean, message: string}>
// 发送成功后更新按钮状态为"已发送""已添加"
binding.addFriendButton.setOnClickListener(v -> { binding.addFriendButton.setOnClickListener(v -> {
if (TextUtils.isEmpty(userId)) { if (TextUtils.isEmpty(currentUserId)) {
Toast.makeText(this, "用户信息缺失", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "用户信息缺失", Toast.LENGTH_SHORT).show();
return; return;
} }
boolean now = isFriend(userId); if (isFollowing) {
if (!now) { unfollowUser();
setFriend(userId, true);
updateAddFriendButton(true);
Toast.makeText(this, "已发送好友请求", Toast.LENGTH_SHORT).show();
} else { } else {
Toast.makeText(this, "已添加", Toast.LENGTH_SHORT).show(); followUser();
} }
}); });
} }
private void checkFollowStatus() {
if (TextUtils.isEmpty(currentUserId)) return;
try {
int userId = Integer.parseInt(currentUserId);
ApiService apiService = RetrofitClient.getInstance(this).getApiService();
Call<ApiResponse<Map<String, Object>>> call = apiService.checkFollowStatus(userId);
call.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
Response<ApiResponse<Map<String, Object>>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<Map<String, Object>> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
Map<String, Object> data = apiResponse.getData();
Boolean following = (Boolean) data.get("isFollowing");
isFollowing = following != null && following;
updateFollowButton();
}
}
}
@Override
public void onFailure(Call<ApiResponse<Map<String, Object>>> call, Throwable t) {
// 忽略错误使用默认状态
}
});
} catch (NumberFormatException e) {
// 忽略错误
}
}
private void followUser() {
if (TextUtils.isEmpty(currentUserId)) return;
try {
int userId = Integer.parseInt(currentUserId);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("userId", userId);
ApiService apiService = RetrofitClient.getInstance(this).getApiService();
Call<ApiResponse<Map<String, Object>>> call = apiService.followUser(requestBody);
call.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
Response<ApiResponse<Map<String, Object>>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<Map<String, Object>> apiResponse = response.body();
if (apiResponse.getCode() == 200) {
isFollowing = true;
updateFollowButton();
Toast.makeText(UserProfileReadOnlyActivity.this, "关注成功", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(UserProfileReadOnlyActivity.this,
apiResponse.getMsg() != null ? apiResponse.getMsg() : "关注失败",
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(UserProfileReadOnlyActivity.this, "网络请求失败", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<Map<String, Object>>> call, Throwable t) {
Toast.makeText(UserProfileReadOnlyActivity.this, "网络错误: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
}
});
} catch (NumberFormatException e) {
Toast.makeText(this, "用户ID格式错误", Toast.LENGTH_SHORT).show();
}
}
private void unfollowUser() {
if (TextUtils.isEmpty(currentUserId)) return;
try {
int userId = Integer.parseInt(currentUserId);
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("userId", userId);
ApiService apiService = RetrofitClient.getInstance(this).getApiService();
Call<ApiResponse<Map<String, Object>>> call = apiService.unfollowUser(requestBody);
call.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
Response<ApiResponse<Map<String, Object>>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<Map<String, Object>> apiResponse = response.body();
if (apiResponse.getCode() == 200) {
isFollowing = false;
updateFollowButton();
Toast.makeText(UserProfileReadOnlyActivity.this, "取消关注成功", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(UserProfileReadOnlyActivity.this,
apiResponse.getMsg() != null ? apiResponse.getMsg() : "取消关注失败",
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(UserProfileReadOnlyActivity.this, "网络请求失败", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<Map<String, Object>>> call, Throwable t) {
Toast.makeText(UserProfileReadOnlyActivity.this, "网络错误: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
}
});
} catch (NumberFormatException e) {
Toast.makeText(this, "用户ID格式错误", Toast.LENGTH_SHORT).show();
}
}
private void updateFollowButton() {
if (binding == null) return;
if (isFollowing) {
binding.addFriendButton.setText("已关注");
binding.addFriendButton.setAlpha(0.7f);
} else {
binding.addFriendButton.setText("关注");
binding.addFriendButton.setAlpha(1f);
}
}
private void updateFollowButton() {
if (binding == null) return;
if (isFollowing) {
binding.addFriendButton.setText("已关注");
binding.addFriendButton.setAlpha(0.7f);
} else {
binding.addFriendButton.setText("关注");
binding.addFriendButton.setAlpha(1f);
}
}
private void setupTabsAndWorks() { private void setupTabsAndWorks() {
if (binding == null) return; if (binding == null) return;
@ -132,27 +272,6 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
} }
private void showTab(int index) { private void showTab(int index) {
// TODO: 接入后端接口 - 获取其他用户的作品列表
// 接口路径: GET /api/users/{userId}/works
// 请求参数:
// - userId: 用户ID路径参数
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
// TODO: 接入后端接口 - 获取其他用户的收藏列表
// 接口路径: GET /api/users/{userId}/favorites
// 请求参数:
// - userId: 用户ID路径参数
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
// TODO: 接入后端接口 - 获取其他用户赞过的作品列表
// 接口路径: GET /api/users/{userId}/liked
// 请求参数:
// - userId: 用户ID路径参数
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
if (binding == null) return; if (binding == null) return;
// 标签页顺序0-作品, 1-收藏, 2-赞过 // 标签页顺序0-作品, 1-收藏, 2-赞过
binding.worksRecycler.setVisibility(index == 0 ? android.view.View.VISIBLE : android.view.View.GONE); binding.worksRecycler.setVisibility(index == 0 ? android.view.View.VISIBLE : android.view.View.GONE);
@ -161,21 +280,6 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
} }
private void bindDemoStatsAndWorks(String userId) { private void bindDemoStatsAndWorks(String userId) {
// TODO: 接入后端接口 - 获取其他用户资料和统计数据
// 接口路径: GET /api/users/{userId}/profile
// 请求参数:
// - userId: 用户ID路径参数
// 返回数据格式: ApiResponse<UserProfile>
// UserProfile对象应包含: id, name, avatarUrl, bio, location, worksCount, followingCount,
// followersCount, likesCount等字段
// TODO: 接入后端接口 - 获取用户作品列表
// 接口路径: GET /api/users/{userId}/works
// 请求参数:
// - userId: 用户ID路径参数
// - page (可选): 页码
// - pageSize (可选): 每页数量
// 返回数据格式: ApiResponse<List<WorkItem>>
// WorkItem对象应包含: id, coverUrl, title, likeCount, viewCount等字段
if (binding == null) return; if (binding == null) return;
String seed = !TextUtils.isEmpty(userId) ? userId : "demo"; String seed = !TextUtils.isEmpty(userId) ? userId : "demo";
@ -191,8 +295,7 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
binding.statFollowersValue.setText(String.valueOf(followers)); binding.statFollowersValue.setText(String.valueOf(followers));
binding.statLikesValue.setText(String.valueOf(likes)); binding.statLikesValue.setText(String.valueOf(likes));
// TODO: 接入后端接口 - 获取用户作品列表 // 创建演示作品列表
// 目前使用演示数据创建临时的WorkItem列表
List<WorkItem> works = new ArrayList<>(); List<WorkItem> works = new ArrayList<>();
int[] pool = new int[] { int[] pool = new int[] {
R.drawable.wish_tree_checker_backup, R.drawable.wish_tree_checker_backup,
@ -208,8 +311,6 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
work.setType(WorkItem.WorkType.IMAGE); work.setType(WorkItem.WorkType.IMAGE);
work.setLikeCount((h + i) % 100); work.setLikeCount((h + i) % 100);
work.setViewCount((h + i) % 500); work.setViewCount((h + i) % 500);
// 注意这里使用drawable资源ID作为临时方案
// 实际应该使用从服务器获取的WorkItem对象
works.add(work); works.add(work);
} }
if (worksAdapter != null) { if (worksAdapter != null) {
@ -221,25 +322,4 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity {
worksAdapter.submitList(works); worksAdapter.submitList(works);
} }
} }
private boolean isFriend(String userId) {
if (TextUtils.isEmpty(userId)) return false;
return getSharedPreferences(PREFS_FRIENDS, MODE_PRIVATE).getBoolean(userId, false);
}
private void setFriend(String userId, boolean value) {
if (TextUtils.isEmpty(userId)) return;
getSharedPreferences(PREFS_FRIENDS, MODE_PRIVATE).edit().putBoolean(userId, value).apply();
}
private void updateAddFriendButton(boolean isFriend) {
if (binding == null) return;
if (isFriend) {
binding.addFriendButton.setText("已添加");
binding.addFriendButton.setAlpha(0.7f);
} else {
binding.addFriendButton.setText("加好友");
binding.addFriendButton.setAlpha(1f);
}
}
} }

View File

@ -19,12 +19,19 @@ import androidx.viewpager2.widget.ViewPager2;
import com.bumptech.glide.Glide; import com.bumptech.glide.Glide;
import com.example.livestreaming.databinding.ActivityWorkDetailBinding; import com.example.livestreaming.databinding.ActivityWorkDetailBinding;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ApiService;
import com.example.livestreaming.net.AuthStore;
import com.example.livestreaming.net.WorksResponse;
import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.bottomsheet.BottomSheetDialog; import com.google.android.material.bottomsheet.BottomSheetDialog;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import retrofit2.Call;
public class WorkDetailActivity extends AppCompatActivity { public class WorkDetailActivity extends AppCompatActivity {
private ActivityWorkDetailBinding binding; private ActivityWorkDetailBinding binding;
@ -57,79 +64,8 @@ public class WorkDetailActivity extends AppCompatActivity {
return; return;
} }
// ============================================ // 加载作品详情
// TODO: 接入后端接口 - 获取作品详情 loadWorkDetail(workId);
// ============================================
// 接口路径: GET /api/works/{workId}
// 请求方法: GET
// 请求头:
// - Authorization: Bearer {token} (可选登录用户可获取更多信息)
// 路径参数:
// - workId: String (必填) - 作品ID
// 返回数据格式: ApiResponse<WorkDetailResponse>
//
// 后端需要返回的数据结构 (WorkDetailResponse):
// {
// "code": 200,
// "message": "success",
// "data": {
// "id": "String - 作品ID",
// "title": "String - 作品标题",
// "description": "String - 作品描述(可选)",
// "type": "String - 作品类型: IMAGE 或 VIDEO",
// "coverUrl": "String - 封面图片URL",
// "videoUrl": "String - 视频URL视频作品时必填",
// "imageUrls": ["String"] - 图片URL数组图片作品时必填最多9张",
// "likeCount": "int - 点赞数",
// "favoriteCount": "int - 收藏数",
// "commentCount": "int - 评论数",
// "viewCount": "int - 观看数",
// "publishTime": "long - 发布时间戳(毫秒)",
// "userId": "String - 作者用户ID",
// "userName": "String - 作者用户名",
// "userAvatar": "String - 作者头像URL",
// "isLiked": "boolean - 当前用户是否已点赞(需要登录)",
// "isFavorited": "boolean - 当前用户是否已收藏(需要登录)",
// "isOwner": "boolean - 是否是当前用户的作品(需要登录)"
// }
// }
//
// 前端需要传入的参数:
// - workId: String (从Intent中获取)
// - token: String (可选从AuthStore获取如果用户已登录)
//
// 实现步骤:
// 1. 显示加载状态ProgressBar或LoadingDialog
// 2. 从AuthStore获取token如果用户已登录
// 3. 调用接口 GET /api/works/{workId}携带token如果有
// 4. 解析返回数据更新workItem对象
// 5. 更新UI显示作品信息标题观看数点赞数收藏数评论数
// 6. 根据isLiked和isFavorited更新按钮状态
// 7. 根据isOwner决定是否显示编辑/删除按钮
// 8. 加载媒体内容图片或视频
// 9. 处理错误情况:
// - 401: 未登录可选不影响查看作品
// - 404: 作品不存在显示错误提示并关闭页面
// - 500: 服务器错误显示错误提示
// - 网络错误: 显示网络错误提示允许重试
//
// 注意:
// - 调用此接口可能会增加作品的观看数viewCount
// - 如果用户未登录isLikedisFavoritedisOwner字段可能为false或null
// - 图片/视频URL应该是完整的可访问URL支持HTTP/HTTPS
// 临时从本地存储加载等待后端接口
workItem = WorkManager.getWorkById(this, workId);
if (workItem == null) {
Toast.makeText(this, "作品不存在", Toast.LENGTH_SHORT).show();
finish();
return;
}
setupToolbar();
setupContent();
setupActionButtons();
setupActionButton();
} }
private void setupToolbar() { private void setupToolbar() {
@ -500,113 +436,136 @@ public class WorkDetailActivity extends AppCompatActivity {
} }
private void toggleLike() { private void toggleLike() {
// ============================================ // 检查登录状态
// TODO: 接入后端接口 - 点赞/取消点赞 if (!AuthHelper.requireLogin(this, "点赞需要登录")) {
// ============================================ return;
// 接口路径: POST /api/works/{workId}/like (点赞) DELETE /api/works/{workId}/like (取消点赞) }
// 请求方法: POST DELETE
// 请求头: try {
// - Authorization: Bearer {token} (必填需要登录) long worksId = Long.parseLong(workItem.getId());
// 路径参数: ApiService apiService = ApiClient.getApiService(this);
// - workId: String (必填) - 作品ID
// 返回数据格式: ApiResponse<LikeResponse> Call<ApiResponse<Boolean>> call;
// if (isLiked) {
// 后端需要返回的数据结构 (LikeResponse): // 取消点赞
// { call = apiService.unlikeWork(worksId);
// "code": 200, } else {
// "message": "success", // 点赞
// "data": { call = apiService.likeWork(worksId);
// "isLiked": "boolean - 当前点赞状态", }
// "likeCount": "int - 更新后的点赞数"
// } // 乐观更新UI
// } boolean oldLiked = isLiked;
// int oldCount = workItem.getLikeCount();
// 前端需要传入的参数: isLiked = !isLiked;
// - workId: String (从workItem.getId()获取) if (isLiked) {
// - token: String (必填从AuthStore获取) workItem.setLikeCount(workItem.getLikeCount() + 1);
// } else {
// 实现步骤: workItem.setLikeCount(Math.max(0, workItem.getLikeCount() - 1));
// 1. 检查用户是否登录未登录则提示需要登录 }
// 2. 根据当前isLiked状态决定调用点赞或取消点赞接口 updateLikeButton();
// 3. 如果isLiked为false调用 POST /api/works/{workId}/like
// 4. 如果isLiked为true调用 DELETE /api/works/{workId}/like call.enqueue(new retrofit2.Callback<ApiResponse<Boolean>>() {
// 5. 解析返回数据更新isLiked和likeCount @Override
// 6. 更新UI按钮颜色和点赞数 public void onResponse(Call<ApiResponse<Boolean>> call, retrofit2.Response<ApiResponse<Boolean>> response) {
// 7. 显示成功提示 if (response.isSuccessful() && response.body() != null) {
// 8. 处理错误情况: ApiResponse<Boolean> apiResponse = response.body();
// - 401: 未登录跳转到登录页 if (apiResponse.getCode() == 200) {
// - 404: 作品不存在显示错误提示 Toast.makeText(WorkDetailActivity.this,
// - 500: 服务器错误显示错误提示恢复原状态 isLiked ? "已点赞" : "已取消点赞",
// - 网络错误: 显示网络错误提示恢复原状态 Toast.LENGTH_SHORT).show();
// } else {
// 注意: // 恢复原状态
// - 需要先检查登录状态未登录用户不能点赞 isLiked = oldLiked;
// - 操作失败时需要恢复UI状态乐观更新需要回滚 workItem.setLikeCount(oldCount);
updateLikeButton();
// 临时实现等待后端接口 Toast.makeText(WorkDetailActivity.this,
isLiked = !isLiked; apiResponse.getMsg() != null ? apiResponse.getMsg() : "操作失败",
if (isLiked) { Toast.LENGTH_SHORT).show();
workItem.setLikeCount(workItem.getLikeCount() + 1); }
Toast.makeText(this, "已点赞", Toast.LENGTH_SHORT).show(); } else {
} else { // 恢复原状态
workItem.setLikeCount(Math.max(0, workItem.getLikeCount() - 1)); isLiked = oldLiked;
Toast.makeText(this, "已取消点赞", Toast.LENGTH_SHORT).show(); workItem.setLikeCount(oldCount);
updateLikeButton();
Toast.makeText(WorkDetailActivity.this, "操作失败", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<Boolean>> call, Throwable t) {
// 恢复原状态
isLiked = oldLiked;
workItem.setLikeCount(oldCount);
updateLikeButton();
Toast.makeText(WorkDetailActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
} catch (NumberFormatException e) {
Toast.makeText(this, "作品ID格式错误", Toast.LENGTH_SHORT).show();
} }
updateLikeButton();
} }
private void toggleFavorite() { private void toggleFavorite() {
// ============================================ // 检查登录状态
// TODO: 接入后端接口 - 收藏/取消收藏 if (!AuthHelper.requireLogin(this, "收藏需要登录")) {
// ============================================ return;
// 接口路径: POST /api/works/{workId}/favorite (收藏) DELETE /api/works/{workId}/favorite (取消收藏) }
// 请求方法: POST DELETE
// 请求头: try {
// - Authorization: Bearer {token} (必填需要登录) long worksId = Long.parseLong(workItem.getId());
// 路径参数: ApiService apiService = ApiClient.getApiService(this);
// - workId: String (必填) - 作品ID
// 返回数据格式: ApiResponse<FavoriteResponse> Call<ApiResponse<Boolean>> call;
// if (isFavorited) {
// 后端需要返回的数据结构 (FavoriteResponse): // 取消收藏
// { call = apiService.uncollectWork(worksId);
// "code": 200, } else {
// "message": "success", // 收藏
// "data": { call = apiService.collectWork(worksId);
// "isFavorited": "boolean - 当前收藏状态", }
// "favoriteCount": "int - 更新后的收藏数"
// } // 乐观更新UI
// } boolean oldFavorited = isFavorited;
// isFavorited = !isFavorited;
// 前端需要传入的参数: updateFavoriteButton();
// - workId: String (从workItem.getId()获取)
// - token: String (必填从AuthStore获取) call.enqueue(new retrofit2.Callback<ApiResponse<Boolean>>() {
// @Override
// 实现步骤: public void onResponse(Call<ApiResponse<Boolean>> call, retrofit2.Response<ApiResponse<Boolean>> response) {
// 1. 检查用户是否登录未登录则提示需要登录 if (response.isSuccessful() && response.body() != null) {
// 2. 根据当前isFavorited状态决定调用收藏或取消收藏接口 ApiResponse<Boolean> apiResponse = response.body();
// 3. 如果isFavorited为false调用 POST /api/works/{workId}/favorite if (apiResponse.getCode() == 200) {
// 4. 如果isFavorited为true调用 DELETE /api/works/{workId}/favorite Toast.makeText(WorkDetailActivity.this,
// 5. 解析返回数据更新isFavorited和favoriteCount isFavorited ? "已收藏" : "已取消收藏",
// 6. 更新UI按钮颜色和收藏数 Toast.LENGTH_SHORT).show();
// 7. 显示成功提示 } else {
// 8. 处理错误情况: // 恢复原状态
// - 401: 未登录跳转到登录页 isFavorited = oldFavorited;
// - 404: 作品不存在显示错误提示 updateFavoriteButton();
// - 500: 服务器错误显示错误提示恢复原状态 Toast.makeText(WorkDetailActivity.this,
// - 网络错误: 显示网络错误提示恢复原状态 apiResponse.getMsg() != null ? apiResponse.getMsg() : "操作失败",
// Toast.LENGTH_SHORT).show();
// 注意: }
// - 需要先检查登录状态未登录用户不能收藏 } else {
// - 操作失败时需要恢复UI状态乐观更新需要回滚 // 恢复原状态
isFavorited = oldFavorited;
// 临时实现等待后端接口 updateFavoriteButton();
isFavorited = !isFavorited; Toast.makeText(WorkDetailActivity.this, "操作失败", Toast.LENGTH_SHORT).show();
if (isFavorited) { }
Toast.makeText(this, "已收藏", Toast.LENGTH_SHORT).show(); }
} else {
Toast.makeText(this, "已取消收藏", Toast.LENGTH_SHORT).show(); @Override
public void onFailure(Call<ApiResponse<Boolean>> call, Throwable t) {
// 恢复原状态
isFavorited = oldFavorited;
updateFavoriteButton();
Toast.makeText(WorkDetailActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
} catch (NumberFormatException e) {
Toast.makeText(this, "作品ID格式错误", Toast.LENGTH_SHORT).show();
} }
updateFavoriteButton();
} }
private void setupActionButton() { private void setupActionButton() {
@ -733,58 +692,41 @@ public class WorkDetailActivity extends AppCompatActivity {
.setTitle("删除作品") .setTitle("删除作品")
.setMessage("确定要删除这个作品吗?") .setMessage("确定要删除这个作品吗?")
.setPositiveButton("删除", (dialog, which) -> { .setPositiveButton("删除", (dialog, which) -> {
// ============================================ // 检查登录状态
// TODO: 接入后端接口 - 删除作品 if (!AuthHelper.requireLogin(this, "删除作品需要登录")) {
// ============================================ return;
// 接口路径: DELETE /api/works/{workId} }
// 请求方法: DELETE
// 请求头:
// - Authorization: Bearer {token} (必填需要登录)
// 路径参数:
// - workId: String (必填) - 作品ID
// 返回数据格式: ApiResponse<Object>
//
// 后端需要返回的数据结构:
// {
// "code": 200,
// "message": "删除成功",
// "data": null
// }
//
// 前端需要传入的参数:
// - workId: String (从workItem.getId()获取)
// - token: String (必填从AuthStore获取)
//
// 实现步骤:
// 1. 显示删除确认对话框已实现
// 2. 用户确认后显示加载状态
// 3. 从AuthStore获取token
// 4. 调用 DELETE /api/works/{workId}携带token
// 5. 删除成功
// - 显示成功提示
// - 关闭当前页面
// - 发送广播或回调通知作品列表页面刷新可选
// 6. 删除失败显示错误提示
//
// 错误处理:
// - 401: 未登录跳转到登录页
// - 403: 无权限不是作品作者显示错误提示"无权限删除此作品"
// - 404: 作品不存在显示错误提示"作品不存在"关闭页面
// - 500: 服务器错误显示错误提示"删除失败,请稍后重试"
// - 网络错误: 显示网络错误提示允许重试
//
// 注意:
// - 删除后服务器会删除关联的图片/视频文件
// - 删除操作不可恢复需要确认对话框
// - 只有作品作者才能删除
// 临时从本地存储删除等待后端接口 try {
boolean deleted = WorkManager.deleteWork(this, workItem.getId()); long worksId = Long.parseLong(workItem.getId());
if (deleted) { ApiService apiService = ApiClient.getApiService(this);
Toast.makeText(this, "删除成功", Toast.LENGTH_SHORT).show(); Call<ApiResponse<Boolean>> call = apiService.deleteWork(worksId);
finish();
} else { call.enqueue(new retrofit2.Callback<ApiResponse<Boolean>>() {
Toast.makeText(this, "删除失败", Toast.LENGTH_SHORT).show(); @Override
public void onResponse(Call<ApiResponse<Boolean>> call, retrofit2.Response<ApiResponse<Boolean>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<Boolean> apiResponse = response.body();
if (apiResponse.getCode() == 200 && Boolean.TRUE.equals(apiResponse.getData())) {
Toast.makeText(WorkDetailActivity.this, "删除成功", Toast.LENGTH_SHORT).show();
finish();
} else {
Toast.makeText(WorkDetailActivity.this,
apiResponse.getMsg() != null ? apiResponse.getMsg() : "删除失败",
Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(WorkDetailActivity.this, "删除失败", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<Boolean>> call, Throwable t) {
Toast.makeText(WorkDetailActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
} catch (NumberFormatException e) {
Toast.makeText(this, "作品ID格式错误", Toast.LENGTH_SHORT).show();
} }
}) })
.setNegativeButton("取消", null) .setNegativeButton("取消", null)
@ -837,5 +779,102 @@ public class WorkDetailActivity extends AppCompatActivity {
} }
} }
} }
/**
* 加载作品详情
*/
private void loadWorkDetail(String workId) {
try {
long worksId = Long.parseLong(workId);
ApiService apiService = ApiClient.getApiService(this);
Call<ApiResponse<WorksResponse>> call = apiService.getWorkDetail(worksId);
call.enqueue(new retrofit2.Callback<ApiResponse<WorksResponse>>() {
@Override
public void onResponse(Call<ApiResponse<WorksResponse>> call, retrofit2.Response<ApiResponse<WorksResponse>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<WorksResponse> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
WorksResponse worksResponse = apiResponse.getData();
// 转换为WorkItem对象
workItem = convertToWorkItem(worksResponse);
// 更新状态
isLiked = worksResponse.getIsLiked();
isFavorited = worksResponse.getIsCollected();
commentCount = worksResponse.getCommentCount();
// 设置UI
setupToolbar();
setupContent();
setupActionButtons();
setupActionButton();
} else {
Toast.makeText(WorkDetailActivity.this,
apiResponse.getMsg() != null ? apiResponse.getMsg() : "获取作品详情失败",
Toast.LENGTH_SHORT).show();
finish();
}
} else {
Toast.makeText(WorkDetailActivity.this, "获取作品详情失败", Toast.LENGTH_SHORT).show();
finish();
}
}
@Override
public void onFailure(Call<ApiResponse<WorksResponse>> call, Throwable t) {
Toast.makeText(WorkDetailActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
finish();
}
});
} catch (NumberFormatException e) {
Toast.makeText(this, "作品ID格式错误", Toast.LENGTH_SHORT).show();
finish();
}
}
/**
* 将WorksResponse转换为WorkItem
*/
private WorkItem convertToWorkItem(WorksResponse response) {
WorkItem item = new WorkItem();
item.setId(String.valueOf(response.getId()));
item.setTitle(response.getTitle());
item.setDescription(response.getDescription());
item.setLikeCount(response.getLikeCount());
item.setViewCount(response.getViewCount());
item.setPublishTime(response.getPublishTime());
// 设置作品类型
if ("VIDEO".equals(response.getType())) {
item.setType(WorkItem.WorkType.VIDEO);
item.setVideoUrl(response.getVideoUrl());
if (!TextUtils.isEmpty(response.getVideoUrl())) {
item.setVideoUri(Uri.parse(response.getVideoUrl()));
}
} else {
item.setType(WorkItem.WorkType.IMAGE);
item.setImageUrls(response.getImageUrls());
if (response.getImageUrls() != null && !response.getImageUrls().isEmpty()) {
List<Uri> imageUris = new ArrayList<>();
for (String url : response.getImageUrls()) {
if (!TextUtils.isEmpty(url)) {
imageUris.add(Uri.parse(url));
}
}
item.setImageUris(imageUris);
}
}
// 设置封面
item.setCoverUrl(response.getCoverUrl());
if (!TextUtils.isEmpty(response.getCoverUrl())) {
item.setCoverUri(Uri.parse(response.getCoverUrl()));
}
return item;
}
} }

View File

@ -132,6 +132,17 @@ public interface ApiService {
@DELETE("api/front/friends/{friendId}") @DELETE("api/front/friends/{friendId}")
Call<ApiResponse<Boolean>> deleteFriend(@Path("friendId") int friendId); Call<ApiResponse<Boolean>> deleteFriend(@Path("friendId") int friendId);
@POST("api/front/friends/block/{friendId}")
Call<ApiResponse<Boolean>> blockFriend(@Path("friendId") int friendId);
@POST("api/front/friends/unblock/{friendId}")
Call<ApiResponse<Boolean>> unblockFriend(@Path("friendId") int friendId);
@GET("api/front/friends/blocked")
Call<ApiResponse<PageResponse<FriendResponse>>> getBlockedList(
@Query("page") int page,
@Query("pageSize") int pageSize);
@GET("api/front/users/search") @GET("api/front/users/search")
Call<ApiResponse<PageResponse<SearchUserResponse>>> searchUsers( Call<ApiResponse<PageResponse<SearchUserResponse>>> searchUsers(
@Query("keyword") String keyword, @Query("keyword") String keyword,
@ -160,6 +171,13 @@ public interface ApiService {
@Part("model") RequestBody model, @Part("model") RequestBody model,
@Part("pid") RequestBody pid); @Part("pid") RequestBody pid);
@Multipart
@POST("api/front/upload/work/video")
Call<ApiResponse<FileUploadResponse>> uploadVideo(
@Part MultipartBody.Part file,
@Part("model") RequestBody model,
@Part("pid") RequestBody pid);
// ==================== 在线状态 ==================== // ==================== 在线状态 ====================
@GET("api/front/online/status/{userId}") @GET("api/front/online/status/{userId}")
@ -189,4 +207,157 @@ public interface ApiService {
@DELETE("api/front/online/offline/messages/{userId}") @DELETE("api/front/online/offline/messages/{userId}")
Call<ApiResponse<String>> clearOfflineMessages(@Path("userId") int userId); Call<ApiResponse<String>> clearOfflineMessages(@Path("userId") int userId);
// ==================== 消息表情回应 ====================
@POST("api/front/messages/reactions/add")
Call<ApiResponse<Boolean>> addMessageReaction(@Body Map<String, Object> body);
@DELETE("api/front/messages/reactions/remove")
Call<ApiResponse<Boolean>> removeMessageReaction(@Body Map<String, Object> body);
@GET("api/front/messages/{messageId}/reactions")
Call<ApiResponse<List<Map<String, Object>>>> getMessageReactions(@Path("messageId") String messageId);
@GET("api/front/messages/{messageId}/reactions/users")
Call<ApiResponse<List<Map<String, Object>>>> getReactionUsers(
@Path("messageId") String messageId,
@Query("emoji") String emoji);
// ==================== 关注功能 ====================
@POST("api/front/follow/follow")
Call<ApiResponse<Map<String, Object>>> followUser(@Body Map<String, Object> body);
@POST("api/front/follow/unfollow")
Call<ApiResponse<Map<String, Object>>> unfollowUser(@Body Map<String, Object> body);
@GET("api/front/follow/status/{userId}")
Call<ApiResponse<Map<String, Object>>> checkFollowStatus(@Path("userId") int userId);
@POST("api/front/follow/status/batch")
Call<ApiResponse<Map<String, Object>>> batchCheckFollowStatus(@Body Map<String, Object> body);
@GET("api/front/follow/following")
Call<ApiResponse<PageResponse<Map<String, Object>>>> getFollowingList(
@Query("page") int page,
@Query("pageSize") int pageSize);
@GET("api/front/follow/followers")
Call<ApiResponse<PageResponse<Map<String, Object>>>> getFollowersList(
@Query("page") int page,
@Query("pageSize") int pageSize);
@GET("api/front/follow/stats")
Call<ApiResponse<Map<String, Object>>> getFollowStats(@Query("userId") Integer userId);
// ==================== 作品管理 ====================
@POST("api/front/works/publish")
Call<ApiResponse<Long>> publishWork(@Body WorksRequest body);
@POST("api/front/works/update")
Call<ApiResponse<Boolean>> updateWork(@Body WorksRequest body);
@POST("api/front/works/delete/{worksId}")
Call<ApiResponse<Boolean>> deleteWork(@Path("worksId") long worksId);
@GET("api/front/works/detail/{worksId}")
Call<ApiResponse<WorksResponse>> getWorkDetail(@Path("worksId") long worksId);
@POST("api/front/works/search")
Call<ApiResponse<PageResponse<WorksResponse>>> searchWorks(@Body WorksSearchRequest body);
@GET("api/front/works/user/{userId}")
Call<ApiResponse<PageResponse<WorksResponse>>> getUserWorks(
@Path("userId") int userId,
@Query("page") int page,
@Query("pageSize") int pageSize);
@POST("api/front/works/like/{worksId}")
Call<ApiResponse<Boolean>> likeWork(@Path("worksId") long worksId);
@POST("api/front/works/unlike/{worksId}")
Call<ApiResponse<Boolean>> unlikeWork(@Path("worksId") long worksId);
@POST("api/front/works/collect/{worksId}")
Call<ApiResponse<Boolean>> collectWork(@Path("worksId") long worksId);
@POST("api/front/works/uncollect/{worksId}")
Call<ApiResponse<Boolean>> uncollectWork(@Path("worksId") long worksId);
@GET("api/front/works/my/liked")
Call<ApiResponse<PageResponse<WorksResponse>>> getMyLikedWorks(
@Query("page") int page,
@Query("pageSize") int pageSize);
@GET("api/front/works/my/collected")
Call<ApiResponse<PageResponse<WorksResponse>>> getMyCollectedWorks(
@Query("page") int page,
@Query("pageSize") int pageSize);
@POST("api/front/works/share/{worksId}")
Call<ApiResponse<Boolean>> shareWork(@Path("worksId") long worksId);
// ==================== 搜索功能 ====================
@GET("api/front/search/users")
Call<ApiResponse<PageResponse<Map<String, Object>>>> searchUsersGlobal(
@Query("keyword") String keyword,
@Query("pageNum") int pageNum,
@Query("pageSize") int pageSize);
@GET("api/front/search/live-rooms")
Call<ApiResponse<PageResponse<Map<String, Object>>>> searchLiveRooms(
@Query("keyword") String keyword,
@Query("categoryId") Integer categoryId,
@Query("isLive") Integer isLive,
@Query("pageNum") int pageNum,
@Query("pageSize") int pageSize);
@GET("api/front/search/works")
Call<ApiResponse<PageResponse<Map<String, Object>>>> searchWorksGlobal(
@Query("keyword") String keyword,
@Query("categoryId") Integer categoryId,
@Query("pageNum") int pageNum,
@Query("pageSize") int pageSize);
@GET("api/front/search/all")
Call<ApiResponse<Map<String, Object>>> searchAll(@Query("keyword") String keyword);
@GET("api/front/search/history")
Call<ApiResponse<List<SearchHistoryResponse>>> getSearchHistory(
@Query("searchType") Integer searchType,
@Query("limit") int limit);
@DELETE("api/front/search/history")
Call<ApiResponse<String>> clearSearchHistory(@Query("searchType") Integer searchType);
@DELETE("api/front/search/history/{historyId}")
Call<ApiResponse<String>> deleteSearchHistory(@Path("historyId") long historyId);
@GET("api/front/search/hot")
Call<ApiResponse<List<HotSearchResponse>>> getHotSearch(
@Query("searchType") int searchType,
@Query("limit") int limit);
@GET("api/front/search/suggestions")
Call<ApiResponse<List<String>>> getSearchSuggestions(
@Query("keyword") String keyword,
@Query("searchType") Integer searchType,
@Query("limit") int limit);
// ==================== 支付集成 ====================
@GET("api/front/gift/recharge/options")
Call<ApiResponse<List<RechargeOptionResponse>>> getRechargeOptions();
@POST("api/front/gift/recharge/create")
Call<ApiResponse<CreateRechargeResponse>> createRecharge(@Body CreateRechargeRequest body);
@GET("api/front/pay/alipay/queryPayResult")
Call<ApiResponse<Boolean>> queryAliPayResult(@Query("orderNo") String orderNo);
@POST("api/front/pay/payment")
Call<ApiResponse<OrderPayResultResponse>> payment(@Body OrderPayRequest body);
} }

View File

@ -0,0 +1,41 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
/**
* 热门搜索响应
*/
public class HotSearchResponse {
@SerializedName("keyword")
private String keyword;
@SerializedName("searchCount")
private Integer searchCount;
@SerializedName("searchType")
private Integer searchType; // 0-全部 1-用户 2-直播间 3-作品
public String getKeyword() {
return keyword;
}
public void setKeyword(String keyword) {
this.keyword = keyword;
}
public Integer getSearchCount() {
return searchCount;
}
public void setSearchCount(Integer searchCount) {
this.searchCount = searchCount;
}
public Integer getSearchType() {
return searchType;
}
public void setSearchType(Integer searchType) {
this.searchType = searchType;
}
}

View File

@ -0,0 +1,43 @@
package com.example.livestreaming.net;
/**
* 消息表情回应数据模型
*/
public class MessageReaction {
private String emoji; // 表情符号
private int count; // 该表情的数量
private boolean reactedByMe; // 当前用户是否已回应该表情
public MessageReaction() {
}
public MessageReaction(String emoji, int count, boolean reactedByMe) {
this.emoji = emoji;
this.count = count;
this.reactedByMe = reactedByMe;
}
public String getEmoji() {
return emoji;
}
public void setEmoji(String emoji) {
this.emoji = emoji;
}
public int getCount() {
return count;
}
public void setCount(int count) {
this.count = count;
}
public boolean isReactedByMe() {
return reactedByMe;
}
public void setReactedByMe(boolean reactedByMe) {
this.reactedByMe = reactedByMe;
}
}

View File

@ -0,0 +1,33 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
/**
* 订单支付请求
*/
public class OrderPayRequest {
@SerializedName("orderNo")
private String orderNo;
@SerializedName("payType")
private String payType; // weixin-微信支付, alipay-支付宝支付, yue-余额支付
@SerializedName("payChannel")
private String payChannel; // weixinAppAndroid-微信app安卓支付, appAliPay-App支付宝支付
@SerializedName("from")
private String from; // android
public OrderPayRequest(String orderNo, String payType, String payChannel) {
this.orderNo = orderNo;
this.payType = payType;
this.payChannel = payChannel;
this.from = "android";
}
public String getOrderNo() { return orderNo; }
public String getPayType() { return payType; }
public String getPayChannel() { return payChannel; }
public String getFrom() { return from; }
}

View File

@ -0,0 +1,26 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
/**
* 订单支付结果响应
*/
public class OrderPayResultResponse {
@SerializedName("status")
private Boolean status;
@SerializedName("payType")
private String payType;
@SerializedName("orderNo")
private String orderNo;
@SerializedName("jsConfig")
private Object jsConfig; // 微信/支付宝调起支付参数对象
public Boolean getStatus() { return status; }
public String getPayType() { return payType; }
public String getOrderNo() { return orderNo; }
public Object getJsConfig() { return jsConfig; }
}

View File

@ -0,0 +1,52 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
/**
* 搜索历史响应
*/
public class SearchHistoryResponse {
@SerializedName("id")
private Long id;
@SerializedName("keyword")
private String keyword;
@SerializedName("searchType")
private Integer searchType; // 1-用户 2-直播间 3-作品 4-消息
@SerializedName("createTime")
private String createTime;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getKeyword() {
return keyword;
}
public void setKeyword(String keyword) {
this.keyword = keyword;
}
public Integer getSearchType() {
return searchType;
}
public void setSearchType(Integer searchType) {
this.searchType = searchType;
}
public String getCreateTime() {
return createTime;
}
public void setCreateTime(String createTime) {
this.createTime = createTime;
}
}

View File

@ -0,0 +1,72 @@
package com.example.livestreaming.net;
import java.util.List;
/**
* 作品请求模型
*/
public class WorksRequest {
private Long id; // 作品ID编辑时需要
private String title; // 作品标题
private String description; // 作品描述
private String type; // 作品类型IMAGE VIDEO
private String coverUrl; // 封面图片URL
private String videoUrl; // 视频URL视频作品
private List<String> imageUrls; // 图片URL列表图片作品
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getCoverUrl() {
return coverUrl;
}
public void setCoverUrl(String coverUrl) {
this.coverUrl = coverUrl;
}
public String getVideoUrl() {
return videoUrl;
}
public void setVideoUrl(String videoUrl) {
this.videoUrl = videoUrl;
}
public List<String> getImageUrls() {
return imageUrls;
}
public void setImageUrls(List<String> imageUrls) {
this.imageUrls = imageUrls;
}
}

View File

@ -0,0 +1,180 @@
package com.example.livestreaming.net;
import java.util.List;
/**
* 作品响应模型
*/
public class WorksResponse {
private Long id; // 作品ID
private String title; // 作品标题
private String description; // 作品描述
private String type; // 作品类型IMAGE VIDEO
private String coverUrl; // 封面图片URL
private String videoUrl; // 视频URL视频作品
private List<String> imageUrls; // 图片URL列表图片作品
private Integer likeCount; // 点赞数
private Integer collectCount; // 收藏数
private Integer commentCount; // 评论数
private Integer viewCount; // 观看数
private Integer shareCount; // 分享数
private Long publishTime; // 发布时间戳
private Integer userId; // 作者用户ID
private String userName; // 作者用户名
private String userAvatar; // 作者头像URL
private Boolean isLiked; // 当前用户是否已点赞
private Boolean isCollected; // 当前用户是否已收藏
private Boolean isOwner; // 是否是当前用户的作品
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getCoverUrl() {
return coverUrl;
}
public void setCoverUrl(String coverUrl) {
this.coverUrl = coverUrl;
}
public String getVideoUrl() {
return videoUrl;
}
public void setVideoUrl(String videoUrl) {
this.videoUrl = videoUrl;
}
public List<String> getImageUrls() {
return imageUrls;
}
public void setImageUrls(List<String> imageUrls) {
this.imageUrls = imageUrls;
}
public Integer getLikeCount() {
return likeCount != null ? likeCount : 0;
}
public void setLikeCount(Integer likeCount) {
this.likeCount = likeCount;
}
public Integer getCollectCount() {
return collectCount != null ? collectCount : 0;
}
public void setCollectCount(Integer collectCount) {
this.collectCount = collectCount;
}
public Integer getCommentCount() {
return commentCount != null ? commentCount : 0;
}
public void setCommentCount(Integer commentCount) {
this.commentCount = commentCount;
}
public Integer getViewCount() {
return viewCount != null ? viewCount : 0;
}
public void setViewCount(Integer viewCount) {
this.viewCount = viewCount;
}
public Integer getShareCount() {
return shareCount != null ? shareCount : 0;
}
public void setShareCount(Integer shareCount) {
this.shareCount = shareCount;
}
public Long getPublishTime() {
return publishTime;
}
public void setPublishTime(Long publishTime) {
this.publishTime = publishTime;
}
public Integer getUserId() {
return userId;
}
public void setUserId(Integer userId) {
this.userId = userId;
}
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public String getUserAvatar() {
return userAvatar;
}
public void setUserAvatar(String userAvatar) {
this.userAvatar = userAvatar;
}
public Boolean getIsLiked() {
return isLiked != null ? isLiked : false;
}
public void setIsLiked(Boolean isLiked) {
this.isLiked = isLiked;
}
public Boolean getIsCollected() {
return isCollected != null ? isCollected : false;
}
public void setIsCollected(Boolean isCollected) {
this.isCollected = isCollected;
}
public Boolean getIsOwner() {
return isOwner != null ? isOwner : false;
}
public void setIsOwner(Boolean isOwner) {
this.isOwner = isOwner;
}
}

View File

@ -0,0 +1,61 @@
package com.example.livestreaming.net;
/**
* 作品搜索请求模型
*/
public class WorksSearchRequest {
private String keyword; // 搜索关键词
private String type; // 作品类型IMAGE VIDEO
private Integer page; // 页码
private Integer pageSize; // 每页数量
private String sortBy; // 排序字段publishTime, likeCount, viewCount
private String sortOrder; // 排序方式asc, desc
public String getKeyword() {
return keyword;
}
public void setKeyword(String keyword) {
this.keyword = keyword;
}
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public Integer getPage() {
return page;
}
public void setPage(Integer page) {
this.page = page;
}
public Integer getPageSize() {
return pageSize;
}
public void setPageSize(Integer pageSize) {
this.pageSize = pageSize;
}
public String getSortBy() {
return sortBy;
}
public void setSortBy(String sortBy) {
this.sortBy = sortBy;
}
public String getSortOrder() {
return sortOrder;
}
public void setSortOrder(String sortOrder) {
this.sortOrder = sortOrder;
}
}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@android:color/white"/>
<corners
android:topLeftRadius="16dp"
android:topRightRadius="16dp"/>
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F0F0F0"/>
<corners android:radius="12dp"/>
<stroke
android:width="1dp"
android:color="#E0E0E0"/>
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E3F2FD"/>
<corners android:radius="12dp"/>
<stroke
android:width="2dp"
android:color="#2196F3"/>
</shape>

View File

@ -136,6 +136,16 @@
android:visibility="gone" android:visibility="gone"
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/blockedRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:paddingTop="6dp"
android:paddingBottom="16dp"
android:visibility="gone"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<com.example.livestreaming.EmptyStateView <com.example.livestreaming.EmptyStateView
android:id="@+id/emptyStateView" android:id="@+id/emptyStateView"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@ -0,0 +1,175 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp"
android:background="@drawable/bottom_sheet_background">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="选择表情回应"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="@android:color/black"
android:gravity="center"
android:paddingBottom="16dp"/>
<GridLayout
android:id="@+id/emojiGrid"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:columnCount="6"
android:rowCount="3"
android:alignmentMode="alignBounds"
android:useDefaultMargins="true">
<!-- 常用表情 -->
<TextView
android:id="@+id/emoji_like"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="👍"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_love"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="❤️"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_laugh"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="😂"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_wow"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="😮"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_sad"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="😢"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_angry"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="😠"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_fire"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="🔥"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_clap"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="👏"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_thinking"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="🤔"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_party"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="🎉"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_star"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="⭐"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
<TextView
android:id="@+id/emoji_check"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_columnWeight="1"
android:text="✅"
android:textSize="32sp"
android:gravity="center"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"/>
</GridLayout>
</LinearLayout>

View File

@ -46,6 +46,14 @@
android:background="@drawable/message_bubble_incoming" android:background="@drawable/message_bubble_incoming"
android:lineSpacingExtra="2dp" /> android:lineSpacingExtra="2dp" />
<!-- 表情回应区域 -->
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/reactionsContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:visibility="gone"/>
<!-- 时间 --> <!-- 时间 -->
<TextView <TextView
android:id="@+id/timeText" android:id="@+id/timeText"

View File

@ -28,6 +28,14 @@
android:background="@drawable/message_bubble_outgoing" android:background="@drawable/message_bubble_outgoing"
android:lineSpacingExtra="2dp" /> android:lineSpacingExtra="2dp" />
<!-- 表情回应区域 -->
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/reactionsContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:visibility="gone"/>
<!-- 时间和状态 --> <!-- 时间和状态 -->
<LinearLayout <LinearLayout
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:background="@drawable/reaction_background"
android:padding="4dp"
android:layout_margin="2dp"
android:gravity="center"
android:clickable="true"
android:focusable="true">
<TextView
android:id="@+id/emojiText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="👍"
android:textSize="16sp"
android:paddingEnd="4dp"/>
<TextView
android:id="@+id/countText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="1"
android:textSize="12sp"
android:textColor="@android:color/darker_gray"/>
</LinearLayout>

View File

@ -1,235 +0,0 @@
# Android 直播应用新 UI 设计说明
## 🎨 设计概览
已将 Android 应用首页重新设计为现代化的瀑布流布局,参考了主流直播应用的设计风格。
---
## ✅ 已完成的修改
### 1. 新增布局文件
#### 主界面布局 (`activity_main.xml`)
- **顶部导航栏**
- 左侧:菜单图标
- 中间:关注/发现/附近 标签页
- 右侧:消息和通知图标
- **搜索栏**
- 搜索框 + 语音搜索按钮
- 圆角设计,灰色背景
- **分类标签**
- 横向滚动
- 推荐、颜值、才艺、户外、娱乐、聊天
- 选中状态:紫色背景 + 白色文字
- 未选中:灰色背景 + 灰色文字
- **内容区域**
- 瀑布流布局2列
- RecyclerView + StaggeredGridLayoutManager
- **底部导航栏**
- 首页、发现、开播(中间大按钮)、消息、我的
- 固定在底部,白色背景,带阴影
#### 直播间卡片布局 (`item_room_waterfall.xml`)
- **封面图**3:4 比例
- **直播标签**:左上角红色标签(直播中时显示)
- **观看人数**:右上角半透明黑色背景
- **底部信息**
- 标题最多2行
- 主播头像 + 名称
- 热门标签(观看人数>100时显示
### 2. 新增 Drawable 资源
| 文件名 | 用途 |
|--------|------|
| `search_background.xml` | 搜索框背景(圆角灰色) |
| `category_selected.xml` | 选中的分类标签背景(紫色) |
| `category_normal.xml` | 未选中的分类标签背景(灰色) |
| `live_badge_background.xml` | 直播标签背景(红色) |
| `viewer_count_background.xml` | 观看人数背景(半透明黑色) |
| `hot_badge_background.xml` | 热门标签背景(红色边框) |
### 3. 新增 Java 类
#### `WaterfallRoomsAdapter.java`
- 瀑布流适配器
- 支持直播状态显示
- 支持观看人数显示
- 支持热门标签(观看人数>100
### 4. 修改的文件
#### `MainActivity.java`
- 使用 `WaterfallRoomsAdapter` 替代 `RoomsAdapter`
- 使用 `StaggeredGridLayoutManager` 实现瀑布流2列
- 保持原有的轮询和点击逻辑
#### `themes.xml`
- 添加 `CategoryChip` 样式
- 修改状态栏和导航栏颜色为白色
- 启用浅色状态栏图标
---
## 🎨 设计细节
### 颜色方案
- **主色调**:紫色 (`#6200EE`)
- **强调色**:红色 (`#E53935`) - 用于直播标签
- **背景色**:浅灰 (`#F5F5F5`)
- **文字色**
- 主要文字:`#333333`
- 次要文字:`#666666`
- 提示文字:`#999999`
### 圆角设计
- 搜索框24dp
- 分类标签16dp
- 直播间卡片12dp
- 直播标签4dp
### 间距规范
- 页面边距16dp
- 卡片间距8dp
- 元素内边距12dp
---
## 📱 功能说明
### 当前已实现
- ✅ 瀑布流布局展示直播间
- ✅ 直播状态标签
- ✅ 观看人数显示
- ✅ 点击进入直播间
- ✅ 自动刷新列表5秒轮询
- ✅ 开播按钮(底部导航中间)
### 待实现UI已准备好
- ⏳ 搜索功能
- ⏳ 语音搜索
- ⏳ 分类筛选
- ⏳ 关注/发现/附近 标签页切换
- ⏳ 消息和通知功能
- ⏳ 底部导航其他页面
---
## 🔧 如何编译和运行
### 1. 清理项目
```bash
cd android-app
gradlew clean
```
### 2. 编译
```bash
gradlew assembleDebug
```
### 3. 安装到模拟器
```bash
gradlew installDebug
```
或者在 Android Studio 中直接点击 ▶️ Run
---
## 📸 布局预览
### 主界面结构
```
┌─────────────────────────────┐
│ ☰ 关注 发现 附近 ✉ 🔔 │ ← 顶部导航
├─────────────────────────────┤
│ 🔍 搜索主播/房间/标签 🎤 │ ← 搜索栏
├─────────────────────────────┤
│ [推荐][颜值][才艺][户外]... │ ← 分类标签
├─────────────────────────────┤
│ ┌──────┐ ┌──────┐ │
│ │ 封面 │ │ 封面 │ │
│ │ 图片 │ │ 图片 │ │
│ │ │ │ │ │ ← 瀑布流内容
│ │ 标题 │ │ 标题 │ │
│ │ 主播 │ │ 主播 │ │
│ └──────┘ └──────┘ │
│ ┌──────┐ ┌──────┐ │
│ │ ... │ │ ... │ │
└─────────────────────────────┘
│ 🏠 🔍 💬 👤 │ ← 底部导航
└─────────────────────────────┘
```
### 直播间卡片结构
```
┌─────────────────┐
│ [直播中] 👁 123│ ← 标签和观看人数
│ │
│ 封面图片 │
│ │
│ │
├─────────────────┤
│ 直播间标题 │
│ 👤 主播名称 [热] │ ← 主播信息
└─────────────────┘
```
---
## 🐛 已知问题
1. **封面图片**:当前使用默认图标,需要后端提供封面图 URL
2. **主播头像**:当前使用默认图标,需要后端提供头像 URL
3. **分类筛选**UI已完成但功能未实现
4. **搜索功能**UI已完成但功能未实现
---
## 🔄 如何恢复旧版 UI
如果需要恢复旧版 UI
```bash
cd android-app/app/src/main/res/layout
Copy-Item activity_main_old.xml activity_main.xml -Force
Copy-Item item_room_old.xml item_room.xml -Force
```
然后在 `MainActivity.java` 中:
- 将 `WaterfallRoomsAdapter` 改回 `RoomsAdapter`
- 将 `StaggeredGridLayoutManager` 改回 `LinearLayoutManager`
---
## 📝 后续优化建议
### 1. 添加封面图支持
- 后端返回封面图 URL
- 使用 Glide 或 Picasso 加载图片
### 2. 实现分类筛选
- 点击分类标签时筛选对应类型的直播间
- 添加分类字段到 Room 模型
### 3. 实现搜索功能
- 点击搜索框打开搜索页面
- 支持搜索主播名称和直播间标题
### 4. 添加下拉刷新
- 使用 SwipeRefreshLayout
- 手动刷新直播间列表
### 5. 添加加载更多
- 监听滚动到底部
- 分页加载直播间数据
---
**设计完成!现在可以编译运行查看新的 UI 效果了!** 🎉

View File

@ -1,309 +0,0 @@
# 项目未完成功能清单
---
## ✅ 刚刚完成的功能
### 通知功能前端UI
- ✅ 通知列表页面 (NotificationsActivity)
- ✅ 通知分类(系统、互动、关注、私信、直播)
- ✅ 通知设置页面 (NotificationSettingsActivity)
- ✅ 本地通知功能 (LocalNotificationManager)
---
## 🔴 高优先级未完成功能(前端可独立完成)
### 1. **数据持久化Room数据库** ⭐⭐⭐
**状态**: 未开始
**位置**: 文档第472行
- [ ] 引入 Room 数据库依赖
- [ ] 创建数据库实体Room、User、Message、Notification等
- [ ] 创建 DAO 接口
- [ ] 创建数据库类
- [ ] 实现 Repository 模式
- [ ] 缓存直播间列表到本地
- [ ] 缓存用户信息
- [ ] 实现搜索历史存储
- [ ] 实现观看历史存储
- [ ] 实现消息记录缓存
**预计工作量**: 3-5天
---
### 2. **顶部标签页功能** ⭐⭐
**状态**: ✅ 已完成
**位置**: MainActivity.java 第265-291行
- [x] 实现关注页面(显示已关注主播的直播)
- [x] 实现发现页面(推荐算法前端实现)
- [x] 实现附近页面(使用模拟位置数据)
- [x] 添加位置权限申请(即使后端未就绪)
**完成说明**:
- 修改MainActivity让顶部标签页在当前页面切换内容而不是跳转到新页面
- 关注页面显示已关注主播的直播列表基于FollowingListActivity的数据结构
- 发现页面:实现前端推荐算法(优先显示正在直播的房间,按类型排序)
- 附近页面:使用模拟位置数据,添加位置权限检查和申请逻辑
- 在AndroidManifest.xml中添加了位置权限声明
- 实现了位置权限申请对话框和权限被拒绝时的友好提示
**预计工作量**: 3-4天已完成
---
### 3. **搜索功能增强**
**状态**: 基础功能完成,增强功能未实现
**位置**: SearchActivity.java
- [ ] 实现搜索历史本地存储使用SharedPreferences或Room
- [ ] 实现热门搜索(模拟数据)
- [ ] 优化搜索性能(防抖、缓存)
- [ ] 添加搜索建议(自动补全)
**预计工作量**: 2-3天
---
### 4. **作品功能前端UI**
**状态**: UI已存在功能未实现
**位置**: ProfileActivity.java 第254行
- [ ] 实现作品列表UI已有tabWorks布局
- [ ] 实现作品发布UI数据仅本地存储
- [ ] 实现作品详情页面
- [ ] 实现作品编辑UI
- [ ] 作品数据模型和适配器
**预计工作量**: 3-4天
---
### 5. **许愿树核心功能**
**状态**: 仅UI完成核心功能未实现
**位置**: WishTreeActivity.java
- [ ] 实现许愿功能
- [ ] 实现抽奖功能
- [ ] 实现许愿列表展示
- [ ] 实现倒计时结束后的事件处理
- [ ] 许愿数据模型和存储
**预计工作量**: 3-4天
---
## 🟡 中优先级未完成功能
### 6. **占位页面功能实现**
**状态**: 多个功能跳转到TabPlaceholderActivity
**位置**: TabPlaceholderActivity.java
以下功能需要实现(目前都是占位页面):
- [ ] 语音匹配 (VoiceMatchActivity) - 已有Activity但功能未实现
- [ ] 心动信号 (HeartbeatSignalActivity) - 已有Activity但功能未实现
- [ ] 在线处对象 (OnlineDatingActivity) - 已有Activity但功能未实现
- [ ] 找人玩游戏 (FindGameActivity) - 已有Activity但功能未实现
- [ ] 一起KTV (KTVTogetherActivity) - 已有Activity但功能未实现
- [ ] 你画我猜 (DrawGuessActivity) - 已有Activity但功能未实现
- [ ] 和平精英 (PeaceEliteActivity) - 已有Activity但功能未实现
- [ ] 桌子游 (TableGamesActivity) - 已有Activity但功能未实现
- [ ] 公园勋章 - 跳转到TabPlaceholderActivity
- [ ] 加好友 - 跳转到TabPlaceholderActivity
- [ ] 定位/发现 - 跳转到TabPlaceholderActivity
- [ ] 附近直播 - TabPlaceholderActivity中显示"待接入"
- [ ] 热门地点 - TabPlaceholderActivity中显示"待接入"
- [ ] 榜单功能 - TabPlaceholderActivity中显示"待接入"
- [ ] 话题功能 - TabPlaceholderActivity中显示"待接入"
**预计工作量**: 每个功能1-2天总计15-30天
---
### 7. **设置页面完善** ⭐ ✅ 已完成
**状态**: 功能已完善
**位置**: SettingsPageActivity.java
- [x] 完善设置页面功能
- [x] 添加应用设置(缓存清理功能实现)
- [x] 添加账号设置UI修改密码、绑定手机号等
- [x] 添加隐私设置UI黑名单、权限管理等
- [x] 实现帮助中心内容
- [x] 实现关于页面内容
**完成内容**:
- 创建了 `CacheManager` 缓存管理工具类
- 实现了所有设置页面的功能对话框
- 创建了对话框布局文件
- 所有功能都已实现部分功能待接入后端API
**预计工作量**: 2-3天
---
### 8. **引导页面和帮助**
**状态**: 未实现
**位置**: 无
- [ ] 实现首次启动引导页ViewPager2
- [ ] 添加功能介绍页面
- [ ] 添加权限说明页面
- [ ] 实现帮助中心
**预计工作量**: 2-3天
---
## 🟢 低优先级未完成功能(体验增强)
### 9. **过渡动画和交互优化**
**状态**: 未实现
- [ ] 添加 Activity 过渡动画
- [ ] 实现共享元素过渡
- [ ] 优化页面内动画
- [ ] 添加触觉反馈Haptic Feedback
**预计工作量**: 2-3天
---
### 10. **深色模式支持**
**状态**: 未实现
- [ ] 创建深色模式资源
- [ ] 适配所有页面颜色
- [ ] 添加手动切换功能
- [ ] 测试深色模式显示
**预计工作量**: 3-4天
---
### 11. **多屏幕适配**
**状态**: 未实现
- [ ] 优化平板布局
- [ ] 添加横屏布局
- [ ] 测试不同屏幕尺寸
- [ ] 优化不同分辨率显示
**预计工作量**: 2-3天
---
## ⚪ 架构和优化(持续进行)
### 12. **前端架构优化** ⭐⭐
**状态**: 未开始
- [ ] 引入 MVVM 架构ViewModel + LiveData
- [ ] 实现 Repository 模式(本地数据源)
- [ ] 提取公共基类 Activity
- [ ] 创建工具类库
- [ ] 引入依赖注入Hilt可选
**预计工作量**: 5-7天
---
### 13. **性能优化**
**状态**: 部分完成
- [ ] 优化图片加载Glide配置优化
- [ ] 优化列表滚动性能
- [ ] 实现请求去重
- [ ] 优化内存使用
**预计工作量**: 3-4天
---
### 14. **代码质量提升**
**状态**: 持续进行
- [ ] 提取硬编码字符串到资源文件
- [ ] 添加代码注释
- [ ] 统一代码风格
- [ ] 重构重复代码
**预计工作量**: 持续进行
---
### 15. **测试**
**状态**: 未开始
- [ ] 编写单元测试(工具类)
- [ ] 编写 UI 测试(关键流程)
- [ ] 性能测试
- [ ] 兼容性测试
**预计工作量**: 持续进行
---
## ❌ 需要后端支持的功能(暂不实现)
以下功能需要后端支持,建议后端开发完成后再实现:
- ❌ 后端API完整集成等待后端接口
- ❌ 实时通信WebSocket等待后端
- ❌ 真实数据同步(等待后端)
- ❌ 用户登录/注册(等待后端)
- ❌ 支付功能等待后端和支付SDK
- ❌ 推流功能如需要等待推流SDK集成
- ❌ 礼物打赏功能等待后端和支付SDK
- ❌ 弹幕功能等待WebSocket服务
---
## 📊 完成度统计
### 前端可独立完成的功能
- **已完成**: 约 40%
- **进行中**: 约 10%
- **未开始**: 约 50%
### 核心功能模块
- ✅ **直播相关**: 85% 完成
- ✅ **社交功能**: 75% 完成
- ⚠️ **个人中心**: 70% 完成(作品功能缺失)
- ⚠️ **发现功能**: 60% 完成(搜索增强、标签页缺失)
- ⚠️ **特色功能**: 50% 完成(许愿树、占位功能缺失)
---
## 🎯 建议优先完成顺序
### 第一周
1. **数据持久化Room数据库** - 3-5天
- 这是基础架构,其他功能会依赖它
### 第二周
2. **搜索功能增强** - 2-3天
3. **作品功能** - 3-4天
### 第三周
4. **顶部标签页功能** - 3-4天
5. **许愿树核心功能** - 3-4天
### 第四周
6. **设置页面完善** - 2-3天
7. **引导页面** - 2-3天
---
## 📝 注意事项
1. **数据持久化**是最重要的基础功能,建议优先完成
2. **占位页面功能**数量较多,可以根据实际需求选择性实现
3. **架构优化**可以在功能完善过程中逐步进行
4. **需要后端支持的功能**可以先做UI使用模拟数据
---
**最后更新**: 2024年

View File

@ -1,132 +0,0 @@
# Android 直播应用问题修复说明
## 问题诊断
### 1. RTMP 地址格式错误 ❌
**问题**:后端返回的 RTMP 地址不完整
- 错误格式:`rtmp://10.0.2.2:1935/live`
- 正确格式:`rtmp://10.0.2.2:1935/live/{streamKey}`
**原因**`live-streaming/server/utils/streamUrl.js` 中缺少 streamKey
### 2. Android 应用功能误解 ⚠️
**重要**:你的 Android 应用**不支持推流**
当前应用功能:
- ✅ 创建直播间
- ✅ 查看直播列表
- ✅ 观看直播(播放 HLS/FLV 流)
- ❌ **不能**从 Android 设备推流
正确的使用流程:
1. 在 Android 应用中创建直播间
2. 获取 RTMP 推流地址
3. **在电脑上用 OBS Studio 推流**
4. 在 Android 应用中观看直播
## 已完成的修复 ✅
### 1. 修复后端 RTMP 地址
修改了 `live-streaming/server/utils/streamUrl.js`
```javascript
// 修复前
rtmp: `rtmp://${host}:${rtmpPort}/live`,
// 修复后
rtmp: `rtmp://${host}:${rtmpPort}/live/${streamKey}`,
```
### 2. 更新 Android 应用提示
修改了 `MainActivity.java` 中的提示信息,明确说明需要用 OBS 推流。
## 如何使用
### 步骤 1启动后端服务器
```bash
cd live-streaming
npm start
```
确认服务器运行:
```bash
curl http://localhost:3001/health
```
### 步骤 2重新构建 Android 应用
```bash
cd android-app
gradlew clean assembleDebug
gradlew installDebug
```
或在 Android Studio 中点击 "Run"
### 步骤 3创建直播间
1. 在 Android 应用中点击"开始直播"
2. 填写直播间标题和主播名称
3. 创建成功后会显示 RTMP 推流地址
### 步骤 4使用 OBS 推流
1. 打开 OBS Studio
2. 设置 → 推流
3. 服务器:`rtmp://localhost:1935/live/{你的streamKey}`
- 或直接填写应用显示的完整地址
4. 开始推流
### 步骤 5在 Android 应用中观看
推流成功后,直播间会显示"直播中"状态,点击进入即可观看。
## Android 模拟器网络说明
在 Android 模拟器中:
- `10.0.2.2` = 宿主机的 `localhost`
- `10.0.2.16` = 模拟器自己的 IP
所以:
- Android 应用访问 API`http://10.0.2.2:3001/api/`
- OBS 推流地址:`rtmp://localhost:1935/live/{streamKey}`
- Android 观看地址:`http://10.0.2.2:8080/live/{streamKey}.flv`
## 常见问题
### Q: 为什么 Android 应用不能直接推流?
A: 从 Android 设备推流需要:
- 摄像头/麦克风权限
- RTMP 推流库(如 librtmp
- 视频编码处理
- 当前应用没有实现这些功能
### Q: 如果想让 Android 应用支持推流怎么办?
A: 需要添加推流功能,可以使用:
- [yasea](https://github.com/begeekmyfriend/yasea) - Android RTMP 推流库
- [LiveVideoBroadcaster](https://github.com/ant-media/LiveVideoBroadcaster) - Ant Media 的推流方案
### Q: 推流后 Android 应用看不到直播?
A: 检查:
1. OBS 是否成功推流(查看 OBS 状态栏)
2. 后端服务器是否收到推流(查看服务器日志)
3. RTMP 地址和 streamKey 是否正确
4. 防火墙是否阻止了端口 1935 和 8080
### Q: 出现"服务器异常"错误?
A: 可能原因:
1. 后端服务器未启动
2. 网络连接问题
3. API 地址配置错误
4. 查看 Android Studio 的 Logcat 获取详细错误信息
## 防火墙配置
如果遇到连接问题,添加防火墙规则:
```powershell
netsh advfirewall firewall add rule name="Node.js API" dir=in action=allow protocol=TCP localport=3001
netsh advfirewall firewall add rule name="RTMP Server" dir=in action=allow protocol=TCP localport=1935
netsh advfirewall firewall add rule name="HTTP-FLV" dir=in action=allow protocol=TCP localport=8080
```
## 相关文件
- 后端服务器:`live-streaming/server/index.js`
- RTMP 地址生成:`live-streaming/server/utils/streamUrl.js`
- Android 配置:`android-app/app/build.gradle.kts`
- Android 主界面:`android-app/app/src/main/java/com/example/livestreaming/MainActivity.java`
- Android 直播间:`android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java`

View File

@ -1,225 +0,0 @@
# 防抖功能使用指南
## 已创建的防抖工具类
已创建 `DebounceClickListener.java`,位于:
```
android-app/app/src/main/java/com/example/livestreaming/DebounceClickListener.java
```
## 使用方法
### 1. 基本用法默认500ms防抖
**之前的代码:**
```java
button.setOnClickListener(v -> {
// 处理点击事件
doSomething();
});
```
**修改后的代码:**
```java
button.setOnClickListener(new DebounceClickListener() {
@Override
public void onDebouncedClick(View v) {
// 处理点击事件
doSomething();
}
});
```
### 2. 自定义防抖时间
```java
button.setOnClickListener(new DebounceClickListener(1000) { // 1秒防抖
@Override
public void onDebouncedClick(View v) {
doSomething();
}
});
```
## 已添加防抖的位置
### WorkDetailActivity.java ✅
- [x] 返回按钮 (backButton)
- [x] 操作菜单按钮 (actionButton)
- [x] 点赞按钮 (likeButtonContainer)
- [x] 收藏按钮 (favoriteButtonContainer)
- [x] 评论按钮 (commentButtonContainer)
- [x] 描述文本点击 (descriptionText)
## 需要添加防抖的位置
### 高优先级(用户交互频繁的按钮)
#### MainActivity.java
- [ ] 菜单按钮 (menuButton) - 第312行
- [ ] 头像按钮 (avatarButton) - 第322行
- [ ] 通知图标 (notificationIcon) - 第335行
- [ ] 添加直播按钮 (fabAddLive) - 第429行
- [ ] 麦克风/搜索图标 (micIcon) - 第505行
- [ ] 创建直播间对话框确认按钮 - 第898行
- [ ] 复制推流地址按钮 (copyAddressBtn) - 第1053行
- [ ] 复制推流密钥按钮 (copyKeyBtn) - 第1058行
#### ConversationActivity.java
- [ ] 返回按钮 (backButton) - 第64行
- [ ] 发送按钮 (sendButton) - 第175行
#### EditProfileActivity.java
- [ ] 返回按钮 (backButton) - 第106行
- [ ] 取消按钮 (cancelButton) - 第107行
- [ ] 头像行点击 (avatarRow) - 第114行
- [ ] 头像预览点击 (avatarPreview) - 第117行
- [ ] 保存按钮 (saveButton) - 第145行
- [ ] 选择图片按钮 (pick) - 第308行
- [ ] 拍照按钮 (camera) - 第313行
- [ ] 取消按钮 (cancel) - 第321行
- [ ] 性别输入点击 (inputGender) - 第351行
- [ ] 位置输入点击 (inputLocation) - 第366行
- [ ] 位置确认按钮 (confirmButton) - 第438行
- [ ] 位置取消按钮 (cancelButton) - 第444行
- [ ] 生日输入点击 (inputBirthday) - 第466行
- [ ] 生日确认按钮 (confirmButton) - 第547行
- [ ] 生日取消按钮 (cancelButton) - 第572行
#### LoginActivity.java
- [ ] 登录按钮
- [ ] 注册跳转按钮
#### RegisterActivity.java
- [ ] 注册按钮
- [ ] 返回登录按钮
#### ProfileActivity.java
- [ ] 返回按钮
- [ ] 编辑资料按钮
- [ ] 关注/取消关注按钮
- [ ] 作品点击
#### PublishWorkActivity.java
- [ ] 返回按钮
- [ ] 选择图片/视频按钮
- [ ] 发布按钮
#### SearchActivity.java
- [ ] 返回按钮
- [ ] 搜索按钮
- [ ] 搜索建议项点击
#### SettingsPageActivity.java
- [ ] 返回按钮
- [ ] 各个设置项点击
- [ ] 退出登录按钮
### 中优先级(列表项点击)
#### Adapter类
- [ ] ConversationsAdapter - 对话列表项点击
- [ ] ConversationMessagesAdapter - 消息项点击(图片、语音)
- [ ] CommentAdapter - 评论点赞按钮
- [ ] BadgesAdapter - 徽章项点击
- [ ] DrawerCardsAdapter - 抽屉卡片点击
- [ ] FriendsAdapter - 好友项点击
- [ ] NearbyUsersAdapter - 附近用户项点击
- [ ] NotificationsAdapter - 通知项点击
- [ ] RoomsAdapter - 直播间项点击
- [ ] UserWorksAdapter - 作品项点击
- [ ] WaterfallRoomsAdapter - 瀑布流直播间项点击
### 低优先级(不太需要防抖的场景)
- [ ] 对话框关闭按钮(用户不太会快速重复点击)
- [ ] 底部导航栏切换(系统已有防抖机制)
- [ ] 长按事件(不需要防抖)
## 批量替换建议
可以使用以下正则表达式进行批量查找替换:
**查找:**
```regex
\.setOnClickListener\(v -> \{
```
**替换为:**
```java
.setOnClickListener(new DebounceClickListener() {
@Override
public void onDebouncedClick(View v) {
```
**注意:** 需要手动添加对应的结束括号 `});`
## 特殊情况处理
### 1. Lambda表达式单行
```java
// 之前
button.setOnClickListener(v -> finish());
// 之后
button.setOnClickListener(new DebounceClickListener() {
@Override
public void onDebouncedClick(View v) {
finish();
}
});
```
### 2. 已有变量引用
```java
// 之前
View.OnClickListener listener = v -> doSomething();
button.setOnClickListener(listener);
// 之后
View.OnClickListener listener = new DebounceClickListener() {
@Override
public void onDebouncedClick(View v) {
doSomething();
}
};
button.setOnClickListener(listener);
```
### 3. 在Adapter中使用
```java
// 在ViewHolder的bind方法中
public void bind(Item item) {
itemView.setOnClickListener(new DebounceClickListener() {
@Override
public void onDebouncedClick(View v) {
if (onItemClickListener != null) {
onItemClickListener.onItemClick(item);
}
}
});
}
```
## 测试建议
添加防抖后,建议测试以下场景:
1. 快速连续点击按钮,确认只触发一次
2. 正常点击间隔(>500ms确认每次都能触发
3. 不同防抖时间的按钮,确认各自独立工作
4. 列表滚动时点击,确认不会误触发
## 注意事项
1. **不要对所有点击都添加防抖**:某些场景(如音乐播放器的快进/快退)不适合防抖
2. **防抖时间要合理**默认500ms适合大多数场景但某些场景可能需要调整
3. **保持一致性**:同类型的操作使用相同的防抖时间
4. **测试充分**:添加防抖后要测试各种点击场景
## 进度追踪
- ✅ 已完成WorkDetailActivity.java
- 🔄 进行中其他Activity
- ⏳ 待处理Adapter类
最后更新2024-12-24