From e3df41657abefb08301f32968353b44adf489e1d Mon Sep 17 00:00:00 2001 From: ShiQi <15883326+shirenan@user.noreply.gitee.com> Date: Mon, 29 Dec 2025 18:02:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=9A=84=E5=AF=B9=E6=8E=A5?= =?UTF-8?q?=E5=92=8C=E4=B8=80=E4=BA=9B=E5=89=8D=E7=AB=AF=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=9A=84=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Android后端对接总结.md | 607 +++++++++++++++--- .../front/controller/FriendController.java | 91 +++ android-app/app/build.gradle.kts | 3 + .../example/livestreaming/ChatMessage.java | 28 + .../livestreaming/ConversationActivity.java | 194 ++++++ .../ConversationMessagesAdapter.java | 76 +++ .../livestreaming/EmojiPickerBottomSheet.java | 72 +++ .../livestreaming/FansListActivity.java | 92 ++- .../livestreaming/FollowingListActivity.java | 89 ++- .../example/livestreaming/FriendsAdapter.java | 27 +- .../livestreaming/MyFriendsActivity.java | 297 ++++++++- .../livestreaming/ProfileActivity.java | 42 ++ .../livestreaming/PublishWorkActivity.java | 392 ++++++++--- .../livestreaming/RoomDetailActivity.java | 286 +++++++-- .../example/livestreaming/SearchActivity.java | 245 ++++++- .../UserProfileReadOnlyActivity.java | 240 ++++--- .../livestreaming/WorkDetailActivity.java | 491 +++++++------- .../example/livestreaming/net/ApiService.java | 171 +++++ .../livestreaming/net/HotSearchResponse.java | 41 ++ .../livestreaming/net/MessageReaction.java | 43 ++ .../livestreaming/net/OrderPayRequest.java | 33 + .../net/OrderPayResultResponse.java | 26 + .../net/SearchHistoryResponse.java | 52 ++ .../livestreaming/net/WorksRequest.java | 72 +++ .../livestreaming/net/WorksResponse.java | 180 ++++++ .../livestreaming/net/WorksSearchRequest.java | 61 ++ .../res/drawable/bottom_sheet_background.xml | 8 + .../main/res/drawable/reaction_background.xml | 9 + .../drawable/reaction_background_selected.xml | 9 + .../main/res/layout/activity_my_friends.xml | 10 + .../res/layout/bottom_sheet_emoji_picker.xml | 175 +++++ .../item_conversation_message_incoming.xml | 8 + .../item_conversation_message_outgoing.xml | 8 + .../main/res/layout/item_message_reaction.xml | 29 + android-app/新UI设计说明.md | 235 ------- android-app/未完成功能清单.md | 309 --------- android-app/网络连接修复说明.md | 132 ---- android-app/防抖功能使用指南.md | 225 ------- 38 files changed, 3619 insertions(+), 1489 deletions(-) create mode 100644 android-app/app/src/main/java/com/example/livestreaming/EmojiPickerBottomSheet.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/HotSearchResponse.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/MessageReaction.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/OrderPayRequest.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/OrderPayResultResponse.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/SearchHistoryResponse.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/WorksRequest.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/WorksResponse.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/net/WorksSearchRequest.java create mode 100644 android-app/app/src/main/res/drawable/bottom_sheet_background.xml create mode 100644 android-app/app/src/main/res/drawable/reaction_background.xml create mode 100644 android-app/app/src/main/res/drawable/reaction_background_selected.xml create mode 100644 android-app/app/src/main/res/layout/bottom_sheet_emoji_picker.xml create mode 100644 android-app/app/src/main/res/layout/item_message_reaction.xml delete mode 100644 android-app/新UI设计说明.md delete mode 100644 android-app/未完成功能清单.md delete mode 100644 android-app/网络连接修复说明.md delete mode 100644 android-app/防抖功能使用指南.md diff --git a/Android后端对接总结.md b/Android后端对接总结.md index 9a4bd86b..dc545f06 100644 --- a/Android后端对接总结.md +++ b/Android后端对接总结.md @@ -3,7 +3,35 @@ > **生成时间**: 2024-12-29 > **更新时间**: 2024-12-29 > **项目**: 直播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 | � 中低 | 作品、评论、搜索、通知、支付等 | --- @@ -33,31 +61,74 @@ | 支付集成 | 4 | ✅ 100% | 微信、支付宝支付 | | 文件上传 | 5 | ✅ 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端需要对接的核心接口 ### 第一阶段:基础功能 (必须) -#### 1. 用户系统 (7个接口) +#### 1. 用户系统 (7个接口) - ✅ 已完成对接 ✨ Android端已接入 ``` -✅ POST /api/front/login # 登录 -✅ POST /api/front/register # 注册 -✅ POST /api/sms/send # 验证码 -✅ GET /api/front/user/info # 用户信息 -✅ POST /api/front/user/update # 更新资料 -✅ POST /api/front/user/upload/image # 上传头像 -✅ GET /api/front/user/logout # 退出登录 +✅ POST /api/front/login # 账号密码登录 (✨已接入) +✅ POST /api/front/register # APP用户注册 (✨已接入) +✅ POST /api/front/sendCode # 发送验证码 (✨已接入) +✅ GET /api/front/user # 获取用户信息 (✨已接入) +✅ POST /api/front/user/edit # 更新用户资料 (✨已接入) +✅ POST /api/front/user/upload/image # 上传头像 (✨已接入) +✅ 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个接口) - ✅ 全部完成 ``` ✅ GET /api/front/live/rooms # 直播间列表 @@ -93,19 +164,81 @@ ### 第二阶段:社交功能 (推荐) -#### 5. 好友管理 (9个接口) +#### 5. 好友管理 (9个接口) - ✅ 已完成对接 ✨ Android端已接入 ``` -✅ POST /api/front/friends/request # 发送好友申请 -✅ POST /api/front/friends/accept # 接受好友申请 -✅ POST /api/front/friends/reject # 拒绝好友申请 -✅ GET /api/front/friends/list # 好友列表 -✅ GET /api/front/friends/requests # 好友申请列表 -✅ POST /api/front/friends/delete/{friendId} # 删除好友 -✅ POST /api/front/friends/block/{friendId} # 拉黑好友 -✅ POST /api/front/friends/unblock/{friendId} # 取消拉黑 -✅ GET /api/front/friends/blocked # 黑名单列表 +✅ GET /api/front/users/search # 搜索用户 (✨已接入) +✅ POST /api/front/friends/request # 发送好友申请 (✨已接入) +✅ POST /api/front/friends/requests/{requestId}/handle # 处理好友请求(接受/拒绝) (✨已接入) +✅ GET /api/front/friends # 好友列表 (✨已接入) +✅ GET /api/front/friends/requests # 好友申请列表 (✨已接入) +✅ DELETE /api/front/friends/{friendId} # 删除好友 (✨已接入) +✅ POST /api/front/friends/block/{friendId} # 拉黑好友 (✨已接入) +✅ POST /api/front/friends/unblock/{friendId} # 取消拉黑 (✨已接入) +✅ 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个接口) ``` ✅ POST /api/front/groups/create # 创建群组 @@ -139,14 +272,44 @@ ... (更多消息相关接口) ``` -#### 9. 消息表情回应 (4个接口) +#### 9. 消息表情回应 (4个接口) - ✅ 已完成对接 ✨ Android端已接入 ``` -✅ POST /api/front/messages/reactions/add # 添加表情回应 -✅ DELETE /api/front/messages/reactions/remove # 移除表情回应 -✅ GET /api/front/messages/{messageId}/reactions # 获取消息的所有表情回应 -✅ GET /api/front/messages/{messageId}/reactions/users # 获取特定表情的用户列表 +✅ POST /api/front/messages/reactions/add # 添加表情回应 (✨已接入) +✅ DELETE /api/front/messages/reactions/remove # 移除表情回应 (✨已接入) +✅ GET /api/front/messages/{messageId}/reactions # 获取消息的所有表情回应 (✨已接入) +✅ 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个接口) ``` ✅ GET /api/front/messages/search/conversations # 搜索会话 @@ -154,15 +317,74 @@ ✅ GET /api/front/messages/search/global # 全局搜索 ``` -#### 11. 关注功能 (8个接口) +#### 11. 关注功能 (8个接口) - ✅ 已完成对接 ✨ Android端已接入 ``` -✅ POST /api/front/follow/follow # 关注 -✅ POST /api/front/follow/unfollow # 取消关注 -✅ GET /api/front/follow/following # 关注列表 -✅ GET /api/front/follow/followers # 粉丝列表 -✅ GET /api/front/follow/status/{userId} # 关注状态 -✅ POST /api/front/follow/status/batch # 批量检查 -✅ GET /api/front/follow/stats # 关注统计 +✅ POST /api/front/follow/follow # 关注 (✨已接入) +✅ POST /api/front/follow/unfollow # 取消关注 (✨已接入) +✅ GET /api/front/follow/following # 关注列表 (✨已接入) +✅ GET /api/front/follow/followers # 粉丝列表 (✨已接入) +✅ GET /api/front/follow/status/{userId} # 关注状态 (✨已接入) +✅ POST /api/front/follow/status/batch # 批量检查 (✨已接入) +✅ 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} # 删除礼物(后台) ``` -#### 13. 作品管理 (15个接口) - ✅ 全部完成 +#### 13. 作品管理 (15个接口) - ✅ 已完成对接 ✨ Android端已接入 ``` -✅ POST /api/front/works/publish # 发布作品 -✅ GET /api/front/works/list # 作品列表 -✅ GET /api/front/works/{worksId} # 作品详情 -✅ POST /api/front/works/{worksId}/like # 点赞 -✅ POST /api/front/works/{worksId}/collect # 收藏 -✅ PUT /api/front/works/{worksId} # 编辑作品 -✅ DELETE /api/front/works/{worksId} # 删除作品 -✅ POST /api/front/works/{worksId}/unlike # 取消点赞 -✅ POST /api/front/works/{worksId}/uncollect # 取消收藏 -✅ GET /api/front/works/user/{userId} # 用户作品列表 -✅ GET /api/front/works/my/liked # 我的点赞列表 -✅ GET /api/front/works/my/collected # 我的收藏列表 -✅ POST /api/front/works/{worksId}/share # 分享作品 -✅ POST /api/front/works/search # 搜索作品 -✅ GET /api/front/works/recommend # 推荐作品 +✅ POST /api/front/works/publish # 发布作品 (✨已接入) +✅ GET /api/front/works/detail/{worksId} # 作品详情 (✨已接入) +✅ POST /api/front/works/update # 编辑作品 (✨已接入) +✅ POST /api/front/works/delete/{worksId} # 删除作品 (✨已接入) +✅ POST /api/front/works/search # 搜索作品 (✨已接入) +✅ GET /api/front/works/user/{userId} # 用户作品列表 (✨已接入) +✅ POST /api/front/works/like/{worksId} # 点赞 (✨已接入) +✅ POST /api/front/works/unlike/{worksId} # 取消点赞 (✨已接入) +✅ POST /api/front/works/collect/{worksId} # 收藏 (✨已接入) +✅ POST /api/front/works/uncollect/{worksId} # 取消收藏 (✨已接入) +✅ GET /api/front/works/my/liked # 我的点赞列表 (✨已接入) +✅ GET /api/front/works/my/collected # 我的收藏列表 (✨已接入) +✅ POST /api/front/works/share/{worksId} # 分享作品 (✨已接入) +✅ POST /api/front/upload/work/video # 视频上传 (✨已接入) +✅ 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个接口) - ✅ 全部完成 ``` ✅ POST /api/front/works/comment/publish # 发布评论 @@ -206,7 +533,85 @@ ✅ 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/live-rooms # 搜索直播间 @@ -234,14 +639,65 @@ ✅ GET /api/front/notification/unread-count-by-type # 按类型统计 ``` -#### 17. 支付集成 (4个接口) - ✅ 全部完成 +#### 17. 支付集成 (4个接口) - ✅ 已完成对接 ✨ Android端已接入 ``` -✅ POST /api/front/pay/payment # 创建支付 -✅ GET /api/front/pay/alipay/queryPayResult # 查询结果 -✅ GET /api/front/pay/alipay/return # 支付返回 -✅ POST /api/admin/payment/callback/alipay # 支付回调 +✅ GET /api/front/gift/recharge/options # 获取充值选项 (✨已接入) +✅ POST /api/front/gift/recharge/create # 创建充值订单 (✨已接入) +✅ POST /api/front/pay/payment # 订单支付 (✨已接入) +✅ 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个接口) - ✅ 全部完成 ``` ✅ POST /api/upload/image # 图片上传 @@ -356,7 +812,7 @@ | 评论功能 | 8 | ✅ 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 | | 分类管理 | 7 | ✅ 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个接口,全部完成 ✅ \ No newline at end of file diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/FriendController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/FriendController.java index 9bcb9374..6c52a955 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/FriendController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/FriendController.java @@ -282,4 +282,95 @@ public class FriendController { return CommonResult.failed("删除失败: " + e.getMessage()); } } + + /** + * 拉黑好友 + */ + @ApiOperation(value = "拉黑好友") + @PostMapping("/friends/block/{friendId}") + public CommonResult 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 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>> 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> list = jdbcTemplate.queryForList(sql, currentUserId, offset, pageSize); + + CommonPage> result = new CommonPage<>(); + result.setList(list); + result.setTotal(total != null ? total : 0L); + result.setPage(page); + result.setLimit(pageSize); + + return CommonResult.success(result); + } } diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index fd715004..7f3c6fba 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -96,6 +96,9 @@ dependencies { 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:converter-gson:2.9.0") implementation("com.squareup.okhttp3:okhttp:4.12.0") diff --git a/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java b/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java index d028c837..bbf09bf1 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java @@ -1,5 +1,9 @@ package com.example.livestreaming; +import com.example.livestreaming.net.MessageReaction; + +import java.util.ArrayList; +import java.util.List; import java.util.UUID; public class ChatMessage { @@ -31,6 +35,9 @@ public class ChatMessage { private int voiceDuration; // 语音时长(秒) private int imageWidth; // 图片宽度 private int imageHeight; // 图片高度 + + // 表情回应相关字段 + private List reactions; // 表情回应列表 // 文本消息构造函数 public ChatMessage(String username, String message) { @@ -212,4 +219,25 @@ public class ChatMessage { public void setImageHeight(int imageHeight) { this.imageHeight = imageHeight; } + + public List getReactions() { + return reactions; + } + + public void setReactions(List 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)); + } + } } \ No newline at end of file diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java index 8daa38c3..451ff398 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationActivity.java @@ -374,6 +374,7 @@ public class ConversationActivity extends AppCompatActivity { private void showMessageMenu(ChatMessage message, int position, View anchorView) { PopupMenu popupMenu = new PopupMenu(this, anchorView); popupMenu.getMenu().add(0, 0, 0, "复制"); + popupMenu.getMenu().add(0, 2, 0, "表情回应"); // 只有自己发送的消息才能删除 if ("我".equals(message.getUsername())) { popupMenu.getMenu().add(0, 1, 0, "删除"); @@ -386,6 +387,9 @@ public class ConversationActivity extends AppCompatActivity { } else if (item.getItemId() == 1) { deleteMessage(message, position); return true; + } else if (item.getItemId() == 2) { + showEmojiPicker(message); + return true; } return false; }); @@ -733,4 +737,194 @@ public class ConversationActivity extends AppCompatActivity { stopPolling(); 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 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); + } + }); + } + }); + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java index 22255597..ec9fab4d 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ConversationMessagesAdapter.java @@ -142,6 +142,7 @@ public class ConversationMessagesAdapter extends ListAdapter { + if (listener != null) { + listener.onEmojiSelected(emoji); + } + dismiss(); + }); + } + } + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/FansListActivity.java b/android-app/app/src/main/java/com/example/livestreaming/FansListActivity.java index 80998869..6d917769 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/FansListActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/FansListActivity.java @@ -3,19 +3,32 @@ package com.example.livestreaming; import android.content.Context; import android.content.Intent; import android.os.Bundle; +import android.view.View; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.LinearLayoutManager; 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.List; +import java.util.Map; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; public class FansListActivity extends AppCompatActivity { private ActivityFansListBinding binding; + private FriendsAdapter adapter; + private int currentPage = 1; + private boolean isLoading = false; public static void start(Context context) { Intent intent = new Intent(context, FansListActivity.class); @@ -30,33 +43,72 @@ public class FansListActivity extends AppCompatActivity { binding.backButton.setOnClickListener(v -> finish()); - FriendsAdapter adapter = new FriendsAdapter(item -> { + adapter = new FriendsAdapter(item -> { if (item == null) return; Toast.makeText(this, "打开粉丝:" + item.getName(), Toast.LENGTH_SHORT).show(); }); - // TODO: 接入后端接口 - 获取粉丝列表 - // 接口路径: GET /api/fans - // 请求参数: - // - userId: 当前用户ID(从token中获取) - // - page (可选): 页码 - // - pageSize (可选): 每页数量 - // 返回数据格式: ApiResponse> - // User对象应包含: id, name, avatarUrl, bio, isLive, followTime等字段 - // 列表应按关注时间倒序排列(最新关注的在前) binding.fansRecyclerView.setLayoutManager(new LinearLayoutManager(this)); binding.fansRecyclerView.setAdapter(adapter); - adapter.submitList(buildDemoFans()); + + loadFollowersList(); } - private List buildDemoFans() { - List list = new ArrayList<>(); - list.add(new FriendItem("f1", "小雨", "关注了你 · 2分钟前", true)); - list.add(new FriendItem("f2", "阿宁", "关注了你 · 昨天", false)); - list.add(new FriendItem("f3", "小星", "关注了你 · 周二", true)); - list.add(new FriendItem("f4", "小林", "关注了你 · 上周", false)); - list.add(new FriendItem("f5", "阿杰", "关注了你 · 上周", false)); - list.add(new FriendItem("f6", "小七", "关注了你 · 上月", true)); - return list; + private void loadFollowersList() { + if (isLoading) return; + isLoading = true; + + ApiService apiService = RetrofitClient.getInstance(this).getApiService(); + Call>>> call = apiService.getFollowersList(currentPage, 20); + + call.enqueue(new Callback>>>() { + @Override + public void onResponse(Call>>> call, + Response>>> response) { + isLoading = false; + + if (response.isSuccessful() && response.body() != null) { + ApiResponse>> apiResponse = response.body(); + + if (apiResponse.getCode() == 200 && apiResponse.getData() != null) { + PageResponse> pageData = apiResponse.getData(); + List> followersList = pageData.getList(); + + if (followersList != null && !followersList.isEmpty()) { + List items = new ArrayList<>(); + for (Map 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>>> call, Throwable t) { + isLoading = false; + Toast.makeText(FansListActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/FollowingListActivity.java b/android-app/app/src/main/java/com/example/livestreaming/FollowingListActivity.java index 83f109e7..c64c3a22 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/FollowingListActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/FollowingListActivity.java @@ -3,19 +3,32 @@ package com.example.livestreaming; import android.content.Context; import android.content.Intent; import android.os.Bundle; +import android.view.View; import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.LinearLayoutManager; 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.List; +import java.util.Map; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; public class FollowingListActivity extends AppCompatActivity { private ActivityFollowingListBinding binding; + private FriendsAdapter adapter; + private int currentPage = 1; + private boolean isLoading = false; public static void start(Context context) { Intent intent = new Intent(context, FollowingListActivity.class); @@ -30,32 +43,70 @@ public class FollowingListActivity extends AppCompatActivity { binding.backButton.setOnClickListener(v -> finish()); - FriendsAdapter adapter = new FriendsAdapter(item -> { + adapter = new FriendsAdapter(item -> { if (item == null) return; Toast.makeText(this, "打开关注:" + item.getName(), Toast.LENGTH_SHORT).show(); }); - // TODO: 接入后端接口 - 获取关注列表 - // 接口路径: GET /api/following - // 请求参数: - // - userId: 当前用户ID(从token中获取) - // - page (可选): 页码 - // - pageSize (可选): 每页数量 - // 返回数据格式: ApiResponse> - // User对象应包含: id, name, avatarUrl, bio, isLive, lastLiveTime, followTime等字段 - // 列表应按关注时间倒序或最后直播时间倒序排列 binding.followingRecyclerView.setLayoutManager(new LinearLayoutManager(this)); binding.followingRecyclerView.setAdapter(adapter); - adapter.submitList(buildDemoFollowing()); + + loadFollowingList(); } - private List buildDemoFollowing() { - List list = new ArrayList<>(); - list.add(new FriendItem("fo1", "王者荣耀陪练", "主播 · 正在直播", true)); - list.add(new FriendItem("fo2", "音乐电台", "主播 · 今日 20:00 开播", false)); - list.add(new FriendItem("fo3", "户外阿杰", "主播 · 1小时前开播", true)); - list.add(new FriendItem("fo4", "美食探店", "主播 · 昨天直播", false)); - list.add(new FriendItem("fo5", "聊天小七", "主播 · 正在直播", true)); - return list; + private void loadFollowingList() { + if (isLoading) return; + isLoading = true; + + ApiService apiService = RetrofitClient.getInstance(this).getApiService(); + Call>>> call = apiService.getFollowingList(currentPage, 20); + + call.enqueue(new Callback>>>() { + @Override + public void onResponse(Call>>> call, + Response>>> response) { + isLoading = false; + + if (response.isSuccessful() && response.body() != null) { + ApiResponse>> apiResponse = response.body(); + + if (apiResponse.getCode() == 200 && apiResponse.getData() != null) { + PageResponse> pageData = apiResponse.getData(); + List> followingList = pageData.getList(); + + if (followingList != null && !followingList.isEmpty()) { + List items = new ArrayList<>(); + for (Map 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>>> call, Throwable t) { + isLoading = false; + Toast.makeText(FollowingListActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java index f0d665a8..9b560e44 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java +++ b/android-app/app/src/main/java/com/example/livestreaming/FriendsAdapter.java @@ -18,37 +18,48 @@ public class FriendsAdapter extends ListAdapter { void onFriendClick(FriendItem item); } + public interface OnFriendLongClickListener { + void onFriendLongClick(FriendItem item, int position); + } + private final OnFriendClickListener onFriendClickListener; + private OnFriendLongClickListener onFriendLongClickListener; public FriendsAdapter(OnFriendClickListener onFriendClickListener) { super(DIFF); this.onFriendClickListener = onFriendClickListener; } + public void setOnFriendLongClickListener(OnFriendLongClickListener listener) { + this.onFriendLongClickListener = listener; + } + @NonNull @Override public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { ItemFriendBinding binding = ItemFriendBinding.inflate(LayoutInflater.from(parent.getContext()), parent, false); - return new VH(binding, onFriendClickListener); + return new VH(binding, onFriendClickListener, onFriendLongClickListener); } @Override public void onBindViewHolder(@NonNull VH holder, int position) { - holder.bind(getItem(position)); + holder.bind(getItem(position), position); } static class VH extends RecyclerView.ViewHolder { private final ItemFriendBinding binding; private final OnFriendClickListener onFriendClickListener; + private final OnFriendLongClickListener onFriendLongClickListener; - VH(ItemFriendBinding binding, OnFriendClickListener onFriendClickListener) { + VH(ItemFriendBinding binding, OnFriendClickListener onFriendClickListener, OnFriendLongClickListener onFriendLongClickListener) { super(binding.getRoot()); this.binding = binding; this.onFriendClickListener = onFriendClickListener; + this.onFriendLongClickListener = onFriendLongClickListener; } - void bind(FriendItem item) { + void bind(FriendItem item, int position) { if (item == null) return; binding.name.setText(item.getName() != null ? item.getName() : ""); @@ -74,6 +85,14 @@ public class FriendsAdapter extends ListAdapter { binding.getRoot().setOnClickListener(v -> { if (onFriendClickListener != null) onFriendClickListener.onFriendClick(item); }); + + binding.getRoot().setOnLongClickListener(v -> { + if (onFriendLongClickListener != null) { + onFriendLongClickListener.onFriendLongClick(item, position); + return true; + } + return false; + }); } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java index 6adfee90..4257c581 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MyFriendsActivity.java @@ -1,5 +1,6 @@ package com.example.livestreaming; +import android.app.AlertDialog; import android.content.Intent; import android.os.Bundle; import android.text.Editable; @@ -36,9 +37,11 @@ public class MyFriendsActivity extends AppCompatActivity { private ActivityMyFriendsBinding binding; private FriendsAdapter friendsAdapter; private FriendRequestAdapter requestAdapter; + private FriendsAdapter blockedAdapter; private final List allFriends = new ArrayList<>(); private final List allRequests = new ArrayList<>(); - private int currentTab = 0; // 0: 好友列表, 1: 好友请求 + private final List allBlocked = new ArrayList<>(); + private int currentTab = 0; // 0: 好友列表, 1: 好友请求, 2: 黑名单 private OkHttpClient httpClient; @Override @@ -71,6 +74,7 @@ public class MyFriendsActivity extends AppCompatActivity { // 点击好友打开私聊会话 openConversation(item); }); + friendsAdapter.setOnFriendLongClickListener(this::showFriendOptionsDialog); binding.friendsRecyclerView.setLayoutManager(new LinearLayoutManager(this)); binding.friendsRecyclerView.setAdapter(friendsAdapter); @@ -90,6 +94,14 @@ public class MyFriendsActivity extends AppCompatActivity { binding.requestsRecyclerView.setLayoutManager(new LinearLayoutManager(this)); 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() { @Override @@ -107,6 +119,7 @@ public class MyFriendsActivity extends AppCompatActivity { 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.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { @Override @@ -125,13 +138,21 @@ public class MyFriendsActivity extends AppCompatActivity { if (tabIndex == 0) { binding.friendsRecyclerView.setVisibility(View.VISIBLE); binding.requestsRecyclerView.setVisibility(View.GONE); + binding.blockedRecyclerView.setVisibility(View.GONE); binding.searchContainer.setVisibility(View.VISIBLE); loadFriendList(); - } else { + } else if (tabIndex == 1) { binding.friendsRecyclerView.setVisibility(View.GONE); binding.requestsRecyclerView.setVisibility(View.VISIBLE); + binding.blockedRecyclerView.setVisibility(View.GONE); binding.searchContainer.setVisibility(View.GONE); 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) { loadFriendList(); - } else { + } else if (currentTab == 1) { 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 blocked) { + if (blocked == null || blocked.isEmpty()) { + showEmptyState("暂无黑名单"); + } else { + if (binding.emptyStateView != null) { + binding.emptyStateView.setVisibility(View.GONE); + } } } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java index 9877b742..5dc3ebba 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java @@ -88,6 +88,7 @@ public class ProfileActivity extends AppCompatActivity { loadProfileFromPrefs(); loadAndDisplayTags(); loadProfileInfo(); + loadFollowStats(); // 加载关注统计 setupEditableAreas(); setupAvatarClick(); setupNavigationClicks(); @@ -507,6 +508,7 @@ public class ProfileActivity extends AppCompatActivity { loadProfileFromPrefs(); loadAndDisplayTags(); loadProfileInfo(); + loadFollowStats(); // 刷新关注统计 loadWorks(); // 重新加载作品列表 BottomNavigationView bottomNav = binding.bottomNavInclude.bottomNavigation; bottomNav.setSelectedItemId(R.id.nav_profile); @@ -671,4 +673,44 @@ public class ProfileActivity extends AppCompatActivity { String shareLink = ShareUtils.generateProfileShareLink(digits); ShareUtils.shareLink(this, shareLink, "个人主页", "来看看我的主页吧"); } + + /** + * 加载关注统计数据 + */ + private void loadFollowStats() { + com.example.livestreaming.net.ApiService apiService = + com.example.livestreaming.net.RetrofitClient.getInstance(this).getApiService(); + retrofit2.Call>> call = + apiService.getFollowStats(null); // null表示查询当前用户 + + call.enqueue(new retrofit2.Callback>>() { + @Override + public void onResponse(retrofit2.Call>> call, + retrofit2.Response>> response) { + if (response.isSuccessful() && response.body() != null) { + com.example.livestreaming.net.ApiResponse> apiResponse = response.body(); + if (apiResponse.getCode() == 200 && apiResponse.getData() != null) { + java.util.Map 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>> call, Throwable t) { + // 忽略错误,使用默认显示 + } + }); + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/PublishWorkActivity.java b/android-app/app/src/main/java/com/example/livestreaming/PublishWorkActivity.java index f5897ce3..5aa7fb84 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/PublishWorkActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/PublishWorkActivity.java @@ -31,6 +31,20 @@ import androidx.recyclerview.widget.RecyclerView; import com.bumptech.glide.Glide; import com.example.livestreaming.databinding.ActivityPublishWorkBinding; 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 java.io.File; @@ -529,85 +543,311 @@ public class PublishWorkActivity extends AppCompatActivity { return; } - // TODO: 接入后端接口 - 发布作品 - // 接口路径: POST /api/works - // 请求方法: POST - // 请求头: - // - 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对象应包含: - // - 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. 调用发布接口,传递title、description、type、coverUrl、videoUrl或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 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()); - } + // 检查登录状态 + if (!AuthHelper.requireLogin(this, "发布作品需要登录")) { + return; } - // 临时:保存到本地存储(等待后端接口) - 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 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> call = apiService.uploadImage(body, model, pid); + + call.enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(Call> call, retrofit2.Response> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse 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> 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> call = apiService.uploadVideo(body, model, pid); + + call.enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(Call> call, retrofit2.Response> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse 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> call, Throwable t) { + callback.onFailure(t.getMessage()); + } + }); + } catch (Exception e) { + callback.onFailure(e.getMessage()); + } + } + + /** + * 上传多张图片 + */ + private void uploadImages(List imageUris, UploadImagesCallback callback) { + if (imageUris == null || imageUris.isEmpty()) { + callback.onFailure("图片列表不能为空"); + return; + } + + List uploadedUrls = new ArrayList<>(); + uploadImageRecursive(imageUris, 0, uploadedUrls, callback); + } + + private void uploadImageRecursive(List imageUris, int index, List 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 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> call = apiService.publishWork(request); + + call.enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(Call> call, retrofit2.Response> response) { + progressDialog.dismiss(); + + if (response.isSuccessful() && response.body() != null) { + ApiResponse 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> 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 urls); + void onFailure(String error); } // 媒体预览适配器 diff --git a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java index 9b4868e2..a6eb75c8 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java @@ -27,7 +27,14 @@ import androidx.recyclerview.widget.GridLayoutManager; import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding; 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.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.StreamConfig; import com.example.livestreaming.ShareUtils; @@ -48,6 +55,10 @@ import okhttp3.WebSocketListener; import okio.ByteString; import org.json.JSONException; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; import org.json.JSONObject; import retrofit2.Call; @@ -1072,24 +1083,13 @@ public class RoomDetailActivity extends AppCompatActivity { RechargeAdapter rechargeAdapter = new RechargeAdapter(); recyclerView.setAdapter(rechargeAdapter); - // TODO: 接入后端接口 - 获取充值选项列表 - // 接口路径: GET /api/recharge/options - // 返回数据格式: ApiResponse> - // RechargeOption对象应包含: id, coinAmount, price, discountLabel等字段 - List 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); currentBalance.setText(String.valueOf(userCoinBalance)); + // 加载充值选项列表 + loadRechargeOptions(rechargeAdapter, dialogView); + // 取消按钮 dialogView.findViewById(R.id.cancelButton).setOnClickListener(v -> rechargeDialog.dismiss()); @@ -1101,39 +1101,237 @@ public class RoomDetailActivity extends AppCompatActivity { return; } - // TODO: 接入后端接口 - 发起充值请求 - // 接口路径: POST /api/recharge/create - // 请求参数: - // - 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(); + // 调用后端接口创建充值订单 + createRechargeOrder(selectedOption, rechargeDialog); }); rechargeDialog.show(); } + /** + * 加载充值选项列表 + */ + private void loadRechargeOptions(RechargeAdapter adapter, View dialogView) { + ApiService apiService = RetrofitClient.getInstance(this).getApiService(); + Call>> call = apiService.getRechargeOptions(); + + call.enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse> apiResponse = response.body(); + if (apiResponse.getCode() == 200 && apiResponse.getData() != null) { + List 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>> call, Throwable t) { + Toast.makeText(RoomDetailActivity.this, + "网络错误: " + t.getMessage(), + Toast.LENGTH_SHORT).show(); + // 使用默认选项 + setDefaultRechargeOptions(adapter); + } + }); + } + + /** + * 设置默认充值选项(当接口失败时使用) + */ + private void setDefaultRechargeOptions(RechargeAdapter adapter) { + List 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> call = apiService.createRecharge(request); + + call.enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse 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> 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> call = apiService.payment(payRequest); + + call.enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse 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> 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(); + } + } diff --git a/android-app/app/src/main/java/com/example/livestreaming/SearchActivity.java b/android-app/app/src/main/java/com/example/livestreaming/SearchActivity.java index 4354ae75..9b287f4b 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/SearchActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/SearchActivity.java @@ -5,24 +5,40 @@ import android.content.Intent; import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; +import android.util.Log; import android.view.View; import android.view.inputmethod.EditorInfo; +import android.widget.Toast; import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.StaggeredGridLayoutManager; 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.SearchHistoryResponse; import java.util.ArrayList; import java.util.List; +import java.util.Map; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; public class SearchActivity extends AppCompatActivity { + private static final String TAG = "SearchActivity"; private ActivitySearchBinding binding; private RoomsAdapter adapter; private final List all = new ArrayList<>(); + private boolean isSearching = false; + private String lastSearchKeyword = ""; private static final String EXTRA_SEARCH_QUERY = "search_query"; @@ -50,11 +66,15 @@ public class SearchActivity extends AppCompatActivity { binding.backButton.setOnClickListener(v -> finish()); binding.cancelBtn.setOnClickListener(v -> finish()); - // 如果从Intent中获取到搜索关键词,自动填充到搜索框 + // 如果从Intent中获取到搜索关键词,自动填充到搜索框并执行搜索 String searchQuery = getIntent().getStringExtra(EXTRA_SEARCH_QUERY); if (searchQuery != null && !searchQuery.trim().isEmpty()) { binding.searchInput.setText(searchQuery); binding.searchInput.setSelection(searchQuery.length()); + performSearch(searchQuery); + } else { + // 加载热门搜索 + loadHotSearch(); } binding.searchInput.requestFocus(); @@ -72,24 +92,25 @@ public class SearchActivity extends AppCompatActivity { glm.setGapStrategy(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS); binding.resultsRecyclerView.setLayoutManager(glm); 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() { 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() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) { @@ -97,13 +118,193 @@ public class SearchActivity extends AppCompatActivity { @Override 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 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>>> call = + apiService.searchLiveRooms(keyword, null, null, 1, 20); + + call.enqueue(new Callback>>>() { + @Override + public void onResponse(Call>>> call, + Response>>> response) { + isSearching = false; + + if (response.isSuccessful() && response.body() != null) { + ApiResponse>> apiResponse = response.body(); + + if (apiResponse.getCode() == 200 && apiResponse.getData() != null) { + PageResponse> pageResponse = apiResponse.getData(); + List> rooms = pageResponse.getList(); + + if (rooms != null && !rooms.isEmpty()) { + List roomList = new ArrayList<>(); + for (Map 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>>> 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 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>> call = apiService.getHotSearch(2, 10); // 2-直播间 + + call.enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse> apiResponse = response.body(); + + if (apiResponse.getCode() == 200 && apiResponse.getData() != null) { + List hotSearchList = apiResponse.getData(); + Log.d(TAG, "热门搜索加载成功: " + hotSearchList.size() + " 条"); + // TODO: 可以在UI上显示热门搜索标签 + } + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + Log.e(TAG, "加载热门搜索失败", t); + } + }); + } + + /** + * 加载搜索建议(可选功能) + */ + private void loadSearchSuggestions(String keyword) { + ApiService apiService = RetrofitClient.getInstance(this).getApiService(); + Call>> call = apiService.getSearchSuggestions(keyword, 2, 10); // 2-直播间 + + call.enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse> apiResponse = response.body(); + + if (apiResponse.getCode() == 200 && apiResponse.getData() != null) { + List suggestions = apiResponse.getData(); + Log.d(TAG, "搜索建议: " + suggestions.size() + " 条"); + // TODO: 可以在UI上显示搜索建议下拉列表 + } + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + Log.e(TAG, "加载搜索建议失败", t); + } + }); } private void applyFilter(String q) { @@ -159,16 +360,4 @@ public class SearchActivity extends AppCompatActivity { } } } - - private List buildDemoRooms(int count) { - List 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; - } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/UserProfileReadOnlyActivity.java b/android-app/app/src/main/java/com/example/livestreaming/UserProfileReadOnlyActivity.java index df39d4cd..5ebf5e0e 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/UserProfileReadOnlyActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/UserProfileReadOnlyActivity.java @@ -10,10 +10,19 @@ import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.GridLayoutManager; 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 java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; 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_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) { Intent intent = new Intent(context, UserProfileReadOnlyActivity.class); @@ -47,7 +57,7 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity { 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 location = getIntent().getStringExtra(EXTRA_LOCATION); String bio = getIntent().getStringExtra(EXTRA_BIO); @@ -66,35 +76,165 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity { }); 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 -> { - if (TextUtils.isEmpty(userId)) { + if (TextUtils.isEmpty(currentUserId)) { Toast.makeText(this, "用户信息缺失", Toast.LENGTH_SHORT).show(); return; } - boolean now = isFriend(userId); - if (!now) { - setFriend(userId, true); - updateAddFriendButton(true); - Toast.makeText(this, "已发送好友请求", Toast.LENGTH_SHORT).show(); + if (isFollowing) { + unfollowUser(); } 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>> call = apiService.checkFollowStatus(userId); + + call.enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse> apiResponse = response.body(); + if (apiResponse.getCode() == 200 && apiResponse.getData() != null) { + Map data = apiResponse.getData(); + Boolean following = (Boolean) data.get("isFollowing"); + isFollowing = following != null && following; + updateFollowButton(); + } + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + // 忽略错误,使用默认状态 + } + }); + } catch (NumberFormatException e) { + // 忽略错误 + } + } + + private void followUser() { + if (TextUtils.isEmpty(currentUserId)) return; + + try { + int userId = Integer.parseInt(currentUserId); + Map requestBody = new HashMap<>(); + requestBody.put("userId", userId); + + ApiService apiService = RetrofitClient.getInstance(this).getApiService(); + Call>> call = apiService.followUser(requestBody); + + call.enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse> 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>> 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 requestBody = new HashMap<>(); + requestBody.put("userId", userId); + + ApiService apiService = RetrofitClient.getInstance(this).getApiService(); + Call>> call = apiService.unfollowUser(requestBody); + + call.enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse> 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>> 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() { if (binding == null) return; @@ -132,27 +272,6 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity { } private void showTab(int index) { - // TODO: 接入后端接口 - 获取其他用户的作品列表 - // 接口路径: GET /api/users/{userId}/works - // 请求参数: - // - userId: 用户ID(路径参数) - // - page (可选): 页码 - // - pageSize (可选): 每页数量 - // 返回数据格式: ApiResponse> - // TODO: 接入后端接口 - 获取其他用户的收藏列表 - // 接口路径: GET /api/users/{userId}/favorites - // 请求参数: - // - userId: 用户ID(路径参数) - // - page (可选): 页码 - // - pageSize (可选): 每页数量 - // 返回数据格式: ApiResponse> - // TODO: 接入后端接口 - 获取其他用户赞过的作品列表 - // 接口路径: GET /api/users/{userId}/liked - // 请求参数: - // - userId: 用户ID(路径参数) - // - page (可选): 页码 - // - pageSize (可选): 每页数量 - // 返回数据格式: ApiResponse> if (binding == null) return; // 标签页顺序:0-作品, 1-收藏, 2-赞过 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) { - // TODO: 接入后端接口 - 获取其他用户资料和统计数据 - // 接口路径: GET /api/users/{userId}/profile - // 请求参数: - // - userId: 用户ID(路径参数) - // 返回数据格式: ApiResponse - // UserProfile对象应包含: id, name, avatarUrl, bio, location, worksCount, followingCount, - // followersCount, likesCount等字段 - // TODO: 接入后端接口 - 获取用户作品列表 - // 接口路径: GET /api/users/{userId}/works - // 请求参数: - // - userId: 用户ID(路径参数) - // - page (可选): 页码 - // - pageSize (可选): 每页数量 - // 返回数据格式: ApiResponse> - // WorkItem对象应包含: id, coverUrl, title, likeCount, viewCount等字段 if (binding == null) return; String seed = !TextUtils.isEmpty(userId) ? userId : "demo"; @@ -191,8 +295,7 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity { binding.statFollowersValue.setText(String.valueOf(followers)); binding.statLikesValue.setText(String.valueOf(likes)); - // TODO: 接入后端接口 - 获取用户作品列表 - // 目前使用演示数据,创建临时的WorkItem列表 + // 创建演示作品列表 List works = new ArrayList<>(); int[] pool = new int[] { R.drawable.wish_tree_checker_backup, @@ -208,8 +311,6 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity { work.setType(WorkItem.WorkType.IMAGE); work.setLikeCount((h + i) % 100); work.setViewCount((h + i) % 500); - // 注意:这里使用drawable资源ID作为临时方案 - // 实际应该使用从服务器获取的WorkItem对象 works.add(work); } if (worksAdapter != null) { @@ -221,25 +322,4 @@ public class UserProfileReadOnlyActivity extends AppCompatActivity { 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); - } - } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/WorkDetailActivity.java b/android-app/app/src/main/java/com/example/livestreaming/WorkDetailActivity.java index ea8f5718..3c6fa8f6 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/WorkDetailActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/WorkDetailActivity.java @@ -19,12 +19,19 @@ import androidx.viewpager2.widget.ViewPager2; import com.bumptech.glide.Glide; 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.BottomSheetDialog; import java.util.ArrayList; import java.util.List; +import retrofit2.Call; + public class WorkDetailActivity extends AppCompatActivity { private ActivityWorkDetailBinding binding; @@ -57,79 +64,8 @@ public class WorkDetailActivity extends AppCompatActivity { return; } - // ============================================ - // TODO: 接入后端接口 - 获取作品详情 - // ============================================ - // 接口路径: GET /api/works/{workId} - // 请求方法: GET - // 请求头: - // - Authorization: Bearer {token} (可选,登录用户可获取更多信息) - // 路径参数: - // - workId: String (必填) - 作品ID - // 返回数据格式: ApiResponse - // - // 后端需要返回的数据结构 (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) - // - 如果用户未登录,isLiked、isFavorited、isOwner字段可能为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(); + // 加载作品详情 + loadWorkDetail(workId); } private void setupToolbar() { @@ -500,113 +436,136 @@ public class WorkDetailActivity extends AppCompatActivity { } private void toggleLike() { - // ============================================ - // TODO: 接入后端接口 - 点赞/取消点赞 - // ============================================ - // 接口路径: POST /api/works/{workId}/like (点赞) 或 DELETE /api/works/{workId}/like (取消点赞) - // 请求方法: POST 或 DELETE - // 请求头: - // - Authorization: Bearer {token} (必填,需要登录) - // 路径参数: - // - workId: String (必填) - 作品ID - // 返回数据格式: ApiResponse - // - // 后端需要返回的数据结构 (LikeResponse): - // { - // "code": 200, - // "message": "success", - // "data": { - // "isLiked": "boolean - 当前点赞状态", - // "likeCount": "int - 更新后的点赞数" - // } - // } - // - // 前端需要传入的参数: - // - workId: String (从workItem.getId()获取) - // - token: String (必填,从AuthStore获取) - // - // 实现步骤: - // 1. 检查用户是否登录,未登录则提示需要登录 - // 2. 根据当前isLiked状态决定调用点赞或取消点赞接口 - // 3. 如果isLiked为false,调用 POST /api/works/{workId}/like - // 4. 如果isLiked为true,调用 DELETE /api/works/{workId}/like - // 5. 解析返回数据,更新isLiked和likeCount - // 6. 更新UI(按钮颜色和点赞数) - // 7. 显示成功提示 - // 8. 处理错误情况: - // - 401: 未登录,跳转到登录页 - // - 404: 作品不存在,显示错误提示 - // - 500: 服务器错误,显示错误提示,恢复原状态 - // - 网络错误: 显示网络错误提示,恢复原状态 - // - // 注意: - // - 需要先检查登录状态,未登录用户不能点赞 - // - 操作失败时需要恢复UI状态(乐观更新需要回滚) - - // 临时实现(等待后端接口) - isLiked = !isLiked; - if (isLiked) { - workItem.setLikeCount(workItem.getLikeCount() + 1); - Toast.makeText(this, "已点赞", Toast.LENGTH_SHORT).show(); - } else { - workItem.setLikeCount(Math.max(0, workItem.getLikeCount() - 1)); - Toast.makeText(this, "已取消点赞", Toast.LENGTH_SHORT).show(); + // 检查登录状态 + if (!AuthHelper.requireLogin(this, "点赞需要登录")) { + return; + } + + try { + long worksId = Long.parseLong(workItem.getId()); + ApiService apiService = ApiClient.getApiService(this); + + Call> call; + if (isLiked) { + // 取消点赞 + call = apiService.unlikeWork(worksId); + } else { + // 点赞 + call = apiService.likeWork(worksId); + } + + // 乐观更新UI + boolean oldLiked = isLiked; + int oldCount = workItem.getLikeCount(); + isLiked = !isLiked; + if (isLiked) { + workItem.setLikeCount(workItem.getLikeCount() + 1); + } else { + workItem.setLikeCount(Math.max(0, workItem.getLikeCount() - 1)); + } + updateLikeButton(); + + call.enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(Call> call, retrofit2.Response> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse apiResponse = response.body(); + if (apiResponse.getCode() == 200) { + Toast.makeText(WorkDetailActivity.this, + isLiked ? "已点赞" : "已取消点赞", + Toast.LENGTH_SHORT).show(); + } else { + // 恢复原状态 + isLiked = oldLiked; + workItem.setLikeCount(oldCount); + updateLikeButton(); + Toast.makeText(WorkDetailActivity.this, + apiResponse.getMsg() != null ? apiResponse.getMsg() : "操作失败", + Toast.LENGTH_SHORT).show(); + } + } else { + // 恢复原状态 + isLiked = oldLiked; + workItem.setLikeCount(oldCount); + updateLikeButton(); + Toast.makeText(WorkDetailActivity.this, "操作失败", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call> 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() { - // ============================================ - // TODO: 接入后端接口 - 收藏/取消收藏 - // ============================================ - // 接口路径: POST /api/works/{workId}/favorite (收藏) 或 DELETE /api/works/{workId}/favorite (取消收藏) - // 请求方法: POST 或 DELETE - // 请求头: - // - Authorization: Bearer {token} (必填,需要登录) - // 路径参数: - // - workId: String (必填) - 作品ID - // 返回数据格式: ApiResponse - // - // 后端需要返回的数据结构 (FavoriteResponse): - // { - // "code": 200, - // "message": "success", - // "data": { - // "isFavorited": "boolean - 当前收藏状态", - // "favoriteCount": "int - 更新后的收藏数" - // } - // } - // - // 前端需要传入的参数: - // - workId: String (从workItem.getId()获取) - // - token: String (必填,从AuthStore获取) - // - // 实现步骤: - // 1. 检查用户是否登录,未登录则提示需要登录 - // 2. 根据当前isFavorited状态决定调用收藏或取消收藏接口 - // 3. 如果isFavorited为false,调用 POST /api/works/{workId}/favorite - // 4. 如果isFavorited为true,调用 DELETE /api/works/{workId}/favorite - // 5. 解析返回数据,更新isFavorited和favoriteCount - // 6. 更新UI(按钮颜色和收藏数) - // 7. 显示成功提示 - // 8. 处理错误情况: - // - 401: 未登录,跳转到登录页 - // - 404: 作品不存在,显示错误提示 - // - 500: 服务器错误,显示错误提示,恢复原状态 - // - 网络错误: 显示网络错误提示,恢复原状态 - // - // 注意: - // - 需要先检查登录状态,未登录用户不能收藏 - // - 操作失败时需要恢复UI状态(乐观更新需要回滚) - - // 临时实现(等待后端接口) - isFavorited = !isFavorited; - if (isFavorited) { - Toast.makeText(this, "已收藏", Toast.LENGTH_SHORT).show(); - } else { - Toast.makeText(this, "已取消收藏", Toast.LENGTH_SHORT).show(); + // 检查登录状态 + if (!AuthHelper.requireLogin(this, "收藏需要登录")) { + return; + } + + try { + long worksId = Long.parseLong(workItem.getId()); + ApiService apiService = ApiClient.getApiService(this); + + Call> call; + if (isFavorited) { + // 取消收藏 + call = apiService.uncollectWork(worksId); + } else { + // 收藏 + call = apiService.collectWork(worksId); + } + + // 乐观更新UI + boolean oldFavorited = isFavorited; + isFavorited = !isFavorited; + updateFavoriteButton(); + + call.enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(Call> call, retrofit2.Response> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse apiResponse = response.body(); + if (apiResponse.getCode() == 200) { + Toast.makeText(WorkDetailActivity.this, + isFavorited ? "已收藏" : "已取消收藏", + Toast.LENGTH_SHORT).show(); + } else { + // 恢复原状态 + isFavorited = oldFavorited; + updateFavoriteButton(); + Toast.makeText(WorkDetailActivity.this, + apiResponse.getMsg() != null ? apiResponse.getMsg() : "操作失败", + Toast.LENGTH_SHORT).show(); + } + } else { + // 恢复原状态 + isFavorited = oldFavorited; + updateFavoriteButton(); + Toast.makeText(WorkDetailActivity.this, "操作失败", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call> 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() { @@ -733,58 +692,41 @@ public class WorkDetailActivity extends AppCompatActivity { .setTitle("删除作品") .setMessage("确定要删除这个作品吗?") .setPositiveButton("删除", (dialog, which) -> { - // ============================================ - // TODO: 接入后端接口 - 删除作品 - // ============================================ - // 接口路径: DELETE /api/works/{workId} - // 请求方法: DELETE - // 请求头: - // - Authorization: Bearer {token} (必填,需要登录) - // 路径参数: - // - workId: String (必填) - 作品ID - // 返回数据格式: ApiResponse - // - // 后端需要返回的数据结构: - // { - // "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: 服务器错误,显示错误提示"删除失败,请稍后重试" - // - 网络错误: 显示网络错误提示,允许重试 - // - // 注意: - // - 删除后,服务器会删除关联的图片/视频文件 - // - 删除操作不可恢复,需要确认对话框 - // - 只有作品作者才能删除 + // 检查登录状态 + if (!AuthHelper.requireLogin(this, "删除作品需要登录")) { + return; + } - // 临时:从本地存储删除(等待后端接口) - boolean deleted = WorkManager.deleteWork(this, workItem.getId()); - if (deleted) { - Toast.makeText(this, "删除成功", Toast.LENGTH_SHORT).show(); - finish(); - } else { - Toast.makeText(this, "删除失败", Toast.LENGTH_SHORT).show(); + try { + long worksId = Long.parseLong(workItem.getId()); + ApiService apiService = ApiClient.getApiService(this); + Call> call = apiService.deleteWork(worksId); + + call.enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(Call> call, retrofit2.Response> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse 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> 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) @@ -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> call = apiService.getWorkDetail(worksId); + + call.enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(Call> call, retrofit2.Response> response) { + if (response.isSuccessful() && response.body() != null) { + ApiResponse 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> 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 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; + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java b/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java index 677374bd..76a46fce 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java @@ -132,6 +132,17 @@ public interface ApiService { @DELETE("api/front/friends/{friendId}") Call> deleteFriend(@Path("friendId") int friendId); + @POST("api/front/friends/block/{friendId}") + Call> blockFriend(@Path("friendId") int friendId); + + @POST("api/front/friends/unblock/{friendId}") + Call> unblockFriend(@Path("friendId") int friendId); + + @GET("api/front/friends/blocked") + Call>> getBlockedList( + @Query("page") int page, + @Query("pageSize") int pageSize); + @GET("api/front/users/search") Call>> searchUsers( @Query("keyword") String keyword, @@ -160,6 +171,13 @@ public interface ApiService { @Part("model") RequestBody model, @Part("pid") RequestBody pid); + @Multipart + @POST("api/front/upload/work/video") + Call> uploadVideo( + @Part MultipartBody.Part file, + @Part("model") RequestBody model, + @Part("pid") RequestBody pid); + // ==================== 在线状态 ==================== @GET("api/front/online/status/{userId}") @@ -189,4 +207,157 @@ public interface ApiService { @DELETE("api/front/online/offline/messages/{userId}") Call> clearOfflineMessages(@Path("userId") int userId); + + // ==================== 消息表情回应 ==================== + + @POST("api/front/messages/reactions/add") + Call> addMessageReaction(@Body Map body); + + @DELETE("api/front/messages/reactions/remove") + Call> removeMessageReaction(@Body Map body); + + @GET("api/front/messages/{messageId}/reactions") + Call>>> getMessageReactions(@Path("messageId") String messageId); + + @GET("api/front/messages/{messageId}/reactions/users") + Call>>> getReactionUsers( + @Path("messageId") String messageId, + @Query("emoji") String emoji); + + // ==================== 关注功能 ==================== + + @POST("api/front/follow/follow") + Call>> followUser(@Body Map body); + + @POST("api/front/follow/unfollow") + Call>> unfollowUser(@Body Map body); + + @GET("api/front/follow/status/{userId}") + Call>> checkFollowStatus(@Path("userId") int userId); + + @POST("api/front/follow/status/batch") + Call>> batchCheckFollowStatus(@Body Map body); + + @GET("api/front/follow/following") + Call>>> getFollowingList( + @Query("page") int page, + @Query("pageSize") int pageSize); + + @GET("api/front/follow/followers") + Call>>> getFollowersList( + @Query("page") int page, + @Query("pageSize") int pageSize); + + @GET("api/front/follow/stats") + Call>> getFollowStats(@Query("userId") Integer userId); + + // ==================== 作品管理 ==================== + + @POST("api/front/works/publish") + Call> publishWork(@Body WorksRequest body); + + @POST("api/front/works/update") + Call> updateWork(@Body WorksRequest body); + + @POST("api/front/works/delete/{worksId}") + Call> deleteWork(@Path("worksId") long worksId); + + @GET("api/front/works/detail/{worksId}") + Call> getWorkDetail(@Path("worksId") long worksId); + + @POST("api/front/works/search") + Call>> searchWorks(@Body WorksSearchRequest body); + + @GET("api/front/works/user/{userId}") + Call>> getUserWorks( + @Path("userId") int userId, + @Query("page") int page, + @Query("pageSize") int pageSize); + + @POST("api/front/works/like/{worksId}") + Call> likeWork(@Path("worksId") long worksId); + + @POST("api/front/works/unlike/{worksId}") + Call> unlikeWork(@Path("worksId") long worksId); + + @POST("api/front/works/collect/{worksId}") + Call> collectWork(@Path("worksId") long worksId); + + @POST("api/front/works/uncollect/{worksId}") + Call> uncollectWork(@Path("worksId") long worksId); + + @GET("api/front/works/my/liked") + Call>> getMyLikedWorks( + @Query("page") int page, + @Query("pageSize") int pageSize); + + @GET("api/front/works/my/collected") + Call>> getMyCollectedWorks( + @Query("page") int page, + @Query("pageSize") int pageSize); + + @POST("api/front/works/share/{worksId}") + Call> shareWork(@Path("worksId") long worksId); + + // ==================== 搜索功能 ==================== + + @GET("api/front/search/users") + Call>>> searchUsersGlobal( + @Query("keyword") String keyword, + @Query("pageNum") int pageNum, + @Query("pageSize") int pageSize); + + @GET("api/front/search/live-rooms") + Call>>> 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>>> searchWorksGlobal( + @Query("keyword") String keyword, + @Query("categoryId") Integer categoryId, + @Query("pageNum") int pageNum, + @Query("pageSize") int pageSize); + + @GET("api/front/search/all") + Call>> searchAll(@Query("keyword") String keyword); + + @GET("api/front/search/history") + Call>> getSearchHistory( + @Query("searchType") Integer searchType, + @Query("limit") int limit); + + @DELETE("api/front/search/history") + Call> clearSearchHistory(@Query("searchType") Integer searchType); + + @DELETE("api/front/search/history/{historyId}") + Call> deleteSearchHistory(@Path("historyId") long historyId); + + @GET("api/front/search/hot") + Call>> getHotSearch( + @Query("searchType") int searchType, + @Query("limit") int limit); + + @GET("api/front/search/suggestions") + Call>> getSearchSuggestions( + @Query("keyword") String keyword, + @Query("searchType") Integer searchType, + @Query("limit") int limit); + + // ==================== 支付集成 ==================== + + @GET("api/front/gift/recharge/options") + Call>> getRechargeOptions(); + + @POST("api/front/gift/recharge/create") + Call> createRecharge(@Body CreateRechargeRequest body); + + @GET("api/front/pay/alipay/queryPayResult") + Call> queryAliPayResult(@Query("orderNo") String orderNo); + + @POST("api/front/pay/payment") + Call> payment(@Body OrderPayRequest body); } diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/HotSearchResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/HotSearchResponse.java new file mode 100644 index 00000000..f367a8c8 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/HotSearchResponse.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/MessageReaction.java b/android-app/app/src/main/java/com/example/livestreaming/net/MessageReaction.java new file mode 100644 index 00000000..26c30a21 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/MessageReaction.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/OrderPayRequest.java b/android-app/app/src/main/java/com/example/livestreaming/net/OrderPayRequest.java new file mode 100644 index 00000000..7c3f54cf --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/OrderPayRequest.java @@ -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; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/OrderPayResultResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/OrderPayResultResponse.java new file mode 100644 index 00000000..5623d0a5 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/OrderPayResultResponse.java @@ -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; } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/SearchHistoryResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/SearchHistoryResponse.java new file mode 100644 index 00000000..f1447129 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/SearchHistoryResponse.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/WorksRequest.java b/android-app/app/src/main/java/com/example/livestreaming/net/WorksRequest.java new file mode 100644 index 00000000..96c332cd --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/WorksRequest.java @@ -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 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 getImageUrls() { + return imageUrls; + } + + public void setImageUrls(List imageUrls) { + this.imageUrls = imageUrls; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/WorksResponse.java b/android-app/app/src/main/java/com/example/livestreaming/net/WorksResponse.java new file mode 100644 index 00000000..2a81ad26 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/WorksResponse.java @@ -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 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 getImageUrls() { + return imageUrls; + } + + public void setImageUrls(List 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; + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/WorksSearchRequest.java b/android-app/app/src/main/java/com/example/livestreaming/net/WorksSearchRequest.java new file mode 100644 index 00000000..4a48ee7e --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/net/WorksSearchRequest.java @@ -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; + } +} diff --git a/android-app/app/src/main/res/drawable/bottom_sheet_background.xml b/android-app/app/src/main/res/drawable/bottom_sheet_background.xml new file mode 100644 index 00000000..c6ccef6e --- /dev/null +++ b/android-app/app/src/main/res/drawable/bottom_sheet_background.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/reaction_background.xml b/android-app/app/src/main/res/drawable/reaction_background.xml new file mode 100644 index 00000000..2af742fc --- /dev/null +++ b/android-app/app/src/main/res/drawable/reaction_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android-app/app/src/main/res/drawable/reaction_background_selected.xml b/android-app/app/src/main/res/drawable/reaction_background_selected.xml new file mode 100644 index 00000000..07d6a569 --- /dev/null +++ b/android-app/app/src/main/res/drawable/reaction_background_selected.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_my_friends.xml b/android-app/app/src/main/res/layout/activity_my_friends.xml index 1c0fbbf6..c016cd27 100644 --- a/android-app/app/src/main/res/layout/activity_my_friends.xml +++ b/android-app/app/src/main/res/layout/activity_my_friends.xml @@ -136,6 +136,16 @@ android:visibility="gone" app:layout_behavior="@string/appbar_scrolling_view_behavior" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/item_conversation_message_incoming.xml b/android-app/app/src/main/res/layout/item_conversation_message_incoming.xml index 4609c5e4..a07a2785 100644 --- a/android-app/app/src/main/res/layout/item_conversation_message_incoming.xml +++ b/android-app/app/src/main/res/layout/item_conversation_message_incoming.xml @@ -46,6 +46,14 @@ android:background="@drawable/message_bubble_incoming" android:lineSpacingExtra="2dp" /> + + + + + + + + + + + + + diff --git a/android-app/新UI设计说明.md b/android-app/新UI设计说明.md deleted file mode 100644 index 67c1dd54..00000000 --- a/android-app/新UI设计说明.md +++ /dev/null @@ -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 效果了!** 🎉 diff --git a/android-app/未完成功能清单.md b/android-app/未完成功能清单.md deleted file mode 100644 index 8174089f..00000000 --- a/android-app/未完成功能清单.md +++ /dev/null @@ -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年 - diff --git a/android-app/网络连接修复说明.md b/android-app/网络连接修复说明.md deleted file mode 100644 index e64f031c..00000000 --- a/android-app/网络连接修复说明.md +++ /dev/null @@ -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` diff --git a/android-app/防抖功能使用指南.md b/android-app/防抖功能使用指南.md deleted file mode 100644 index f12a66c7..00000000 --- a/android-app/防抖功能使用指南.md +++ /dev/null @@ -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