diff --git a/.kiro/specs/android-hot-tab/design.md b/.kiro/specs/android-hot-tab/design.md new file mode 100644 index 00000000..47a714b2 --- /dev/null +++ b/.kiro/specs/android-hot-tab/design.md @@ -0,0 +1,578 @@ +# Design Document + +## Overview + +本设计文档描述了在Android直播应用中添加"热门"Tab功能的技术实现方案。该功能将在"发现"页面的分类标签栏(categoryTabs)中添加一个"热门"Tab,位于最左侧("推荐"Tab左边),用于展示后台管理员标记为热门的作品内容。 + +## Architecture + +### 系统架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ MainActivity │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ topTabs: 关注 | 发现 | 附近 │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ categoryTabs: 热门 | 推荐 | 直播 | 视频 | 音乐 | 游戏│ │ +│ └───────────────────────────────────────────────────────┘ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ RecyclerView (WorksAdapter) │ │ +│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ +│ │ │ 作品1 │ │ 作品2 │ │ 作品3 │ │ │ +│ │ │ 🔥热门 │ │ 🔥热门 │ │ 🔥热门 │ │ │ +│ │ └─────────┘ └─────────┘ └─────────┘ │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↓ + ApiService + ↓ + GET /api/works/hot?page=1&pageSize=20 + ↓ + 后端服务器 + ↓ + 返回 is_hot=1 的作品列表 +``` + +### 数据流 + +1. **用户操作** → 点击"热门"Tab +2. **MainActivity** → 调用 `loadHotWorks(page)` 方法 +3. **ApiService** → 发送 GET 请求到 `/api/works/hot` +4. **后端服务器** → 查询 `is_hot=1` 的作品,按 `hot_time` 降序排序 +5. **ApiService** → 接收 `PageResponse` 数据 +6. **WorksAdapter** → 渲染作品列表 +7. **UI** → 显示热门作品,每个作品卡片显示🔥标识 + +## Components and Interfaces + +### 1. MainActivity 修改 + +#### 新增成员变量 + +```java +// 热门作品相关 +private final List hotWorks = new ArrayList<>(); // 热门作品列表 +private int currentHotWorksPage = 1; // 当前热门作品页码 +private boolean isLoadingHotWorks = false; // 是否正在加载热门作品 +private boolean hasMoreHotWorks = true; // 是否还有更多热门作品 +``` + +#### 新增方法 + +```java +/** + * 加载热门作品列表 + * @param page 页码(从1开始) + */ +private void loadHotWorks(int page) { + if (isLoadingHotWorks) return; + + isLoadingHotWorks = true; + + // 显示加载状态 + if (page == 1) { + if (hotWorks.isEmpty()) { + LoadingStateManager.showSkeleton(binding.roomsRecyclerView, 6); + } + } + + ApiClient.getService(getApplicationContext()) + .getHotWorks(page, 20) + .enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + isLoadingHotWorks = false; + binding.swipeRefresh.setRefreshing(false); + + if (response.isSuccessful() && response.body() != null && response.body().isOk()) { + PageResponse pageData = response.body().getData(); + if (pageData != null && pageData.getList() != null) { + if (page == 1) { + hotWorks.clear(); + } + hotWorks.addAll(pageData.getList()); + + // 检查是否还有更多数据 + hasMoreHotWorks = pageData.getList().size() >= 20; + + // 更新UI + updateHotWorksUI(); + } + } else { + showErrorState("加载失败,请重试"); + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + isLoadingHotWorks = false; + binding.swipeRefresh.setRefreshing(false); + showErrorState("网络错误,请重试"); + } + }); +} + +/** + * 更新热门作品UI + */ +private void updateHotWorksUI() { + if (hotWorks.isEmpty()) { + showEmptyState("暂无热门作品"); + } else { + hideEmptyState(); + hideErrorState(); + + // 转换为FeedItem列表 + List feedItems = new ArrayList<>(); + for (WorksResponse work : hotWorks) { + feedItems.add(FeedItem.fromWorks(work)); + } + adapter.submitList(feedItems); + } +} + +/** + * 刷新热门作品 + */ +private void refreshHotWorks() { + currentHotWorksPage = 1; + hasMoreHotWorks = true; + loadHotWorks(1); +} + +/** + * 加载更多热门作品 + */ +private void loadMoreHotWorks() { + if (!isLoadingHotWorks && hasMoreHotWorks) { + currentHotWorksPage++; + loadHotWorks(currentHotWorksPage); + } +} +``` + +#### 修改 categoryTabs 初始化 + +在 `activity_main.xml` 中的 `categoryTabs` 添加"热门"Tab: + +```xml + + + + + + + + + ... + +``` + +#### 修改 categoryTabs 监听器 + +```java +binding.categoryTabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { + @Override + public void onTabSelected(TabLayout.Tab tab) { + if (tab == null) return; + CharSequence title = tab.getText(); + currentCategory = title != null ? title.toString() : "热门"; + + // 保存选中的分类 + CategoryFilterManager.saveLastCategory(MainActivity.this, currentCategory); + + // 根据分类加载数据 + if ("热门".equals(currentCategory)) { + // 加载热门作品 + refreshHotWorks(); + } else { + // 应用其他分类筛选 + applyCategoryFilterWithAnimation(currentCategory); + } + } + + @Override + public void onTabUnselected(TabLayout.Tab tab) {} + + @Override + public void onTabReselected(TabLayout.Tab tab) { + if (tab == null) return; + CharSequence title = tab.getText(); + currentCategory = title != null ? title.toString() : "热门"; + + if ("热门".equals(currentCategory)) { + // 刷新热门作品 + refreshHotWorks(); + } else { + applyCategoryFilterWithAnimation(currentCategory); + } + } +}); +``` + +#### 修改下拉刷新逻辑 + +```java +binding.swipeRefresh.setOnRefreshListener(() -> { + if ("热门".equals(currentCategory)) { + refreshHotWorks(); + } else { + fetchDiscoverRooms(); + } +}); +``` + +#### 修改滚动加载更多逻辑 + +```java +binding.roomsRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + if (dy <= 0) return; + + RecyclerView.LayoutManager lm = recyclerView.getLayoutManager(); + if (!(lm instanceof GridLayoutManager)) return; + + GridLayoutManager glm = (GridLayoutManager) lm; + int total = glm.getItemCount(); + int lastVisible = glm.findLastVisibleItemPosition(); + + if (total <= 0) return; + + if (lastVisible >= total - 4) { + if ("热门".equals(currentCategory)) { + loadMoreHotWorks(); + } else { + // 其他分类的加载逻辑 + } + } + } +}); +``` + +### 2. WorksAdapter 修改 + +#### 添加热门标识显示 + +在 `item_works.xml` 布局中添加热门标识: + +```xml + + +``` + +#### 修改 ViewHolder 的 bind 方法 + +```java +public void bind(WorksResponse works, OnWorksClickListener listener) { + if (works == null) return; + + // ... 现有代码 ... + + // 显示热门标识 + ImageView hotBadge = itemView.findViewById(R.id.hotBadge); + if (hotBadge != null) { + Boolean isHot = works.getIsHot(); + if (isHot != null && isHot) { + hotBadge.setVisibility(View.VISIBLE); + } else { + hotBadge.setVisibility(View.GONE); + } + } + + // ... 现有代码 ... +} +``` + +### 3. WorksResponse 数据模型 + +确保 `WorksResponse` 类包含 `isHot` 字段: + +```java +public class WorksResponse { + private Long id; + private String title; + private String coverUrl; + private String videoUrl; + private String type; + private Integer userId; + private String userName; + private String authorName; + private Integer likeCount; + private Boolean isLiked; + private Boolean isHot; // 新增:是否热门 + private String hotTime; // 新增:设置热门的时间 + + // Getters and Setters + public Boolean getIsHot() { + return isHot; + } + + public void setIsHot(Boolean isHot) { + this.isHot = isHot; + } + + public String getHotTime() { + return hotTime; + } + + public void setHotTime(String hotTime) { + this.hotTime = hotTime; + } +} +``` + +### 4. ApiService 接口 + +已存在的接口: + +```java +/** + * 获取热门作品列表 + */ +@GET("api/works/hot") +Call>> getHotWorks( + @Query("page") int page, + @Query("pageSize") int pageSize); +``` + +## Data Models + +### WorksResponse + +```java +public class WorksResponse { + private Long id; // 作品ID + private String title; // 作品标题 + private String coverUrl; // 封面图片URL + private String videoUrl; // 视频URL + private String type; // 作品类型(VIDEO/IMAGE) + private Integer userId; // 用户ID + private String userName; // 用户名 + private String authorName; // 作者名 + private Integer likeCount; // 点赞数 + private Boolean isLiked; // 当前用户是否已点赞 + private Boolean isHot; // 是否热门 + private String hotTime; // 设置热门的时间 +} +``` + +### PageResponse + +```java +public class PageResponse { + private List list; // 数据列表 + private Integer total; // 总数 + private Integer page; // 当前页码 + private Integer pageSize; // 每页大小 + private Integer totalPage; // 总页数 +} +``` + +### ApiResponse + +```java +public class ApiResponse { + private Integer code; // 响应码(200表示成功) + private String message; // 响应消息 + private T data; // 响应数据 + + public boolean isOk() { + return code != null && code == 200; + } +} +``` + +## Data Models + +### 数据库字段(后端) + +```sql +-- eb_works 表 +ALTER TABLE `eb_works` +ADD COLUMN `is_hot` tinyint DEFAULT 0 COMMENT '是否热门:1-是 0-否' AFTER `status`, +ADD COLUMN `hot_time` datetime DEFAULT NULL COMMENT '设置热门的时间' AFTER `is_hot`, +ADD INDEX `idx_is_hot` (`is_hot`); +``` + +### API 请求参数 + +``` +GET /api/works/hot?page=1&pageSize=20 +``` + +### API 响应格式 + +```json +{ + "code": 200, + "message": "success", + "data": { + "list": [ + { + "id": 1, + "title": "精彩作品", + "coverUrl": "https://example.com/cover.jpg", + "videoUrl": "https://example.com/video.mp4", + "type": "VIDEO", + "userId": 10, + "userName": "用户A", + "authorName": "作者A", + "likeCount": 100, + "isLiked": false, + "isHot": true, + "hotTime": "2026-01-08 10:00:00" + } + ], + "total": 50, + "page": 1, + "pageSize": 20, + "totalPage": 3 + } +} +``` + +## Error Handling + +### 错误场景处理 + +1. **网络错误** + - 显示错误提示:"网络错误,请重试" + - 提供重试按钮 + - 保留已加载的数据 + +2. **API 返回错误** + - 显示服务器返回的错误消息 + - 提供重试按钮 + +3. **空数据** + - 显示空状态视图:"暂无热门作品" + - 提供刷新按钮 + +4. **加载超时** + - 设置合理的超时时间(30秒) + - 显示超时提示 + - 提供重试选项 + +### 错误处理代码 + +```java +@Override +public void onFailure(Call>> call, Throwable t) { + isLoadingHotWorks = false; + binding.swipeRefresh.setRefreshing(false); + + String errorMsg = "网络错误"; + if (t != null) { + if (t.getMessage() != null && t.getMessage().contains("Unable to resolve host")) { + errorMsg = "无法连接服务器,请检查网络"; + } else if (t.getMessage() != null && t.getMessage().contains("timeout")) { + errorMsg = "连接超时,请重试"; + } else if (t.getMessage() != null && t.getMessage().contains("Connection refused")) { + errorMsg = "连接被拒绝,请确保服务器已启动"; + } + } + + showErrorState(errorMsg); +} +``` + +## Testing Strategy + +### Correctness Properties + +*A property is a characteristic or behavior that should hold true across all valid executions of a system-essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.* + +#### Property 1: 热门Tab仅显示作品内容 +*For any* 热门Tab中显示的数据项,该数据项应该是WorksResponse类型,不应包含Room(直播间)类型的数据 +**Validates: Requirements 2.3** + +#### Property 2: 热门作品显示热门标识 +*For any* 在热门Tab中显示的作品,如果该作品的isHot字段为true,则作品卡片上应该显示热门图标(hotBadge可见) +**Validates: Requirements 3.1** + +### Unit Tests + +1. **MainActivity 单元测试** + - 测试"热门"Tab在categoryTabs的索引为0(最左侧)**Validates: Requirements 1.1, 1.2** + - 测试categoryTabs包含6个Tab,顺序为:热门、推荐、直播、视频、音乐、游戏 **Validates: Requirements 1.4** + - 测试进入"发现"Tab时默认选中"热门"Tab **Validates: Requirements 1.3** + - 测试点击"热门"Tab调用loadHotWorks方法 **Validates: Requirements 2.1** + - 测试loadHotWorks方法调用ApiService.getHotWorks接口 **Validates: Requirements 2.1** + - 测试接收到数据后WorksAdapter被正确更新 **Validates: Requirements 2.2** + - 测试加载中显示骨架屏或加载动画 **Validates: Requirements 2.4** + - 测试加载失败显示错误提示 **Validates: Requirements 2.5** + - 测试空数据显示"暂无热门作品" **Validates: Requirements 2.6** + - 测试下拉刷新触发refreshHotWorks方法 **Validates: Requirements 4.1** + - 测试刷新时SwipeRefreshLayout.isRefreshing为true **Validates: Requirements 4.2** + - 测试刷新完成后isRefreshing为false且列表更新 **Validates: Requirements 4.3** + - 测试刷新失败显示错误提示且保留原数据 **Validates: Requirements 4.4** + - 测试滚动到底部触发loadMoreHotWorks方法 **Validates: Requirements 5.1** + - 测试加载更多时显示底部加载指示器 **Validates: Requirements 5.2** + - 测试hasMoreHotWorks为false时显示"没有更多内容" **Validates: Requirements 5.3** + - 测试加载更多失败显示错误提示 **Validates: Requirements 5.4** + - 测试切换Tab后hotWorks列表数据保留 **Validates: Requirements 6.1** + - 测试返回热门Tab后列表数据和滚动位置恢复 **Validates: Requirements 6.2** + - 测试停留超过5分钟后返回触发自动刷新 **Validates: Requirements 6.3** + +2. **WorksAdapter 单元测试** + - 测试isHot=true时hotBadge可见 **Validates: Requirements 3.1** + - 测试isHot=false时hotBadge不可见 **Validates: Requirements 3.1** + - 测试点击作品触发onWorksClick回调 **Validates: Requirements 3.3** + - 测试点击作品启动WorkDetailActivity **Validates: Requirements 3.3** + +3. **API 接口测试** + - 测试getHotWorks接口返回PageResponse + - 测试page和pageSize参数正确传递 + - 测试错误响应正确处理 + +### Integration Tests + +1. **端到端测试** + - 测试从点击"热门"Tab到显示作品列表的完整流程 + - 测试下拉刷新功能 + - 测试上拉加载更多功能 + - 测试点击作品跳转到详情页 + +2. **UI 测试** + - 测试"热门"Tab在最左侧显示 + - 测试热门标识🔥正确显示 + - 测试加载动画正确显示 + - 测试空状态和错误状态正确显示 + +### 测试数据准备 + +```sql +-- 准备测试数据:设置一些作品为热门 +UPDATE eb_works SET is_hot = 1, hot_time = NOW() WHERE id IN (1, 2, 3, 4, 5); +``` + +### 测试用例 + +| 测试用例 | 输入 | 预期输出 | +|---------|------|---------| +| 加载热门作品 | page=1, pageSize=20 | 返回20条热门作品 | +| 空数据 | 数据库无热门作品 | 显示"暂无热门作品" | +| 网络错误 | 网络断开 | 显示"网络错误,请重试" | +| 下拉刷新 | 下拉列表 | 重新加载第1页数据 | +| 上拉加载更多 | 滚动到底部 | 加载下一页数据 | +| 点击作品 | 点击作品卡片 | 跳转到作品详情页 | + diff --git a/.kiro/specs/android-hot-tab/requirements.md b/.kiro/specs/android-hot-tab/requirements.md new file mode 100644 index 00000000..fdf00c63 --- /dev/null +++ b/.kiro/specs/android-hot-tab/requirements.md @@ -0,0 +1,84 @@ +# Requirements Document + +## Introduction + +本文档定义了在Android直播应用中添加"热门"Tab功能的需求。该功能将在"发现"页面的分类标签栏(categoryTabs)中添加一个"热门"Tab,显示在"推荐"Tab的左侧(最左侧位置),展示后台管理员设置为热门的作品内容。 + +## Glossary + +- **Android App**: 基于Android平台的直播应用客户端 +- **topTabs**: 主页面顶部的标签栏,包含"关注"、"发现"、"附近"三个Tab +- **categoryTabs**: "发现"Tab下方的分类标签栏,当前包含"推荐"、"直播"、"视频"、"音乐"、"游戏"五个分类 +- **热门Tab**: 新增的分类标签,显示热门作品,位于categoryTabs的最左侧("推荐"左边) +- **热门作品**: 后台管理员通过`is_hot`字段标记为热门的作品(`is_hot=1`) +- **作品列表**: 展示作品的RecyclerView列表,使用WorksAdapter适配器 +- **后端API**: 提供热门作品数据的服务端接口 `/api/works/hot` +- **发现Tab**: topTabs中的"发现"标签页,包含categoryTabs分类标签栏 + +## Requirements + +### Requirement 1 + +**User Story:** 作为用户,我想在"发现"页面的分类标签栏看到"热门"Tab,以便快速浏览热门作品 + +#### Acceptance Criteria + +1. WHEN 用户打开主页面并选择"发现"Tab THEN Android App SHALL 在categoryTabs的最左侧显示"热门"Tab +2. WHEN categoryTabs显示时 THEN Android App SHALL 确保"热门"Tab位于"推荐"Tab的左侧 +3. WHEN 用户首次进入"发现"Tab THEN Android App SHALL 默认选中"热门"Tab并显示热门作品列表 +4. WHEN categoryTabs显示时 THEN Android App SHALL 保持现有Tab(推荐、直播、视频、音乐、游戏)的位置和功能不变,仅在最左侧添加"热门"Tab + +### Requirement 2 + +**User Story:** 作为用户,我想点击"热门"Tab查看热门作品列表,以便发现平台推荐的优质作品内容 + +#### Acceptance Criteria + +1. WHEN 用户点击"热门"Tab THEN Android App SHALL 调用后端API `/api/works/hot` 获取热门作品数据 +2. WHEN 后端返回热门作品数据 THEN Android App SHALL 使用WorksAdapter在RecyclerView中展示作品列表 +3. WHEN "热门"Tab显示内容 THEN Android App SHALL 仅显示作品列表,不包含直播间内容 +4. WHEN 热门作品列表加载中 THEN Android App SHALL 显示加载动画或骨架屏 +5. WHEN 热门作品列表加载失败 THEN Android App SHALL 显示错误提示并提供重试选项 +6. WHEN 热门作品列表为空 THEN Android App SHALL 显示"暂无热门作品"的空状态提示 + +### Requirement 3 + +**User Story:** 作为用户,我想在热门作品列表中看到作品的热门标识,以便识别这些是热门内容 + +#### Acceptance Criteria + +1. WHEN 作品在热门Tab中显示 THEN Android App SHALL 在作品卡片上显示"🔥"热门图标或标签 +2. WHEN 作品卡片显示热门标识 THEN Android App SHALL 确保标识位置醒目且不遮挡作品主要信息 +3. WHEN 用户点击热门作品 THEN Android App SHALL 跳转到作品详情页面 + +### Requirement 4 + +**User Story:** 作为用户,我想下拉刷新热门作品列表,以便获取最新的热门内容 + +#### Acceptance Criteria + +1. WHEN 用户在热门Tab中下拉列表 THEN Android App SHALL 触发刷新操作并重新请求热门作品数据 +2. WHEN 刷新操作进行中 THEN Android App SHALL 显示下拉刷新动画 +3. WHEN 刷新完成 THEN Android App SHALL 更新作品列表并隐藏刷新动画 +4. WHEN 刷新失败 THEN Android App SHALL 显示错误提示并保持当前列表数据 + +### Requirement 5 + +**User Story:** 作为用户,我想在热门作品列表滚动到底部时自动加载更多内容,以便浏览更多热门作品 + +#### Acceptance Criteria + +1. WHEN 用户滚动热门作品列表到底部 THEN Android App SHALL 自动请求下一页热门作品数据 +2. WHEN 加载更多数据时 THEN Android App SHALL 在列表底部显示加载指示器 +3. WHEN 没有更多热门作品时 THEN Android App SHALL 显示"没有更多内容"提示 +4. WHEN 加载更多失败 THEN Android App SHALL 显示错误提示并允许用户重试 + +### Requirement 6 + +**User Story:** 作为用户,我想在切换到其他Tab后再返回热门Tab时保持之前的浏览位置,以便继续浏览 + +#### Acceptance Criteria + +1. WHEN 用户从热门Tab切换到其他Tab THEN Android App SHALL 保存热门Tab的滚动位置和已加载数据 +2. WHEN 用户返回热门Tab THEN Android App SHALL 恢复之前的滚动位置和列表数据 +3. WHEN 用户在其他Tab停留超过5分钟后返回热门Tab THEN Android App SHALL 自动刷新热门作品列表 diff --git a/.kiro/specs/android-hot-tab/tasks.md b/.kiro/specs/android-hot-tab/tasks.md new file mode 100644 index 00000000..50be308f --- /dev/null +++ b/.kiro/specs/android-hot-tab/tasks.md @@ -0,0 +1,236 @@ +# Implementation Plan + +- [x] 1. 修改布局文件添加"热门"Tab + + + + +- [x] 1.1 在activity_main.xml的categoryTabs中添加"热门"TabItem,放在最左侧 + + + + + - 在categoryTabs的第一个位置添加TabItem,文本为"热门" + - 确保"热门"Tab在"推荐"Tab之前 + - _Requirements: 1.1, 1.2, 1.4_ + +- [x] 1.2 在item_works.xml中添加热门标识ImageView + + + + + - 添加id为hotBadge的ImageView + - 设置火焰图标(ic_fire_24) + - 设置橙红色tint(#FF4500) + - 默认visibility为gone + - _Requirements: 3.1_ + +- [x] 2. 修改WorksResponse数据模型 + + + + +- [x] 2.1 在WorksResponse类中添加isHot和hotTime字段 + + + + + - 添加Boolean类型的isHot字段 + - 添加String类型的hotTime字段 + - 添加对应的getter和setter方法 + - _Requirements: 2.2, 3.1_ + +- [x] 3. 修改WorksAdapter显示热门标识 + + + + +- [x] 3.1 在WorksViewHolder的bind方法中添加热门标识显示逻辑 + + + + + - 获取hotBadge视图 + - 根据works.getIsHot()判断是否显示 + - isHot为true时设置visibility为VISIBLE + - isHot为false或null时设置visibility为GONE + - _Requirements: 3.1_ + +- [ ]* 3.2 编写Property测试:热门作品显示热门标识 + - **Property 2: 热门作品显示热门标识** + - **Validates: Requirements 3.1** + +- [x] 4. 在MainActivity中添加热门作品数据管理 + + + +- [x] 4.1 添加热门作品相关成员变量 + + + + + - 添加hotWorks列表(List) + - 添加currentHotWorksPage(int,初始值1) + - 添加isLoadingHotWorks(boolean,初始值false) + - 添加hasMoreHotWorks(boolean,初始值true) + - _Requirements: 2.1, 5.1_ + +- [x] 4.2 实现loadHotWorks方法 + + + + + - 检查isLoadingHotWorks,避免重复请求 + - 调用ApiService.getHotWorks(page, 20) + - 处理成功响应:更新hotWorks列表,更新hasMoreHotWorks标志 + - 处理失败响应:显示错误提示 + - 更新isLoadingHotWorks状态 + - _Requirements: 2.1, 2.2, 2.4, 2.5_ + +- [x] 4.3 实现refreshHotWorks方法 + + + - 重置currentHotWorksPage为1 + - 重置hasMoreHotWorks为true + - 调用loadHotWorks(1) + - _Requirements: 4.1, 4.3_ + +- [x] 4.4 实现loadMoreHotWorks方法 + + - 检查isLoadingHotWorks和hasMoreHotWorks + - 增加currentHotWorksPage + - 调用loadHotWorks(currentHotWorksPage) + - _Requirements: 5.1, 5.3_ + +- [x] 4.5 实现updateHotWorksUI方法 + + - 检查hotWorks是否为空 + - 为空时显示空状态视图 + - 不为空时转换为FeedItem列表并更新adapter + - _Requirements: 2.2, 2.6_ + +- [ ]* 4.6 编写Property测试:热门Tab仅显示作品内容 + - **Property 1: 热门Tab仅显示作品内容** + - **Validates: Requirements 2.3** + +- [x] 5. 修改categoryTabs的Tab选择监听器 + + +- [x] 5.1 修改onTabSelected方法处理"热门"Tab + + - 获取选中Tab的文本 + - 判断是否为"热门" + - 如果是"热门",调用refreshHotWorks() + - 如果是其他分类,调用applyCategoryFilterWithAnimation() + - _Requirements: 1.3, 2.1, 4.1_ + +- [x] 5.2 修改onTabReselected方法处理"热门"Tab + + - 判断是否为"热门" + - 如果是"热门",调用refreshHotWorks()刷新数据 + - _Requirements: 4.1_ + +- [x] 6. 修改下拉刷新逻辑 + + +- [x] 6.1 在SwipeRefreshLayout的OnRefreshListener中添加"热门"Tab判断 + + + - 检查currentCategory是否为"热门" + - 如果是"热门",调用refreshHotWorks() + - 如果不是,调用fetchDiscoverRooms() + - _Requirements: 4.1, 4.2, 4.3_ + +- [x] 7. 修改滚动加载更多逻辑 + + +- [x] 7.1 在RecyclerView的OnScrollListener中添加"热门"Tab判断 + + + - 检查是否滚动到底部(lastVisible >= total - 4) + - 检查currentCategory是否为"热门" + - 如果是"热门",调用loadMoreHotWorks() + - _Requirements: 5.1, 5.2_ + +- [-] 8. 实现Tab切换时的状态保存和恢复 + +- [x] 8.1 添加Tab切换时间记录 + + + - 添加lastHotTabLeaveTime成员变量(long) + - 在切换离开"热门"Tab时记录当前时间 + - _Requirements: 6.1, 6.3_ + +- [ ] 8.2 实现返回"热门"Tab时的状态恢复 + + + - 检查是否有已加载的数据 + - 如果有数据且停留时间小于5分钟,恢复数据和滚动位置 + - 如果停留时间超过5分钟,调用refreshHotWorks() + - _Requirements: 6.2, 6.3_ + +- [x] 9. 添加错误处理和空状态显示 + + +- [x] 9.1 实现showErrorState方法 + + - 显示错误提示视图 + - 提供重试按钮 + - 重试按钮点击时调用refreshHotWorks() + - _Requirements: 2.5, 4.4, 5.4_ + +- [x] 9.2 实现showEmptyState方法 + + - 显示空状态视图 + - 显示"暂无热门作品"文本 + - 提供刷新按钮 + - _Requirements: 2.6_ + +- [x] 10. 设置默认选中"热门"Tab + + + +- [x] 10.1 在showDiscoverTab方法中设置默认选中 + + + - 在categoryTabs初始化完成后 + - 获取索引为0的Tab("热门"Tab) + - 调用tab.select()选中该Tab + - 触发loadHotWorks加载数据 + - _Requirements: 1.3_ + +- [ ]* 11. 编写单元测试 +- [ ]* 11.1 测试"热门"Tab位置和顺序 + - 测试categoryTabs包含6个Tab + - 测试"热门"Tab在索引0位置 + - 测试"推荐"Tab在索引1位置 + - _Requirements: 1.1, 1.2, 1.4_ + +- [ ]* 11.2 测试热门作品加载逻辑 + - 测试loadHotWorks调用正确的API + - 测试refreshHotWorks重置页码 + - 测试loadMoreHotWorks增加页码 + - 测试空数据显示空状态 + - 测试错误显示错误提示 + - _Requirements: 2.1, 2.4, 2.5, 2.6, 4.1, 5.1_ + +- [ ]* 11.3 测试WorksAdapter热门标识显示 + - 测试isHot=true时hotBadge可见 + - 测试isHot=false时hotBadge不可见 + - _Requirements: 3.1_ + +- [ ]* 11.4 测试下拉刷新和加载更多 + - 测试下拉刷新触发refreshHotWorks + - 测试滚动到底部触发loadMoreHotWorks + - 测试刷新动画显示和隐藏 + - _Requirements: 4.1, 4.2, 4.3, 5.1, 5.2_ + +- [ ]* 11.5 测试Tab切换状态保存 + - 测试切换Tab后数据保留 + - 测试返回Tab后数据恢复 + - 测试超过5分钟自动刷新 + - _Requirements: 6.1, 6.2, 6.3_ + +- [ ] 12. Checkpoint - 确保所有测试通过 + - Ensure all tests pass, ask the user if questions arise. + diff --git a/Log/8-配置上传服务器.md b/Log/8-配置上传服务器.md deleted file mode 100644 index 584f0cf5..00000000 --- a/Log/8-配置上传服务器.md +++ /dev/null @@ -1,59 +0,0 @@ -安装软件 -# 安装 Node.js 18 -curl -fsSL https://rpm.nodesource.com/setup_18.x | bash - -yum install -y nodejs - -# 验证 -node -v -npm -v - -npm install -g pm2 -pm2 start server.js --name upload-server -pm2 save -pm2 startup - -更改配置 -[root@VM-0-16-opencloudos ~]# # 重写正确的配置 -cat > /www/server/panel/vhost/nginx/1.15.149.240_30005.conf << 'EOF' -server -{ - listen 30005; - listen [::]:30005; - server_name 1.15.149.240_30005; - index index.php index.html index.htm default.php default.htm default.html; - root /www/wwwroot/1.15.149.240_30005; - - #CERT-APPLY-CHECK--START - include /www/server/panel/vhost/nginx/well-known/1.15.149.240_30005.conf; - #CERT-APPLY-CHECK--END - - #ERROR-PAGE-START - error_page 404 /404.html; - #ERROR-PAGE-END - - #PHP-INFO-START - include enable-php-84.conf; - #PHP-INFO-END - - #REWRITE-START - include /www/server/panel/vhost/rewrite/1.15.149.240_30005.conf; - #REWRITE-END - - # 上传API代理 - location /api/ { - proxy_pass http://127.0.0.1:3000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - client_max_body_size 500M; - } - - location ~ ^/(\.user.ini|\.htaccess|\.git|\.env|\.svn|\.project|LICENSE|README.md) - { - return 404; - } - -nginx -s reload/www/wwwlogs/1.15.149.240_30005.error.log;a|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) { -nginx: [warn] conflicting server name "1.15.149.240" on 0.0.0.0:20001, ignored -nginx: the configuration file /www/server/nginx/conf/nginx.conf syntax is ok -nginx: configuration file /www/server/nginx/conf/nginx.conf test is successful -nginx: [warn] conflicting server name "1.15.149.240" on 0.0.0.0:20001, ignored \ No newline at end of file diff --git a/Log/指令/作品上热门功能实现说明.md b/Log/指令/作品上热门功能实现说明.md deleted file mode 100644 index 4b30e7b8..00000000 --- a/Log/指令/作品上热门功能实现说明.md +++ /dev/null @@ -1,225 +0,0 @@ -# 作品上热门功能实现说明 - -## 功能概述 -实现了最简单的"作品上热门"功能,管理员可以在后台设置/取消作品的热门状态,用户可以在App中查看热门作品列表。 - -## 实现方案 -采用**最简单方案**:管理员手动设置热门 - -### 核心特点 -- ✅ 只在后台管理端操作,不涉及用户端复杂交互 -- ✅ 不需要付费系统,不需要金币/积分 -- ✅ 不需要审核流程,管理员直接设置 -- ✅ 不需要定时任务,手动管理即可 -- ✅ 最小化数据库改动 - -## 已完成的工作 - -### 1. 数据库改动 ✅ -**文件:** `Zhibo/zhibo-h/sql/add_works_hot_fields.sql` - -在 `works` 表添加了2个字段: -- `is_hot` (tinyint): 是否热门,1-是 0-否 -- `hot_time` (datetime): 设置热门的时间 - -### 2. 后端Service层 ✅ - -#### WorksService接口 -**文件:** `Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/WorksService.java` - -新增方法: -```java -// 设置/取消热门 -Boolean toggleHot(Long worksId, Boolean isHot); - -// 获取热门作品列表 -CommonPage getHotWorks(Integer page, Integer pageSize, Integer userId); -``` - -#### WorksServiceImpl实现 -**文件:** `Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/WorksServiceImpl.java` - -实现了: -- `toggleHot()`: 设置/取消热门状态 -- `getHotWorks()`: 查询热门作品列表(按hot_time倒序) -- `convertToResponse()`: 添加了热门字段的转换 - -### 3. 管理端Controller ✅ -**文件:** `Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/WorksController.java` - -新增接口: -- `POST /api/admin/works/toggleHot` - 设置/取消热门 - - 参数:id (作品ID), isHot (true/false) - - 返回:操作结果 - -- `GET /api/admin/works/hotList` - 获取热门作品列表 - - 参数:page, limit - - 返回:热门作品分页列表 - -### 4. 用户端Controller ✅ -**文件:** `Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/WorksController.java` - -新增接口: -- `GET /api/works/hot` - 获取热门作品列表(用户端) - - 参数:page, pageSize - - 返回:热门作品分页列表 - - 不需要登录即可访问 - -### 5. 实体类更新 ✅ - -#### Works实体 -**文件:** `Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/works/Works.java` - -添加字段: -- `isHot`: 是否热门 -- `hotTime`: 设置热门的时间 - -#### WorksResponse响应类 -**文件:** `Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/response/WorksResponse.java` - -添加字段: -- `isHot`: 是否热门 -- `hotTime`: 设置热门的时间 - -## API接口说明 - -### 管理端接口 - -#### 1. 设置/取消热门 -``` -POST /api/admin/works/toggleHot -参数: - - id: 作品ID (Long) - - isHot: 是否热门 (Boolean, true=设置热门, false=取消热门) -返回: - { - "code": 200, - "message": "设置热门成功" / "取消热门成功" - } -``` - -#### 2. 获取热门作品列表(管理端) -``` -GET /api/admin/works/hotList -参数: - - page: 页码 (默认1) - - limit: 每页数量 (默认20) -返回: - { - "code": 200, - "data": { - "list": [...], - "total": 100, - "page": 1, - "limit": 20 - } - } -``` - -### 用户端接口 - -#### 3. 获取热门作品列表(用户端) -``` -GET /api/works/hot -参数: - - page: 页码 (默认1) - - pageSize: 每页数量 (默认20) -返回: - { - "code": 200, - "data": { - "list": [...], - "total": 100, - "page": 1, - "limit": 20 - } - } -``` - -## 后续工作 - -### 待实现: - -#### 1. 后台管理界面 (Vue Admin) -需要在作品管理页面添加: -- 作品列表中每一行添加"设置热门"/"取消热门"按钮 -- 热门作品用特殊样式标识(如红色高亮) -- 可选:添加单独的"热门作品管理"页面 - -**文件位置:** `Zhibo/admin/src/views/content/works/list.vue` - -#### 2. Android App界面 -需要实现: -- 首页添加"热门"Tab -- 调用 `GET /api/works/hot` 接口获取热门作品 -- 作品卡片显示热门标签(🔥热门) - -**需要修改的文件:** -- `android-app/app/src/main/java/com/example/livestreaming/MainActivity.java` -- `android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java` - -## 使用流程 - -### 管理员操作流程: -1. 登录后台管理系统 -2. 进入"作品管理"页面 -3. 找到想要设置为热门的作品 -4. 点击"设置热门"按钮 -5. 该作品立即成为热门作品 - -### 用户查看流程: -1. 打开App -2. 点击"热门"Tab -3. 查看热门作品列表 -4. 热门作品按设置时间倒序排列(最新设置的在前面) - -## 数据库执行 - -在部署前需要执行SQL文件: -```bash -mysql -u root -p crmeb < Zhibo/zhibo-h/sql/add_works_hot_fields.sql -``` - -或者手动执行: -```sql -USE `crmeb`; - -ALTER TABLE `works` -ADD COLUMN `is_hot` tinyint DEFAULT 0 COMMENT '是否热门:1-是 0-否' AFTER `status`, -ADD COLUMN `hot_time` datetime DEFAULT NULL COMMENT '设置热门的时间' AFTER `is_hot`; - -ALTER TABLE `works` ADD INDEX `idx_is_hot` (`is_hot`); -``` - -## 测试建议 - -### 后端测试: -1. 测试设置热门接口 -2. 测试取消热门接口 -3. 测试获取热门列表接口 -4. 验证热门作品排序是否正确 - -### 前端测试: -1. 测试后台管理界面的热门设置功能 -2. 测试App热门Tab的显示 -3. 测试热门标签的显示 - -## 扩展建议 - -如果以后需要更复杂的功能,可以在此基础上扩展: -- 添加热门时长限制(加个 `hot_end_time` 字段) -- 添加自动热门算法(定时任务计算热度) -- 添加用户付费推广(加个 `works_hot` 表记录推广记录) -- 添加热门位置排序(加个 `hot_order` 字段) - -## 注意事项 - -1. 后端代码已完成并编译通过 ✅ -2. 数据库SQL文件已创建 ✅ -3. 前端界面(Vue Admin + Android App)需要继续实现 -4. 部署前记得执行数据库迁移脚本 - ---- - -**创建时间:** 2026-01-08 -**状态:** 后端完成,前端待实现 diff --git a/Zhibo/admin/src/api/works.js b/Zhibo/admin/src/api/works.js index 52ca3f70..35c737ea 100644 --- a/Zhibo/admin/src/api/works.js +++ b/Zhibo/admin/src/api/works.js @@ -118,3 +118,37 @@ export function UpdateWorksStatus(pram) { params: data, }); } + +/** + * 设置/取消热门 + * @param pram + * @constructor + */ +export function ToggleWorksHot(pram) { + const data = { + id: pram.id, + isHot: pram.isHot, + }; + return request({ + url: '/admin/works/toggleHot', + method: 'POST', + params: data, + }); +} + +/** + * 获取热门作品列表 + * @param pram + * @constructor + */ +export function ListHotWorks(pram) { + const data = { + page: pram.page, + limit: pram.limit, + }; + return request({ + url: '/admin/works/hotList', + method: 'GET', + params: data, + }); +} diff --git a/Zhibo/admin/src/views/content/works/list.vue b/Zhibo/admin/src/views/content/works/list.vue index 18ab7917..d1af780d 100644 --- a/Zhibo/admin/src/views/content/works/list.vue +++ b/Zhibo/admin/src/views/content/works/list.vue @@ -69,8 +69,16 @@ 已删除 + + + - + @@ -164,6 +180,18 @@ export default { }); }); }, + handleToggleHot(rowData) { + const isHot = rowData.isHot === 1 ? false : true; + const action = isHot ? '设为热门' : '取消热门'; + this.$modalSure(`确定要${action}该作品吗?`).then(() => { + worksApi.ToggleWorksHot({ id: rowData.id, isHot: isHot }).then(() => { + this.$message.success(`${action}成功`); + this.handlerGetListData(this.listPram); + }).catch(err => { + this.$message.error(err.message || `${action}失败`); + }); + }); + }, handleSizeChange(val) { this.listPram.limit = val; this.handlerGetListData(this.listPram); diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/WorksRequest.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/WorksRequest.java index 31d6d0e3..42bcbd48 100644 --- a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/WorksRequest.java +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/request/WorksRequest.java @@ -5,7 +5,6 @@ import io.swagger.annotations.ApiModelProperty; import lombok.Data; import javax.validation.constraints.NotBlank; -import javax.validation.constraints.NotNull; import javax.validation.constraints.Size; /** @@ -19,7 +18,6 @@ public class WorksRequest { private Long id; @ApiModelProperty(value = "作品标题", required = true) - @NotBlank(message = "作品标题不能为空") @Size(max = 200, message = "作品标题最多200个字符") private String title; diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/WorksController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/WorksController.java index 376c5a71..a96f6357 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/WorksController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/WorksController.java @@ -71,6 +71,26 @@ public class WorksController { } } + @ApiOperation(value = "获取附近作品列表") + @GetMapping("/nearby") + public CommonResult> getNearbyWorks( + @RequestParam(value = "page", defaultValue = "1") Integer page, + @RequestParam(value = "limit", defaultValue = "20") Integer limit) { + + Integer userId = userService.getUserId(); + if (userId == null) { + return CommonResult.unauthorized("请先登录"); + } + + try { + CommonPage result = worksService.getNearbyWorks(page, limit, userId); + return CommonResult.success(result); + } catch (Exception e) { + log.error("获取附近作品列表失败", e); + return CommonResult.failed(e.getMessage()); + } + } + /** * 编辑作品(需要登录) */ diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/WorksService.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/WorksService.java index 36e1d516..40ea0dcc 100644 --- a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/WorksService.java +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/WorksService.java @@ -71,6 +71,15 @@ public interface WorksService extends IService { */ CommonPage getUserWorks(Integer userId, Integer page, Integer pageSize); + /** + * 获取附近作品列表(按距离从近到远排序) + * @param page 页码 + * @param limit 每页数量 + * @param userId 当前用户ID + * @return 附近作品列表 + */ + CommonPage getNearbyWorks(Integer page, Integer limit, Integer userId); + /** * 增加浏览次数 * @param worksId 作品ID diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/UploadServiceImpl.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/UploadServiceImpl.java index 636dc77e..bbe982fe 100644 --- a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/UploadServiceImpl.java +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/UploadServiceImpl.java @@ -306,6 +306,20 @@ public class UploadServiceImpl implements UploadService { systemAttachmentService.save(systemAttachment); return resultFile; } + + File parentDir = file.getParentFile(); + if (!parentDir.exists()) { + logger.info("父目录不存在,开始创建: {}", parentDir.getAbsolutePath()); + boolean created = parentDir.mkdirs(); + if (!created) { + logger.error("创建目录失败: {}", parentDir.getAbsolutePath()); + throw new CrmebException("无法创建上传目录: " + parentDir.getAbsolutePath()); + } + logger.info("目录创建成功!"); + } else { + logger.info("父目录已存在: {}", parentDir.getAbsolutePath()); + } + CloudVo cloudVo = new CloudVo(); // 判断是否保存本地 String fileIsSave = systemConfigService.getValueByKeyException(SysConfigConstants.CONFIG_FILE_IS_SAVE); diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/WorksServiceImpl.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/WorksServiceImpl.java index f825ad29..674767aa 100644 --- a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/WorksServiceImpl.java +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/WorksServiceImpl.java @@ -25,7 +25,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; /** @@ -58,11 +62,8 @@ public class WorksServiceImpl extends ServiceImpl implements Wo // 验证作品类型和媒体资源 if ("IMAGE".equalsIgnoreCase(request.getType())) { - // 图片作品必须有图片列表 - if (request.getImageUrls() == null || request.getImageUrls().isEmpty()) { - throw new CrmebException("图片作品必须上传至少一张图片"); - } - if (request.getImageUrls().size() > 9) { + // 图片作品允许只上传封面(图片列表可为空),但如果上传了图片,最多9张 + if (request.getImageUrls() != null && request.getImageUrls().size() > 9) { throw new CrmebException("图片作品最多只能上传9张图片"); } } else if ("VIDEO".equalsIgnoreCase(request.getType())) { @@ -85,7 +86,8 @@ public class WorksServiceImpl extends ServiceImpl implements Wo // 创建作品对象 Works works = new Works(); works.setUid(userId); - works.setTitle(request.getTitle()); + // title 字段数据库 NOT NULL,允许为空时写入空字符串 + works.setTitle(StrUtil.blankToDefault(request.getTitle(), "")); works.setDescription(request.getDescription()); works.setType(request.getType().toUpperCase()); // 统一转为大写 works.setCoverImage(request.getCoverUrl()); @@ -195,7 +197,8 @@ public class WorksServiceImpl extends ServiceImpl implements Wo // 更新作品信息 if (request.getTitle() != null) { - works.setTitle(request.getTitle()); + // title 字段数据库 NOT NULL,允许为空时写入空字符串 + works.setTitle(StrUtil.blankToDefault(request.getTitle(), "")); } if (request.getDescription() != null) { works.setDescription(request.getDescription()); @@ -217,6 +220,10 @@ public class WorksServiceImpl extends ServiceImpl implements Wo // 处理图片列表 if (request.getImageUrls() != null) { + // 图片列表允许为空(仅封面发布) + if (!request.getImageUrls().isEmpty() && request.getImageUrls().size() > 9) { + throw new CrmebException("图片作品最多只能上传9张图片"); + } if (request.getImageUrls().isEmpty()) { works.setImages(null); } else { @@ -454,6 +461,117 @@ public class WorksServiceImpl extends ServiceImpl implements Wo return searchWorks(request, userId); } + @Override + public CommonPage getNearbyWorks(Integer page, Integer limit, Integer userId) { + if (userId == null) { + throw new CrmebException("请先登录"); + } + if (page == null || page < 1) page = 1; + if (limit == null || limit < 1) limit = 20; + + User currentUser = userService.getById(userId); + if (currentUser == null) { + throw new CrmebException("用户不存在"); + } + if (currentUser.getLatitude() == null || currentUser.getLongitude() == null) { + throw new CrmebException("请先开启定位"); + } + + LambdaQueryWrapper userWrapper = new LambdaQueryWrapper<>(); + userWrapper.eq(User::getStatus, true) + .ne(User::getUid, userId) + .isNotNull(User::getLatitude) + .isNotNull(User::getLongitude) + .last("LIMIT 2000"); + List users = userService.list(userWrapper); + + Map userDistanceMap = new HashMap<>(); + for (User u : users) { + double d = calculateDistanceKm( + currentUser.getLatitude().doubleValue(), + currentUser.getLongitude().doubleValue(), + u.getLatitude().doubleValue(), + u.getLongitude().doubleValue()); + userDistanceMap.put(u.getUid(), d); + } + + List sortedUserIds = users.stream() + .map(User::getUid) + .filter(uid -> uid != null) + .sorted(Comparator.comparingDouble(uid -> userDistanceMap.getOrDefault(uid, Double.MAX_VALUE))) + .collect(Collectors.toList()); + + if (sortedUserIds.isEmpty()) { + CommonPage empty = new CommonPage<>(); + empty.setPage(page); + empty.setLimit(limit); + empty.setTotal(0L); + empty.setTotalPage(0); + empty.setList(new ArrayList<>()); + return empty; + } + + int userPickCount = Math.min(sortedUserIds.size(), Math.max(limit * 50, 500)); + List candidateUserIds = sortedUserIds.subList(0, userPickCount); + + int worksPickCount = Math.max(limit * 200, 2000); + Page worksPage = new Page<>(1, worksPickCount); + LambdaQueryWrapper worksWrapper = new LambdaQueryWrapper<>(); + worksWrapper.eq(Works::getIsDeleted, 0) + .eq(Works::getStatus, 1) + .ne(Works::getUid, userId) + .in(Works::getUid, candidateUserIds) + .orderByDesc(Works::getCreateTime); + Page candidateWorksPage = page(worksPage, worksWrapper); + List candidateWorks = candidateWorksPage.getRecords() != null ? candidateWorksPage.getRecords() : Collections.emptyList(); + + List sortedWorks = new ArrayList<>(candidateWorks); + sortedWorks.sort((a, b) -> { + Double da = userDistanceMap.getOrDefault(a.getUid(), Double.MAX_VALUE); + Double db = userDistanceMap.getOrDefault(b.getUid(), Double.MAX_VALUE); + int c = Double.compare(da, db); + if (c != 0) return c; + if (a.getCreateTime() == null && b.getCreateTime() == null) return 0; + if (a.getCreateTime() == null) return 1; + if (b.getCreateTime() == null) return -1; + return b.getCreateTime().compareTo(a.getCreateTime()); + }); + + long total = sortedWorks.size(); + int start = (page - 1) * limit; + int end = (int) Math.min(total, start + limit); + List list; + if (start >= total) { + list = new ArrayList<>(); + } else { + list = sortedWorks.subList(start, end) + .stream() + .map(w -> convertToResponse(w, userId)) + .collect(Collectors.toList()); + } + + CommonPage result = new CommonPage<>(); + result.setPage(page); + result.setLimit(limit); + result.setTotal(total); + result.setTotalPage((int) Math.ceil(total * 1.0 / limit)); + result.setList(list); + return result; + } + + private double calculateDistanceKm(double lat1, double lon1, double lat2, double lon2) { + final double EARTH_RADIUS = 6371.0; + double lat1Rad = Math.toRadians(lat1); + double lat2Rad = Math.toRadians(lat2); + double deltaLat = Math.toRadians(lat2 - lat1); + double deltaLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + + Math.cos(lat1Rad) * Math.cos(lat2Rad) + * Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return EARTH_RADIUS * c; + } + @Override public void increaseViewCount(Long worksId) { try { diff --git a/Zhibo/zhibo-h/sql/add_works_hot_fields.sql b/Zhibo/zhibo-h/sql/add_works_hot_fields.sql index 5f38822f..c5f6ed06 100644 --- a/Zhibo/zhibo-h/sql/add_works_hot_fields.sql +++ b/Zhibo/zhibo-h/sql/add_works_hot_fields.sql @@ -1,15 +1,54 @@ -- 添加作品热门相关字段 -- 执行时间:2026-01-08 -USE `crmeb`; +USE `zhibo`; --- 在 works 表添加热门相关字段 -ALTER TABLE `works` -ADD COLUMN `is_hot` tinyint DEFAULT 0 COMMENT '是否热门:1-是 0-否' AFTER `status`, -ADD COLUMN `hot_time` datetime DEFAULT NULL COMMENT '设置热门的时间' AFTER `is_hot`; +-- 检查字段是否已存在,如果不存在则添加 +-- 添加 is_hot 字段(如果不存在) +SET @col_exists = 0; +SELECT COUNT(*) INTO @col_exists +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'zhibo' + AND TABLE_NAME = 'eb_works' + AND COLUMN_NAME = 'is_hot'; --- 添加索引以提高查询性能 -ALTER TABLE `works` ADD INDEX `idx_is_hot` (`is_hot`); +SET @sql = IF(@col_exists = 0, + 'ALTER TABLE `eb_works` ADD COLUMN `is_hot` tinyint DEFAULT 0 COMMENT ''是否热门:1-是 0-否'' AFTER `status`', + 'SELECT ''字段 is_hot 已存在'' AS message'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; --- 查看修改结果 -SHOW COLUMNS FROM `works` LIKE '%hot%'; +-- 添加 hot_time 字段(如果不存在) +SET @col_exists = 0; +SELECT COUNT(*) INTO @col_exists +FROM information_schema.COLUMNS +WHERE TABLE_SCHEMA = 'zhibo' + AND TABLE_NAME = 'eb_works' + AND COLUMN_NAME = 'hot_time'; + +SET @sql = IF(@col_exists = 0, + 'ALTER TABLE `eb_works` ADD COLUMN `hot_time` datetime DEFAULT NULL COMMENT ''设置热门的时间'' AFTER `is_hot`', + 'SELECT ''字段 hot_time 已存在'' AS message'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 添加索引(如果不存在) +SET @index_exists = 0; +SELECT COUNT(*) INTO @index_exists +FROM information_schema.STATISTICS +WHERE TABLE_SCHEMA = 'zhibo' + AND TABLE_NAME = 'eb_works' + AND INDEX_NAME = 'idx_is_hot'; + +SET @sql = IF(@index_exists = 0, + 'ALTER TABLE `eb_works` ADD INDEX `idx_is_hot` (`is_hot`)', + 'SELECT ''索引 idx_is_hot 已存在'' AS message'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 查看最终结果 +SHOW COLUMNS FROM `eb_works` LIKE '%hot%'; +SELECT '作品热门字段添加完成!' AS result; 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 ddcf6b81..f03353e2 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 @@ -4,9 +4,11 @@ import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; +import android.provider.OpenableColumns; import android.text.TextUtils; import android.util.Log; import android.view.KeyEvent; @@ -19,7 +21,11 @@ import androidx.appcompat.widget.PopupMenu; import androidx.recyclerview.widget.LinearLayoutManager; import com.example.livestreaming.databinding.ActivityConversationBinding; +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.FileUploadResponse; import com.google.android.material.snackbar.Snackbar; import org.json.JSONArray; @@ -27,6 +33,8 @@ import org.json.JSONObject; import java.io.IOException; import java.io.File; +import java.io.InputStream; +import java.io.FileOutputStream; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -38,6 +46,7 @@ import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; +import okhttp3.MultipartBody; public class ConversationActivity extends AppCompatActivity { @@ -67,6 +76,9 @@ public class ConversationActivity extends AppCompatActivity { private String otherUserName; private String otherUserAvatarUrl; + private static final int REQ_PICK_BURN_IMAGE = 4101; + private Integer pendingBurnSeconds; + public static void start(Context context, String conversationId, String title) { Intent intent = new Intent(context, ConversationActivity.class); intent.putExtra(EXTRA_CONVERSATION_ID, conversationId); @@ -811,6 +823,13 @@ public class ConversationActivity extends AppCompatActivity { } }); + binding.burnImageButton.setOnClickListener(new DebounceClickListener(300) { + @Override + public void onDebouncedClick(View v) { + sendBurnImage(); + } + }); + binding.messageInput.setOnEditorActionListener((v, actionId, event) -> { if (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER) { sendMessage(); @@ -826,6 +845,202 @@ public class ConversationActivity extends AppCompatActivity { // 设置通话按钮点击事件 setupCallButtons(); } + + private void sendBurnImage() { + if (!AuthHelper.requireLoginWithToast(this, "发送私信需要登录")) { + return; + } + if (conversationId == null) { + Snackbar.make(binding.getRoot(), "会话ID无效", Snackbar.LENGTH_SHORT).show(); + return; + } + + String[] items = new String[]{"3秒", "5秒", "10秒"}; + new AlertDialog.Builder(this) + .setTitle("阅后时间") + .setItems(items, (dialog, which) -> { + int sec; + if (which == 0) sec = 3; + else if (which == 1) sec = 5; + else sec = 10; + pendingBurnSeconds = sec; + Intent it = new Intent(Intent.ACTION_GET_CONTENT); + it.setType("image/*"); + it.addCategory(Intent.CATEGORY_OPENABLE); + startActivityForResult(Intent.createChooser(it, "选择图片"), REQ_PICK_BURN_IMAGE); + }) + .setNegativeButton("取消", null) + .show(); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { + super.onActivityResult(requestCode, resultCode, data); + if (requestCode == REQ_PICK_BURN_IMAGE) { + if (resultCode != RESULT_OK || data == null) { + pendingBurnSeconds = null; + return; + } + Uri uri = data.getData(); + if (uri == null) { + pendingBurnSeconds = null; + Snackbar.make(binding.getRoot(), "选择图片失败", Snackbar.LENGTH_SHORT).show(); + return; + } + Integer sec = pendingBurnSeconds; + pendingBurnSeconds = null; + if (sec == null) { + Snackbar.make(binding.getRoot(), "阅后时间无效", Snackbar.LENGTH_SHORT).show(); + return; + } + uploadAndSendBurnImage(uri, sec); + } + } + + private void uploadAndSendBurnImage(Uri imageUri, int burnSeconds) { + ApiService api = ApiClient.getService(this); + if (api == null) { + Snackbar.make(binding.getRoot(), "请先登录", Snackbar.LENGTH_SHORT).show(); + return; + } + + File file = copyUriToCacheFile(imageUri); + if (file == null || !file.exists()) { + Snackbar.make(binding.getRoot(), "读取图片失败", Snackbar.LENGTH_SHORT).show(); + return; + } + + RequestBody requestFile = RequestBody.create(okhttp3.MediaType.parse("image/*"), file); + MultipartBody.Part body = MultipartBody.Part.createFormData("file", file.getName(), requestFile); + RequestBody model = RequestBody.create(okhttp3.MediaType.parse("text/plain"), "works"); + RequestBody pid = RequestBody.create(okhttp3.MediaType.parse("text/plain"), "0"); + + ChatMessage localMessage = ChatMessage.createBurnImageMessage("我", imageUri.toString(), burnSeconds); + localMessage.setStatus(ChatMessage.MessageStatus.SENDING); + messages.add(localMessage); + adapter.submitList(new ArrayList<>(messages)); + scrollToBottom(); + + api.uploadImage(body, model, pid).enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(retrofit2.Call> call, retrofit2.Response> response) { + if (!response.isSuccessful() || response.body() == null || !response.body().isOk()) { + String msg = response.body() != null ? response.body().getMessage() : "上传失败"; + runOnUiThread(() -> { + Snackbar.make(binding.getRoot(), msg, Snackbar.LENGTH_SHORT).show(); + messages.remove(localMessage); + adapter.submitList(new ArrayList<>(messages)); + }); + return; + } + FileUploadResponse data = response.body().getData(); + String url = data != null ? data.getUrl() : null; + if (TextUtils.isEmpty(url)) { + runOnUiThread(() -> { + Snackbar.make(binding.getRoot(), "上传失败", Snackbar.LENGTH_SHORT).show(); + messages.remove(localMessage); + adapter.submitList(new ArrayList<>(messages)); + }); + return; + } + sendBurnImageToServer(url, burnSeconds, localMessage); + } + + @Override + public void onFailure(retrofit2.Call> call, Throwable t) { + runOnUiThread(() -> { + Snackbar.make(binding.getRoot(), "上传失败", Snackbar.LENGTH_SHORT).show(); + messages.remove(localMessage); + adapter.submitList(new ArrayList<>(messages)); + }); + } + }); + } + + private void sendBurnImageToServer(String mediaUrl, int burnSeconds, ChatMessage localMessage) { + String token = AuthStore.getToken(this); + if (token == null) return; + + String url = ApiConfig.getBaseUrl() + "/api/front/conversations/" + conversationId + "/messages"; + Log.d(TAG, "发送消息: " + url); + + try { + JSONObject body = new JSONObject(); + body.put("message", ""); + body.put("content", ""); + body.put("messageType", "burn_image"); + body.put("mediaUrl", mediaUrl); + body.put("burnSeconds", burnSeconds); + + 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(); + messages.remove(localMessage); + adapter.submitList(new ArrayList<>(messages)); + }); + } + + @Override + public void onResponse(Call call, Response response) throws IOException { + String responseBody = response.body() != null ? response.body().string() : ""; + Log.d(TAG, "发送消息响应: " + responseBody); + runOnUiThread(() -> handleSendResponse(responseBody, localMessage)); + } + }); + } catch (Exception e) { + Log.e(TAG, "构建请求失败", e); + } + } + + private File copyUriToCacheFile(Uri uri) { + try { + String name = queryDisplayName(uri); + if (TextUtils.isEmpty(name)) { + name = "burn_" + System.currentTimeMillis(); + } + File out = new File(getCacheDir(), name); + InputStream is = getContentResolver().openInputStream(uri); + if (is == null) return null; + FileOutputStream fos = new FileOutputStream(out); + byte[] buf = new byte[8192]; + int len; + while ((len = is.read(buf)) != -1) { + fos.write(buf, 0, len); + } + fos.flush(); + fos.close(); + is.close(); + return out; + } catch (Exception e) { + return null; + } + } + + private String queryDisplayName(Uri uri) { + android.database.Cursor cursor = null; + try { + cursor = getContentResolver().query(uri, null, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (idx >= 0) { + return cursor.getString(idx); + } + } + } catch (Exception ignored) { + } finally { + if (cursor != null) cursor.close(); + } + return null; + } /** * 设置通话按钮 diff --git a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java index 2d67c44e..a801572f 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java @@ -35,6 +35,8 @@ import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import com.bumptech.glide.Glide; import com.example.livestreaming.databinding.ActivityMainBinding; @@ -53,6 +55,8 @@ import com.example.livestreaming.net.PageResponse; import com.example.livestreaming.net.Room; import com.example.livestreaming.net.StreamConfig; import com.example.livestreaming.net.WorksResponse; +import com.example.livestreaming.location.TianDiTuLocationService; +import com.example.livestreaming.net.UserEditRequest; import java.io.IOException; import java.util.ArrayList; @@ -77,16 +81,27 @@ public class MainActivity extends AppCompatActivity { private final List allFeedItems = new ArrayList<>(); private final List followRooms = new ArrayList<>(); // 关注页面的房间列表 private final List discoverRooms = new ArrayList<>(); // 发现页面的房间列表 - private final List nearbyUsers = new ArrayList<>(); // 附近页面的用户列表 - private NearbyUsersAdapter nearbyUsersAdapter; // 附近页面的适配器 - private boolean isLoadingMoreNearby = false; // 是否正在加载更多附近用户 + + // 附近作品相关 + private FeedAdapter nearbyWorksAdapter; // 附近作品适配器 + private final List nearbyWorks = new ArrayList<>(); // 附近作品列表 + private int currentNearbyWorksPage = 1; + private boolean isLoadingNearbyWorks = false; + private TianDiTuLocationService locationService; + private ActivityResultLauncher requestLocationPermissionLauncher; // 作品相关 - private WorksAdapter worksAdapter; // 作品列表适配器 private final List allWorks = new ArrayList<>(); // 所有作品列表 private int currentWorksPage = 1; // 当前作品页码 private boolean isLoadingWorks = false; // 是否正在加载作品 + // 热门作品相关 + private final List hotWorks = new ArrayList<>(); // 热门作品列表 + private int currentHotWorksPage = 1; // 当前热门作品页码 + private boolean isLoadingHotWorks = false; // 是否正在加载热门作品 + private boolean hasMoreHotWorks = true; // 是否还有更多热门作品 + private long lastHotTabLeaveTime = 0; // 离开热门Tab的时间戳 + // 新Tab布局相关 private RecommendUserAdapter recommendUserAdapter; // 关注页面推荐用户适配器 private ChannelTagAdapter myChannelAdapter; // 发现页面我的频道适配器 @@ -146,6 +161,26 @@ public class MainActivity extends AppCompatActivity { loadAvatarFromPrefs(); setupSpeechRecognizer(); + // 初始化定位服务和权限请求器 + locationService = new TianDiTuLocationService(this); + requestLocationPermissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestMultiplePermissions(), + result -> { + boolean fineLocationGranted = Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_FINE_LOCATION)); + boolean coarseLocationGranted = Boolean.TRUE.equals(result.get(Manifest.permission.ACCESS_COARSE_LOCATION)); + + if (fineLocationGranted || coarseLocationGranted) { + // 权限已授予,如果当前在附近Tab,刷新显示数据 + if ("附近".equals(currentTopTab)) { + loadNearbyWorks(); + } + } else { + // 权限被拒绝,在附近Tab显示相应提示 + Toast.makeText(this, "需要位置权限才能查看附近作品", Toast.LENGTH_SHORT).show(); + } + } + ); + // 初始化未读消息数量 if (UnreadMessageManager.getUnreadCount(this) == 0) { // 从会话列表获取总未读数量 @@ -359,7 +394,11 @@ public class MainActivity extends AppCompatActivity { // 启用下拉刷新 if (binding.swipeRefresh != null) { binding.swipeRefresh.setOnRefreshListener(() -> { - fetchDiscoverRooms(); + if ("热门".equals(currentCategory)) { + refreshHotWorks(); + } else { + fetchDiscoverRooms(); + } }); } @@ -459,19 +498,26 @@ public class MainActivity extends AppCompatActivity { } }); - // 从后端加载分类数据 - loadCategoriesFromBackend(); + // 初始化分类标签(包含"热门"标签) + initializeCategoryTabs(); binding.categoryTabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { @Override public void onTabSelected(TabLayout.Tab tab) { if (tab == null) return; CharSequence title = tab.getText(); - currentCategory = title != null ? title.toString() : "推荐"; + currentCategory = title != null ? title.toString() : "热门"; // 保存选中的分类 CategoryFilterManager.saveLastCategory(MainActivity.this, currentCategory); - // 应用筛选(带动画) - applyCategoryFilterWithAnimation(currentCategory); + + // 根据分类加载数据 + if ("热门".equals(currentCategory)) { + // 加载热门作品 + refreshHotWorks(); + } else { + // 应用其他分类筛选(带动画) + applyCategoryFilterWithAnimation(currentCategory); + } } @Override @@ -482,11 +528,18 @@ public class MainActivity extends AppCompatActivity { public void onTabReselected(TabLayout.Tab tab) { if (tab == null) return; CharSequence title = tab.getText(); - currentCategory = title != null ? title.toString() : "推荐"; + currentCategory = title != null ? title.toString() : "热门"; // 保存选中的分类 CategoryFilterManager.saveLastCategory(MainActivity.this, currentCategory); - // 应用筛选(带动画) - applyCategoryFilterWithAnimation(currentCategory); + + // 根据分类加载数据 + if ("热门".equals(currentCategory)) { + // 刷新热门作品 + refreshHotWorks(); + } else { + // 应用其他分类筛选(带动画) + applyCategoryFilterWithAnimation(currentCategory); + } } }); @@ -503,10 +556,15 @@ public class MainActivity extends AppCompatActivity { if (total <= 0) return; if (lastVisible >= total - 4) { - long now = System.currentTimeMillis(); - if (!isFetching && now - lastFetchMs > 1500) { - // 注释掉加载更多,使用静态数据 - // fetchRooms(); + // 根据当前分类加载更多数据 + if ("热门".equals(currentCategory)) { + loadMoreHotWorks(); + } else { + long now = System.currentTimeMillis(); + if (!isFetching && now - lastFetchMs > 1500) { + // 注释掉加载更多,使用静态数据 + // fetchRooms(); + } } } } @@ -1985,8 +2043,8 @@ public class MainActivity extends AppCompatActivity { // 发现页面数据将在 showDiscoverTab() 中加载 // 不在这里加载,避免重复请求 - // 初始化附近页面数据 - nearbyUsers.clear(); + // 初始化附近作品数据 + nearbyWorks.clear(); } /** @@ -2448,15 +2506,20 @@ public class MainActivity extends AppCompatActivity { // 使用房间适配器显示推荐内容 if (adapter != null) { - // 如果已经有混合的Feed数据,直接显示 - if (!allFeedItems.isEmpty()) { - Log.d(TAG, "showDiscoverTab() 使用缓存数据,共 " + allFeedItems.size() + " 项"); - adapter.submitList(new ArrayList<>(allFeedItems)); - updateDiscoverEmptyState(); - } else { - // 否则从后端加载数据 - Log.d(TAG, "showDiscoverTab() 开始加载数据"); - fetchDiscoverRooms(); + // 默认选中"热门"Tab并加载热门作品 + if (binding.categoryTabs != null) { + binding.categoryTabs.post(() -> { + TabLayout.Tab hotTab = binding.categoryTabs.getTabAt(0); // "热门"Tab在索引0 + if (hotTab != null && !hotTab.isSelected()) { + hotTab.select(); // 选中"热门"Tab,会触发onTabSelected监听器并加载热门作品 + } else if (hotWorks.isEmpty()) { + // 如果已经选中但没有数据,手动加载热门作品 + refreshHotWorks(); + } else { + // 使用现有的热门作品数据 + updateHotWorksUI(); + } + }); } } else { Log.w(TAG, "showDiscoverTab() adapter 为空"); @@ -2496,7 +2559,7 @@ public class MainActivity extends AppCompatActivity { } /** - * 显示附近页面 + * 显示附近页面(附近作品,按距离排序) */ private void showNearbyTab() { // 隐藏分类标签容器(附近页面不需要分类筛选) @@ -2524,97 +2587,68 @@ public class MainActivity extends AppCompatActivity { } hideEmptyState(); - // 初始化附近用户适配器(如果还没有) - if (nearbyUsersAdapter == null) { - nearbyUsersAdapter = new NearbyUsersAdapter(user -> { - if (user == null) return; - // 点击附近用户,跳转到用户主页 - UserProfileReadOnlyActivity.start(MainActivity.this, - user.getId(), - user.getName(), - user.getDistanceText(), // 位置信息 - "", // bio - ""); // avatarUrl - }); - // 设置添加好友按钮点击事件 - nearbyUsersAdapter.setOnAddFriendClickListener(user -> { - if (user == null) return; - sendFriendRequestToNearbyUser(user); - }); - } - - // 设置附近内容RecyclerView - RecyclerView nearbyContentList = findViewById(R.id.nearbyContentList); - if (nearbyContentList != null && nearbyContentList.getAdapter() == null) { - LinearLayoutManager layoutManager = new LinearLayoutManager(this); - nearbyContentList.setLayoutManager(layoutManager); - nearbyContentList.setAdapter(nearbyUsersAdapter); - - // 添加滚动监听,实现加载更多 - nearbyContentList.addOnScrollListener(new RecyclerView.OnScrollListener() { - @Override - public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { - super.onScrolled(recyclerView, dx, dy); - - // 只在向下滚动时检测 - if (dy > 0) { - int visibleItemCount = layoutManager.getChildCount(); - int totalItemCount = layoutManager.getItemCount(); - int firstVisibleItemPosition = layoutManager.findFirstVisibleItemPosition(); - - // 当滚动到底部附近时加载更多(距离底部还有5个item时开始加载) - if ((visibleItemCount + firstVisibleItemPosition) >= totalItemCount - 5 - && firstVisibleItemPosition >= 0) { - loadMoreNearbyUsers(); - } - } - } - }); - } - // 设置附近页面的SwipeRefreshLayout SwipeRefreshLayout nearbySwipeRefresh = findViewById(R.id.nearbySwipeRefresh); if (nearbySwipeRefresh != null) { - nearbySwipeRefresh.setOnRefreshListener(this::loadNearbyUsers); + nearbySwipeRefresh.setOnRefreshListener(this::loadNearbyWorks); } // 设置刷新按钮点击事件 View btnOpenLocal = findViewById(R.id.btnOpenLocal); if (btnOpenLocal != null) { - btnOpenLocal.setOnClickListener(v -> loadNearbyUsers()); + btnOpenLocal.setOnClickListener(v -> loadNearbyWorks()); } // 设置开启定位按钮点击事件 View btnEnableLocation = findViewById(R.id.btnEnableLocation); if (btnEnableLocation != null) { - btnEnableLocation.setOnClickListener(v -> requestLocationPermission()); + btnEnableLocation.setOnClickListener(v -> { + requestLocationPermissionLauncher.launch(new String[]{ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + }); + }); } - // 检查位置权限 - if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) - != PackageManager.PERMISSION_GRANTED - && ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) - != PackageManager.PERMISSION_GRANTED) { - // 显示空状态提示,隐藏其他状态 - View emptyNearbyContainer = findViewById(R.id.emptyNearbyContainer); - View nearbyLoadingContainer = findViewById(R.id.nearbyLoadingContainer); - RecyclerView nearbyList = findViewById(R.id.nearbyContentList); - if (nearbyLoadingContainer != null) { - nearbyLoadingContainer.setVisibility(View.GONE); - } - if (emptyNearbyContainer != null) { - emptyNearbyContainer.setVisibility(View.VISIBLE); - } - if (nearbyList != null) { - nearbyList.setVisibility(View.GONE); - } - // 请求位置权限 - requestLocationPermission(); + // 加载附近作品 + loadNearbyWorks(); + } + + /** + * 初始化分类标签(包含"热门"标签) + */ + private void initializeCategoryTabs() { + if (binding == null || binding.categoryTabs == null) { + Log.w(TAG, "binding或categoryTabs为null,无法初始化分类标签"); return; } - // 有权限,加载附近用户 - loadNearbyUsers(); + // 清空现有标签 + binding.categoryTabs.removeAllTabs(); + + // 添加"热门"标签(固定在第一位) + binding.categoryTabs.addTab(binding.categoryTabs.newTab().setText("热门")); + + // 从本地加载我的频道配置 + loadMyChannelsFromPrefs(); + + // 添加我的频道到分类标签 + for (ChannelTagAdapter.ChannelTag channel : myChannels) { + if (channel != null && channel.getName() != null) { + binding.categoryTabs.addTab(binding.categoryTabs.newTab().setText(channel.getName())); + } + } + + // 默认选中"热门"标签 + if (binding.categoryTabs.getTabCount() > 0) { + currentCategory = "热门"; + TabLayout.Tab hotTab = binding.categoryTabs.getTabAt(0); + if (hotTab != null) { + hotTab.select(); + } + } + + Log.d(TAG, "分类标签初始化完成,共 " + binding.categoryTabs.getTabCount() + " 个标签"); } /** @@ -2711,66 +2745,6 @@ public class MainActivity extends AppCompatActivity { return list; } - /** - * 构建附近页面的用户列表(使用模拟位置数据) - */ - private List buildNearbyUsers() { - // TODO: 接入后端接口 - 获取附近用户列表 - // 接口路径: GET /api/users/nearby - // 请求参数: - // - latitude: 当前用户纬度(必填) - // - longitude: 当前用户经度(必填) - // - radius (可选): 搜索半径(单位:米,默认5000) - // - page (可选): 页码 - // - pageSize (可选): 每页数量 - // 返回数据格式: ApiResponse> - // NearbyUser对象应包含: id, name, avatarUrl, distance (距离,单位:米), isLive, location等字段 - // 需要先获取用户位置权限,然后调用此接口 - List list = new ArrayList<>(); - - // 不再使用模拟数据,只从后端接口获取真实附近用户数据 - - return list; - } - - /** - * 请求位置权限 - */ - private void requestLocationPermission() { - // 检查是否应该显示权限说明 - // shouldShowRequestPermissionRationale 返回 true 表示用户之前拒绝过,但还可以再次请求 - // 返回 false 可能是第一次请求,也可能是用户选择了"不再询问" - boolean shouldShowRationale = ActivityCompat.shouldShowRequestPermissionRationale(this, Manifest.permission.ACCESS_FINE_LOCATION); - - if (shouldShowRationale) { - // 用户之前拒绝过,但还可以再次请求,显示说明后直接请求权限 - new AlertDialog.Builder(this) - .setTitle("需要位置权限") - .setMessage("附近功能需要访问位置信息,以便为您推荐附近的用户和直播。") - .setPositiveButton("授权", (dialog, which) -> { - // 直接请求权限 - ActivityCompat.requestPermissions(this, - new String[]{ - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - }, - REQUEST_LOCATION_PERMISSION); - }) - .setNegativeButton("取消", null) - .setCancelable(true) - .show(); - } else { - // 第一次请求权限,或者用户选择了"不再询问" - // 直接请求权限,如果用户选择了"不再询问",系统会静默失败 - // 我们会在权限回调中处理这种情况 - ActivityCompat.requestPermissions(this, - new String[]{ - Manifest.permission.ACCESS_FINE_LOCATION, - Manifest.permission.ACCESS_COARSE_LOCATION - }, - REQUEST_LOCATION_PERMISSION); - } - } // 用于取消超时回调的Runnable private Runnable followLoadingTimeoutRunnable; @@ -3136,43 +3110,6 @@ public class MainActivity extends AppCompatActivity { }); } - /** - * 向附近用户发送好友请求 - */ - private void sendFriendRequestToNearbyUser(NearbyUser user) { - if (!AuthHelper.isLoggedIn(this)) { - AuthHelper.requireLogin(this, "添加好友需要登录"); - return; - } - - try { - int userId = Integer.parseInt(user.getId()); - Map body = new java.util.HashMap<>(); - body.put("targetUserId", userId); - body.put("message", "我想加你为好友"); - - ApiClient.getService(getApplicationContext()).sendFriendRequest(body) - .enqueue(new Callback>() { - @Override - public void onResponse(Call> call, Response> response) { - if (response.isSuccessful() && response.body() != null && response.body().isOk()) { - Toast.makeText(MainActivity.this, "好友请求已发送", Toast.LENGTH_SHORT).show(); - } else { - String msg = response.body() != null && response.body().getMessage() != null - ? response.body().getMessage() : "发送失败"; - Toast.makeText(MainActivity.this, msg, Toast.LENGTH_SHORT).show(); - } - } - - @Override - public void onFailure(Call> call, Throwable t) { - Toast.makeText(MainActivity.this, "网络错误", Toast.LENGTH_SHORT).show(); - } - }); - } catch (NumberFormatException e) { - Toast.makeText(this, "用户ID格式错误", Toast.LENGTH_SHORT).show(); - } - } /** * 加载频道数据(发现页面) @@ -3194,15 +3131,21 @@ public class MainActivity extends AppCompatActivity { } } + /** - * 加载附近用户(附近页面) - * 展示同城用户,最多10名,刷新时随机展示同省用户 + * 加载附近作品(按距离从近到远排序) */ - private void loadNearbyUsers() { + private void loadNearbyWorks() { + if (isLoadingNearbyWorks) { + return; + } + + isLoadingNearbyWorks = true; + currentNearbyWorksPage = 1; + View emptyNearbyContainer = findViewById(R.id.emptyNearbyContainer); RecyclerView nearbyList = findViewById(R.id.nearbyContentList); View nearbyLoadingContainer = findViewById(R.id.nearbyLoadingContainer); - TextView nearbyCountText = findViewById(R.id.nearbyCountText); TextView locationText = findViewById(R.id.locationText); // 获取SwipeRefreshLayout引用,判断是否是下拉刷新触发 @@ -3222,266 +3165,284 @@ public class MainActivity extends AppCompatActivity { nearbyList.setVisibility(View.GONE); } if (locationText != null) { - locationText.setText("正在搜索附近的人..."); + locationText.setText("正在获取您的位置..."); } - // 取消之前的超时回调 - if (nearbyLoadingTimeoutRunnable != null) { - handler.removeCallbacks(nearbyLoadingTimeoutRunnable); + // 更新界面文案为附近作品 + TextView loadingText = nearbyLoadingContainer != null ? + nearbyLoadingContainer.findViewById(android.R.id.text1) : null; + if (loadingText != null) { + loadingText.setText("正在加载附近作品..."); } - // 设置3秒超时,确保加载圈不会一直显示 - nearbyLoadingTimeoutRunnable = () -> { - // 停止下拉刷新 - stopNearbySwipeRefresh(); - if (nearbyLoadingContainer != null && nearbyLoadingContainer.getVisibility() == View.VISIBLE) { - nearbyLoadingContainer.setVisibility(View.GONE); - // 如果还在加载,显示空状态 - if (nearbyUsers.isEmpty()) { - if (nearbyList != null) { - nearbyList.setVisibility(View.GONE); - } - if (emptyNearbyContainer != null) { - emptyNearbyContainer.setVisibility(View.VISIBLE); - } - if (locationText != null) { - locationText.setText("加载超时,请重试"); - } - } - } - }; - handler.postDelayed(nearbyLoadingTimeoutRunnable, 3000); + TextView emptyTitle = emptyNearbyContainer != null ? + emptyNearbyContainer.findViewById(R.id.emptyNearbyTitle) : null; + if (emptyTitle != null) { + emptyTitle.setText("附近暂无作品"); + } - // 从后端获取附近用户(使用简化接口) - Log.d(TAG, "开始加载附近用户,调用接口: /api/front/community/nearby-users-simple"); - ApiClient.getService(getApplicationContext()).getNearbyUsersSimple(100) - .enqueue(new Callback>() { - @Override - public void onResponse(Call> call, - Response> response) { - Log.d(TAG, "附近用户接口响应 - HTTP状态码: " + response.code()); - - // 取消超时回调 - if (nearbyLoadingTimeoutRunnable != null) { - handler.removeCallbacks(nearbyLoadingTimeoutRunnable); - } - // 停止下拉刷新 - stopNearbySwipeRefresh(); - // 隐藏加载状态 - if (nearbyLoadingContainer != null) { - nearbyLoadingContainer.setVisibility(View.GONE); - } - - if (response.isSuccessful() && response.body() != null) { - Log.d(TAG, "响应成功 - isOk: " + response.body().isOk() + ", code: " + response.body().getCode()); - - if (response.body().isOk()) { - CommunityResponse.NearbyUserSimpleList userList = response.body().getData(); - Log.d(TAG, "用户列表数据: " + (userList != null ? "不为空" : "为空")); - - if (userList != null && userList.getUsers() != null) { - Log.d(TAG, "用户数量: " + userList.getUsers().size()); - - if (!userList.getUsers().isEmpty()) { - nearbyUsers.clear(); - - // 不打乱顺序,保持后端返回的距离排序(从近到远) - List sortedUsers = userList.getUsers(); - - // 显示所有用户,不限制数量 - for (CommunityResponse.NearbyUserSimple user : sortedUsers) { - String address = user.address != null ? user.address : "未设置位置"; - String distanceText = user.distanceText != null ? user.distanceText : ""; - - // 调试日志:打印原始数据 - Log.d(TAG, "用户数据 - ID: " + user.id + ", 昵称: " + user.nickname); - Log.d(TAG, " 地址(address): " + user.address); - Log.d(TAG, " 距离(distanceText): " + user.distanceText); - - // 显示逻辑: - // 1. 如果有距离,显示:地址 · 距离 - // 2. 如果没有距离,只显示:地址 - String locationInfo; - if (!distanceText.isEmpty()) { - locationInfo = address + " · " + distanceText; - } else { - locationInfo = address; - } - Log.d(TAG, " 最终显示: " + locationInfo); - - nearbyUsers.add(new NearbyUser(String.valueOf(user.id), user.nickname, locationInfo, false)); - } - - if (nearbyUsersAdapter != null) { - nearbyUsersAdapter.submitList(new ArrayList<>(nearbyUsers)); - } - - // 更新附近人数 - if (nearbyCountText != null) { - nearbyCountText.setText("共" + nearbyUsers.size() + "人"); - } - if (locationText != null) { - locationText.setText("已找到附近的人"); - } - - // 显示列表,隐藏空状态 - if (emptyNearbyContainer != null) { - emptyNearbyContainer.setVisibility(View.GONE); - } - if (nearbyList != null) { - nearbyList.setVisibility(View.VISIBLE); - } - } else { - // 没有数据,显示空状态 - Log.w(TAG, "用户列表为空"); - if (nearbyCountText != null) { - nearbyCountText.setText(""); - } - if (locationText != null) { - locationText.setText("附近暂无用户"); - } - if (emptyNearbyContainer != null) { - emptyNearbyContainer.setVisibility(View.VISIBLE); - } - if (nearbyList != null) { - nearbyList.setVisibility(View.GONE); - } - } - } else { - // userList 为空 - Log.w(TAG, "用户列表数据为空"); - if (nearbyCountText != null) { - nearbyCountText.setText(""); - } - if (locationText != null) { - locationText.setText("附近暂无用户"); - } - if (emptyNearbyContainer != null) { - emptyNearbyContainer.setVisibility(View.VISIBLE); - } - if (nearbyList != null) { - nearbyList.setVisibility(View.GONE); - } - } - } else { - // 请求失败 - Log.e(TAG, "请求失败 - message: " + response.body().getMessage()); - if (locationText != null) { - locationText.setText("获取附近用户失败"); - } - if (emptyNearbyContainer != null) { - emptyNearbyContainer.setVisibility(View.VISIBLE); - } - if (nearbyList != null) { - nearbyList.setVisibility(View.GONE); - } - } - } else { - // HTTP 请求失败 - Log.e(TAG, "HTTP请求失败 - 状态码: " + response.code()); - if (locationText != null) { - locationText.setText("网络请求失败"); - } - if (emptyNearbyContainer != null) { - emptyNearbyContainer.setVisibility(View.VISIBLE); - } - if (nearbyList != null) { - nearbyList.setVisibility(View.GONE); - } - } - } - - @Override - public void onFailure(Call> call, Throwable t) { - Log.e(TAG, "加载附近用户失败: " + t.getMessage()); - // 取消超时回调 - if (nearbyLoadingTimeoutRunnable != null) { - handler.removeCallbacks(nearbyLoadingTimeoutRunnable); - } - // 停止下拉刷新 - stopNearbySwipeRefresh(); - // 隐藏加载状态,显示空状态 - if (nearbyLoadingContainer != null) { - nearbyLoadingContainer.setVisibility(View.GONE); - } - if (locationText != null) { - locationText.setText("网络错误,请重试"); - } - if (emptyNearbyContainer != null) { - emptyNearbyContainer.setVisibility(View.VISIBLE); - } - if (nearbyList != null) { - nearbyList.setVisibility(View.GONE); - } - } - }); + TextView emptyDesc = emptyNearbyContainer != null ? + emptyNearbyContainer.findViewById(R.id.emptyNearbyDesc) : null; + if (emptyDesc != null) { + emptyDesc.setText("开启定位后可以查看附近的精彩作品"); + } + + // 首先检查并上传用户位置 + uploadLocationAndLoadWorks(); } /** - * 加载更多附近用户 + * 上传用户位置并加载附近作品 */ - private void loadMoreNearbyUsers() { - // 防止重复加载 - if (isLoadingMoreNearby) { + private void uploadLocationAndLoadWorks() { + if (!AuthHelper.isLoggedIn(this)) { + showNearbyWorksError("请先登录"); return; } - isLoadingMoreNearby = true; + // 检查定位权限 + if (ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) + != PackageManager.PERMISSION_GRANTED + && ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) + != PackageManager.PERMISSION_GRANTED) { + + // 显示需要权限的提示 + View emptyNearbyContainer = findViewById(R.id.emptyNearbyContainer); + View nearbyLoadingContainer = findViewById(R.id.nearbyLoadingContainer); + TextView btnEnableLocation = emptyNearbyContainer != null ? + emptyNearbyContainer.findViewById(R.id.btnEnableLocation) : null; + + if (nearbyLoadingContainer != null) { + nearbyLoadingContainer.setVisibility(View.GONE); + } + if (emptyNearbyContainer != null) { + emptyNearbyContainer.setVisibility(View.VISIBLE); + } + if (btnEnableLocation != null) { + btnEnableLocation.setVisibility(View.VISIBLE); + btnEnableLocation.setOnClickListener(v -> { + requestLocationPermissionLauncher.launch(new String[]{ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + }); + }); + } + + isLoadingNearbyWorks = false; + return; + } - // 从后端获取更多附近用户(使用简化接口) - ApiClient.getService(getApplicationContext()).getNearbyUsersSimple(50) - .enqueue(new Callback>() { - @Override - public void onResponse(Call> call, - Response> response) { - isLoadingMoreNearby = false; - - if (response.isSuccessful() && response.body() != null && response.body().isOk()) { - CommunityResponse.NearbyUserSimpleList userList = response.body().getData(); - if (userList != null && userList.getUsers() != null && !userList.getUsers().isEmpty()) { - // 随机打乱用户列表 - List shuffledUsers = new ArrayList<>(userList.getUsers()); - Collections.shuffle(shuffledUsers); - - // 获取当前已有的用户ID,避免重复 - Set existingIds = new HashSet<>(); - for (NearbyUser user : nearbyUsers) { - existingIds.add(user.getId()); - } - - // 添加新用户(过滤掉已存在的) - int addedCount = 0; - for (CommunityResponse.NearbyUserSimple user : shuffledUsers) { - String userId = String.valueOf(user.id); - if (!existingIds.contains(userId)) { - String address = user.address != null ? user.address : "未设置"; - nearbyUsers.add(new NearbyUser(userId, user.nickname, address, false)); - addedCount++; - } - } - - // 如果添加了新用户,更新列表 - if (addedCount > 0 && nearbyUsersAdapter != null) { - nearbyUsersAdapter.submitList(new ArrayList<>(nearbyUsers)); - - // 更新附近人数 - TextView nearbyCountText = findViewById(R.id.nearbyCountText); - if (nearbyCountText != null) { - nearbyCountText.setText("共" + nearbyUsers.size() + "人"); - } + // 开始定位 + TextView locationText = findViewById(R.id.locationText); + if (locationText != null) { + locationText.setText("正在定位..."); + } + + locationService.startLocation(new TianDiTuLocationService.OnLocationResultListener() { + @Override + public void onSuccess(String province, String city, String address, double latitude, double longitude) { + Log.d(TAG, "定位成功,准备上传位置: " + address + ", 经纬度: " + latitude + "," + longitude); + + // 更新位置到服务器 + UserEditRequest request = new UserEditRequest(); + request.setAddres(address); + request.setLatitude(latitude); + request.setLongitude(longitude); + + ApiClient.getService(MainActivity.this).updateUserInfo(request) + .enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + if (response.isSuccessful() && response.body() != null && response.body().isOk()) { + Log.d(TAG, "位置上传成功,开始加载附近作品"); + // 位置上传成功,开始加载附近作品 + loadNearbyWorksFromServer(); + } else { + String errorMsg = "位置上传失败"; + if (response.body() != null && response.body().getMessage() != null) { + errorMsg += ": " + response.body().getMessage(); } + Log.e(TAG, errorMsg + ", Response code: " + response.code()); + showNearbyWorksError(errorMsg + ",请重试"); } } - } - - @Override - public void onFailure(Call> call, Throwable t) { - isLoadingMoreNearby = false; - Log.e(TAG, "加载更多附近用户失败: " + t.getMessage()); - } - }); + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "位置上传网络错误", t); + showNearbyWorksError("网络错误,请重试"); + } + }); + } + + @Override + public void onError(String error) { + Log.e(TAG, "定位失败: " + error); + showNearbyWorksError("定位失败,请重试"); + } + }); } + /** + * 从服务器加载附近作品 + */ + private void loadNearbyWorksFromServer() { + TextView locationText = findViewById(R.id.locationText); + if (locationText != null) { + locationText.setText("正在加载附近作品..."); + } + + ApiClient.getService(this).getNearbyWorks(currentNearbyWorksPage, 20) + .enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + isLoadingNearbyWorks = false; + + // 停止下拉刷新 + SwipeRefreshLayout nearbySwipeRefresh = findViewById(R.id.nearbySwipeRefresh); + if (nearbySwipeRefresh != null) { + nearbySwipeRefresh.setRefreshing(false); + } + + // 隐藏加载状态 + View nearbyLoadingContainer = findViewById(R.id.nearbyLoadingContainer); + if (nearbyLoadingContainer != null) { + nearbyLoadingContainer.setVisibility(View.GONE); + } + + if (response.isSuccessful() && response.body() != null && response.body().isOk()) { + PageResponse pageData = response.body().getData(); + if (pageData != null && pageData.getList() != null && !pageData.getList().isEmpty()) { + Log.d(TAG, "加载到 " + pageData.getList().size() + " 个附近作品"); + + nearbyWorks.clear(); + nearbyWorks.addAll(pageData.getList()); + + // 显示作品列表 + showNearbyWorksList(); + + TextView locationText = findViewById(R.id.locationText); + if (locationText != null) { + locationText.setText("已找到 " + nearbyWorks.size() + " 个附近作品"); + } + } else { + Log.w(TAG, "附近作品列表为空"); + showNearbyWorksEmpty("附近暂无作品"); + } + } else { + String errorMsg = "加载附近作品失败"; + if (response.body() != null && response.body().getMessage() != null) { + errorMsg += ": " + response.body().getMessage(); + } + Log.e(TAG, errorMsg + ", Response code: " + response.code()); + showNearbyWorksError(errorMsg); + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + isLoadingNearbyWorks = false; + Log.e(TAG, "加载附近作品网络错误", t); + showNearbyWorksError("网络错误,请重试"); + + // 停止下拉刷新 + SwipeRefreshLayout nearbySwipeRefresh = findViewById(R.id.nearbySwipeRefresh); + if (nearbySwipeRefresh != null) { + nearbySwipeRefresh.setRefreshing(false); + } + } + }); + } + + /** + * 显示附近作品列表 + */ + private void showNearbyWorksList() { + RecyclerView nearbyList = findViewById(R.id.nearbyContentList); + View emptyNearbyContainer = findViewById(R.id.emptyNearbyContainer); + + if (nearbyWorksAdapter == null) { + nearbyWorksAdapter = new FeedAdapter(new FeedAdapter.OnItemClickListener() { + @Override + public void onRoomClick(Room room) { + // 附近页面只显示作品,不处理房间点击 + } + + @Override + public void onWorkClick(WorksResponse work) { + if (work != null) { + WorkDetailActivity.start(MainActivity.this, String.valueOf(work.getId())); + } + } + }); + } + + if (nearbyList != null) { + nearbyList.setLayoutManager(new LinearLayoutManager(this)); + nearbyList.setAdapter(nearbyWorksAdapter); + + // 将 WorksResponse 转换为 FeedItem + List feedItems = new ArrayList<>(); + for (WorksResponse work : nearbyWorks) { + feedItems.add(FeedItem.fromWork(work)); + } + nearbyWorksAdapter.submitList(feedItems); + nearbyList.setVisibility(View.VISIBLE); + } + + if (emptyNearbyContainer != null) { + emptyNearbyContainer.setVisibility(View.GONE); + } + } + + /** + * 显示附近作品空状态 + */ + private void showNearbyWorksEmpty(String message) { + View emptyNearbyContainer = findViewById(R.id.emptyNearbyContainer); + RecyclerView nearbyList = findViewById(R.id.nearbyContentList); + TextView emptyTitle = emptyNearbyContainer != null ? + emptyNearbyContainer.findViewById(R.id.emptyNearbyTitle) : null; + TextView locationText = findViewById(R.id.locationText); + + if (nearbyList != null) { + nearbyList.setVisibility(View.GONE); + } + if (emptyNearbyContainer != null) { + emptyNearbyContainer.setVisibility(View.VISIBLE); + } + if (emptyTitle != null) { + emptyTitle.setText(message); + } + if (locationText != null) { + locationText.setText(message); + } + } + + /** + * 显示附近作品错误状态 + */ + private void showNearbyWorksError(String errorMsg) { + isLoadingNearbyWorks = false; + + // 停止下拉刷新 + SwipeRefreshLayout nearbySwipeRefresh = findViewById(R.id.nearbySwipeRefresh); + if (nearbySwipeRefresh != null) { + nearbySwipeRefresh.setRefreshing(false); + } + + // 隐藏加载状态,显示错误状态 + View nearbyLoadingContainer = findViewById(R.id.nearbyLoadingContainer); + if (nearbyLoadingContainer != null) { + nearbyLoadingContainer.setVisibility(View.GONE); + } + + showNearbyWorksEmpty(errorMsg); + Toast.makeText(this, errorMsg, Toast.LENGTH_SHORT).show(); + } + + /** * 打开应用设置页面,引导用户手动开启权限 */ @@ -3780,6 +3741,9 @@ public class MainActivity extends AppCompatActivity { binding.categoryTabs.removeAllTabs(); + // 首先添加"热门"标签(固定在第一位) + binding.categoryTabs.addTab(binding.categoryTabs.newTab().setText("热门")); + // 添加我的频道到分类标签 for (ChannelTagAdapter.ChannelTag channel : myChannels) { if (channel != null && channel.getName() != null) { @@ -3787,15 +3751,12 @@ public class MainActivity extends AppCompatActivity { } } - // 选中第一个标签 - if (myChannels.size() > 0 && binding.categoryTabs.getTabCount() > 0) { - ChannelTagAdapter.ChannelTag firstChannel = myChannels.get(0); - if (firstChannel != null && firstChannel.getName() != null) { - currentCategory = firstChannel.getName(); - TabLayout.Tab firstTab = binding.categoryTabs.getTabAt(0); - if (firstTab != null) { - firstTab.select(); - } + // 选中第一个标签(热门) + if (binding.categoryTabs.getTabCount() > 0) { + currentCategory = "热门"; + TabLayout.Tab firstTab = binding.categoryTabs.getTabAt(0); + if (firstTab != null) { + firstTab.select(); } } } catch (Exception e) { @@ -3877,4 +3838,183 @@ public class MainActivity extends AppCompatActivity { myChannels.add(new ChannelTagAdapter.ChannelTag(3, "音乐")); Log.d(TAG, "使用默认我的频道配置"); } -} \ No newline at end of file + + /** + * 加载热门作品列表 + * @param page 页码(从1开始) + */ + private void loadHotWorks(int page) { + if (isLoadingHotWorks) { + Log.d(TAG, "loadHotWorks: 已在加载中,跳过"); + return; + } + + isLoadingHotWorks = true; + Log.d(TAG, "loadHotWorks: 开始加载第" + page + "页"); + + // 显示加载状态 + if (page == 1) { + if (hotWorks.isEmpty()) { + LoadingStateManager.showSkeleton(binding.roomsRecyclerView, 6); + } + binding.loading.setVisibility(View.GONE); + } + + ApiClient.getService(getApplicationContext()) + .getHotWorks(page, 20) + .enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + isLoadingHotWorks = false; + + // 恢复真实适配器(移除骨架屏) + if (binding != null && binding.roomsRecyclerView != null) { + binding.roomsRecyclerView.setAdapter(adapter); + } + + if (binding != null && binding.swipeRefresh != null) { + binding.swipeRefresh.setRefreshing(false); + } + + if (binding != null && binding.loading != null) { + binding.loading.setVisibility(View.GONE); + } + + Log.d(TAG, "loadHotWorks: 响应成功 - " + response.code()); + + if (response.isSuccessful() && response.body() != null && response.body().isOk()) { + PageResponse pageData = response.body().getData(); + if (pageData != null && pageData.getList() != null) { + Log.d(TAG, "loadHotWorks: 获取到 " + pageData.getList().size() + " 条数据"); + + if (page == 1) { + hotWorks.clear(); + } + hotWorks.addAll(pageData.getList()); + + // 检查是否还有更多数据 + hasMoreHotWorks = pageData.getList().size() >= 20; + Log.d(TAG, "loadHotWorks: hasMoreHotWorks=" + hasMoreHotWorks); + + // 更新UI + updateHotWorksUI(); + } else { + Log.w(TAG, "loadHotWorks: pageData或list为null"); + if (page == 1) { + showEmptyState("暂无热门作品"); + } + } + } else { + String errorMsg = "加载失败"; + if (response.body() != null && response.body().getMessage() != null) { + errorMsg = response.body().getMessage(); + } + Log.e(TAG, "loadHotWorks: 加载失败 - " + errorMsg); + showEmptyState(errorMsg + ",请重试"); + Toast.makeText(MainActivity.this, errorMsg, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + isLoadingHotWorks = false; + + // 恢复真实适配器(移除骨架屏) + if (binding != null && binding.roomsRecyclerView != null) { + binding.roomsRecyclerView.setAdapter(adapter); + } + + if (binding != null && binding.swipeRefresh != null) { + binding.swipeRefresh.setRefreshing(false); + } + + if (binding != null && binding.loading != null) { + binding.loading.setVisibility(View.GONE); + } + + String errorMsg = "网络错误"; + if (t != null) { + if (t.getMessage() != null && t.getMessage().contains("Unable to resolve host")) { + errorMsg = "无法连接服务器,请检查网络"; + } else if (t.getMessage() != null && t.getMessage().contains("timeout")) { + errorMsg = "连接超时,请重试"; + } else if (t.getMessage() != null && t.getMessage().contains("Connection refused")) { + errorMsg = "连接被拒绝,请确保服务器已启动"; + } else if (t.getMessage() != null) { + errorMsg = "网络错误:" + t.getMessage(); + } + } + + Log.e(TAG, "loadHotWorks: 网络错误 - " + errorMsg, t); + showEmptyState(errorMsg); + Toast.makeText(MainActivity.this, errorMsg, Toast.LENGTH_SHORT).show(); + } + }); + } + + /** + * 更新热门作品UI + */ + private void updateHotWorksUI() { + if (hotWorks.isEmpty()) { + Log.d(TAG, "updateHotWorksUI: 热门作品列表为空"); + showEmptyState("暂无热门作品"); + } else { + Log.d(TAG, "updateHotWorksUI: 显示 " + hotWorks.size() + " 条热门作品"); + hideEmptyState(); + hideErrorState(); + + // 转换为FeedItem列表 + List feedItems = new ArrayList<>(); + for (WorksResponse work : hotWorks) { + feedItems.add(FeedItem.fromWork(work)); + } + + if (adapter != null) { + adapter.submitList(feedItems); + } + } + } + + /** + * 刷新热门作品 + */ + private void refreshHotWorks() { + Log.d(TAG, "refreshHotWorks: 刷新热门作品"); + currentHotWorksPage = 1; + hasMoreHotWorks = true; + loadHotWorks(1); + } + + /** + * 加载更多热门作品 + */ + private void loadMoreHotWorks() { + if (!isLoadingHotWorks && hasMoreHotWorks) { + currentHotWorksPage++; + Log.d(TAG, "loadMoreHotWorks: 加载第 " + currentHotWorksPage + " 页"); + loadHotWorks(currentHotWorksPage); + } else { + Log.d(TAG, "loadMoreHotWorks: 跳过 - isLoading=" + isLoadingHotWorks + ", hasMore=" + hasMoreHotWorks); + } + } + + /** + * 显示空状态 + */ + private void showEmptyState(String message) { + if (binding != null) { + if (binding.emptyStateView != null) { + binding.emptyStateView.setVisibility(View.VISIBLE); + binding.emptyStateView.setMessage(message); + } + if (binding.loading != null) { + binding.loading.setVisibility(View.GONE); + } + if (binding.roomsRecyclerView != null) { + binding.roomsRecyclerView.setVisibility(View.GONE); + } + } + } +} 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 b531dfa0..568fde05 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 @@ -3,16 +3,28 @@ package com.example.livestreaming; import android.app.AlertDialog; import android.content.Intent; import android.os.Bundle; +import android.Manifest; +import android.content.pm.PackageManager; import android.text.Editable; import android.text.TextWatcher; import android.util.Log; import android.view.View; import android.widget.Toast; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.ContextCompat; import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout; import com.example.livestreaming.databinding.ActivityMyFriendsBinding; +import com.example.livestreaming.location.TianDiTuLocationService; +import com.example.livestreaming.net.ApiClient; +import com.example.livestreaming.net.ApiResponse; +import com.example.livestreaming.net.CommunityResponse; +import com.example.livestreaming.net.UserEditRequest; import com.example.livestreaming.net.AuthStore; import com.google.android.material.tabs.TabLayout; @@ -41,9 +53,15 @@ public class MyFriendsActivity extends AppCompatActivity { private final List allFriends = new ArrayList<>(); private final List allRequests = new ArrayList<>(); private final List allBlocked = new ArrayList<>(); - private int currentTab = 0; // 0: 好友列表, 1: 好友请求, 2: 黑名单 + private int currentTab = 0; // 0: 好友列表, 1: 好友请求, 2: 附近, 3: 黑名单 private OkHttpClient httpClient; + // 附近Tab相关 + private NearbyUsersAdapter nearbyUsersAdapter; + private final List nearbyUsers = new ArrayList<>(); + private TianDiTuLocationService locationService; + private ActivityResultLauncher requestLocationPermissionLauncher; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -51,12 +69,31 @@ public class MyFriendsActivity extends AppCompatActivity { setContentView(binding.getRoot()); httpClient = new OkHttpClient(); + locationService = new TianDiTuLocationService(this); + setupLaunchers(); setupViews(); setupTabs(); loadFriendList(); } + private void setupLaunchers() { + requestLocationPermissionLauncher = registerForActivityResult( + new ActivityResultContracts.RequestMultiplePermissions(), + result -> { + Boolean fine = result.get(Manifest.permission.ACCESS_FINE_LOCATION); + Boolean coarse = result.get(Manifest.permission.ACCESS_COARSE_LOCATION); + boolean granted = (fine != null && fine) || (coarse != null && coarse); + if (granted) { + updateMyLocationAndReloadNearby(); + } else { + Toast.makeText(this, "需要位置权限才能查看附近的人", Toast.LENGTH_SHORT).show(); + updateNearbyPermissionUi(); + } + } + ); + } + private void setupViews() { binding.backButton.setOnClickListener(v -> finish()); @@ -119,6 +156,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.addTab(binding.tabLayout.newTab().setText("黑名单")); binding.tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() { @@ -139,23 +177,274 @@ public class MyFriendsActivity extends AppCompatActivity { binding.friendsRecyclerView.setVisibility(View.VISIBLE); binding.requestsRecyclerView.setVisibility(View.GONE); binding.blockedRecyclerView.setVisibility(View.GONE); + binding.nearbyTabContainer.getRoot().setVisibility(View.GONE); binding.searchContainer.setVisibility(View.VISIBLE); loadFriendList(); } else if (tabIndex == 1) { binding.friendsRecyclerView.setVisibility(View.GONE); binding.requestsRecyclerView.setVisibility(View.VISIBLE); binding.blockedRecyclerView.setVisibility(View.GONE); + binding.nearbyTabContainer.getRoot().setVisibility(View.GONE); binding.searchContainer.setVisibility(View.GONE); loadFriendRequests(); + } else if (tabIndex == 2) { + binding.friendsRecyclerView.setVisibility(View.GONE); + binding.requestsRecyclerView.setVisibility(View.GONE); + binding.blockedRecyclerView.setVisibility(View.GONE); + binding.nearbyTabContainer.getRoot().setVisibility(View.VISIBLE); + binding.searchContainer.setVisibility(View.GONE); + showNearbyTab(); } else { binding.friendsRecyclerView.setVisibility(View.GONE); binding.requestsRecyclerView.setVisibility(View.GONE); binding.blockedRecyclerView.setVisibility(View.VISIBLE); + binding.nearbyTabContainer.getRoot().setVisibility(View.GONE); binding.searchContainer.setVisibility(View.GONE); loadBlockedList(); } } + private void showNearbyTab() { + ensureNearbyAdapter(); + bindNearbyViews(); + updateNearbyPermissionUi(); + loadNearbyUsersSimple(false); + } + + private void ensureNearbyAdapter() { + if (nearbyUsersAdapter != null) return; + nearbyUsersAdapter = new NearbyUsersAdapter(user -> { + if (user == null) return; + UserProfileReadOnlyActivity.start(MyFriendsActivity.this, + user.getId(), + user.getName(), + user.getDistanceText(), + "", + ""); + }); + nearbyUsersAdapter.setOnAddFriendClickListener(user -> { + if (user == null) return; + sendFriendRequestToNearbyUser(user); + }); + } + + private void sendFriendRequestToNearbyUser(NearbyUser user) { + if (!AuthHelper.isLoggedIn(this)) { + AuthHelper.requireLogin(this, "添加好友需要登录"); + return; + } + + try { + int userId = Integer.parseInt(user.getId()); + java.util.Map body = new java.util.HashMap<>(); + body.put("targetUserId", userId); + body.put("message", "我想加你为好友"); + + ApiClient.getService(getApplicationContext()).sendFriendRequest(body) + .enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(retrofit2.Call> call, retrofit2.Response> response) { + if (response.isSuccessful() && response.body() != null && response.body().isOk()) { + Toast.makeText(MyFriendsActivity.this, "好友请求已发送", Toast.LENGTH_SHORT).show(); + } else { + String msg = response.body() != null && response.body().getMessage() != null + ? response.body().getMessage() : "发送失败"; + Toast.makeText(MyFriendsActivity.this, msg, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(retrofit2.Call> call, Throwable t) { + Toast.makeText(MyFriendsActivity.this, "网络错误", Toast.LENGTH_SHORT).show(); + } + }); + } catch (NumberFormatException e) { + Toast.makeText(this, "用户ID格式错误", Toast.LENGTH_SHORT).show(); + } + } + + private void bindNearbyViews() { + View root = binding.nearbyTabContainer.getRoot(); + RecyclerView list = root.findViewById(R.id.nearbyContentList); + SwipeRefreshLayout swipe = root.findViewById(R.id.nearbySwipeRefresh); + View btnRefresh = root.findViewById(R.id.btnOpenLocal); + View btnEnableLocation = root.findViewById(R.id.btnEnableLocation); + + if (list != null && list.getAdapter() == null) { + list.setLayoutManager(new LinearLayoutManager(this)); + list.setAdapter(nearbyUsersAdapter); + } + if (swipe != null) { + swipe.setOnRefreshListener(() -> loadNearbyUsersSimple(true)); + } + if (btnRefresh != null) { + btnRefresh.setOnClickListener(v -> loadNearbyUsersSimple(false)); + } + if (btnEnableLocation != null) { + btnEnableLocation.setOnClickListener(v -> requestLocationPermission()); + } + } + + private void requestLocationPermission() { + requestLocationPermissionLauncher.launch(new String[]{ + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION + }); + } + + private boolean hasLocationPermission() { + return ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED + || ContextCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED; + } + + private void updateNearbyPermissionUi() { + View root = binding.nearbyTabContainer.getRoot(); + View empty = root.findViewById(R.id.emptyNearbyContainer); + View loading = root.findViewById(R.id.nearbyLoadingContainer); + RecyclerView list = root.findViewById(R.id.nearbyContentList); + View btnEnableLocation = root.findViewById(R.id.btnEnableLocation); + + if (loading != null) loading.setVisibility(View.GONE); + + boolean hasPermission = hasLocationPermission(); + if (btnEnableLocation != null) { + btnEnableLocation.setVisibility(hasPermission ? View.GONE : View.VISIBLE); + } + + if (!hasPermission) { + if (list != null) list.setVisibility(View.GONE); + if (empty != null) empty.setVisibility(View.VISIBLE); + } + } + + private void stopNearbySwipeRefresh() { + SwipeRefreshLayout swipe = binding.nearbyTabContainer.getRoot().findViewById(R.id.nearbySwipeRefresh); + if (swipe != null && swipe.isRefreshing()) { + swipe.setRefreshing(false); + } + } + + private void updateMyLocationAndReloadNearby() { + locationService.startLocation(new TianDiTuLocationService.OnLocationResultListener() { + @Override + public void onSuccess(String province, String city, String address, double latitude, double longitude) { + String addres; + if (province != null && !province.isEmpty() && city != null && !city.isEmpty()) { + addres = province + "-" + city; + } else if (address != null && !address.isEmpty()) { + addres = address; + } else { + addres = null; + } + + UserEditRequest req = new UserEditRequest(); + req.setAddres(addres); + req.setLatitude(latitude); + req.setLongitude(longitude); + + ApiClient.getService(getApplicationContext()).updateUserInfo(req) + .enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(retrofit2.Call> call, retrofit2.Response> response) { + loadNearbyUsersSimple(false); + } + + @Override + public void onFailure(retrofit2.Call> call, Throwable t) { + loadNearbyUsersSimple(false); + } + }); + } + + @Override + public void onError(String error) { + runOnUiThread(() -> { + Toast.makeText(MyFriendsActivity.this, error != null ? error : "定位失败", Toast.LENGTH_SHORT).show(); + loadNearbyUsersSimple(false); + }); + } + }); + } + + private void loadNearbyUsersSimple(boolean fromSwipeRefresh) { + View root = binding.nearbyTabContainer.getRoot(); + View empty = root.findViewById(R.id.emptyNearbyContainer); + View loading = root.findViewById(R.id.nearbyLoadingContainer); + RecyclerView list = root.findViewById(R.id.nearbyContentList); + + if (!hasLocationPermission()) { + updateNearbyPermissionUi(); + stopNearbySwipeRefresh(); + return; + } + + if (!AuthHelper.isLoggedIn(this)) { + stopNearbySwipeRefresh(); + if (list != null) list.setVisibility(View.GONE); + if (empty != null) empty.setVisibility(View.VISIBLE); + Toast.makeText(this, "登录后查看附近的人", Toast.LENGTH_SHORT).show(); + return; + } + + if (!fromSwipeRefresh) { + if (loading != null) loading.setVisibility(View.VISIBLE); + } + if (empty != null) empty.setVisibility(View.GONE); + if (list != null) list.setVisibility(View.GONE); + + ApiClient.getService(getApplicationContext()).getNearbyUsersSimple(100) + .enqueue(new retrofit2.Callback>() { + @Override + public void onResponse(retrofit2.Call> call, + retrofit2.Response> response) { + runOnUiThread(() -> { + stopNearbySwipeRefresh(); + if (loading != null) loading.setVisibility(View.GONE); + + if (!response.isSuccessful() || response.body() == null || !response.body().isOk()) { + if (empty != null) empty.setVisibility(View.VISIBLE); + if (list != null) list.setVisibility(View.GONE); + return; + } + + CommunityResponse.NearbyUserSimpleList data = response.body().getData(); + List users = data != null ? data.getUsers() : null; + nearbyUsers.clear(); + + if (users != null) { + for (CommunityResponse.NearbyUserSimple u : users) { + String address = u.address != null ? u.address : "未设置位置"; + String distanceText = u.distanceText != null ? u.distanceText : ""; + String locationInfo = !distanceText.isEmpty() ? (address + " · " + distanceText) : address; + nearbyUsers.add(new NearbyUser(String.valueOf(u.id), u.nickname, locationInfo, false)); + } + } + + if (!nearbyUsers.isEmpty()) { + if (nearbyUsersAdapter != null) { + nearbyUsersAdapter.submitList(new ArrayList<>(nearbyUsers)); + } + if (list != null) list.setVisibility(View.VISIBLE); + if (empty != null) empty.setVisibility(View.GONE); + } else { + if (list != null) list.setVisibility(View.GONE); + if (empty != null) empty.setVisibility(View.VISIBLE); + } + }); + } + + @Override + public void onFailure(retrofit2.Call> call, Throwable t) { + runOnUiThread(() -> { + stopNearbySwipeRefresh(); + if (loading != null) loading.setVisibility(View.GONE); + if (empty != null) empty.setVisibility(View.VISIBLE); + if (list != null) list.setVisibility(View.GONE); + }); + } + }); + } + private void loadFriendList() { String token = AuthStore.getToken(this); if (token == null) { 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 753482ee..9a1e513b 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 @@ -630,7 +630,9 @@ public class PublishWorkActivity extends AppCompatActivity { binding.emptyMediaContainer.setVisibility(View.VISIBLE); binding.mediaRecyclerView.setVisibility(View.GONE); binding.videoPreviewContainer.setVisibility(View.GONE); - binding.coverCard.setVisibility(View.GONE); + // 允许只选封面发布:无媒体时也展示封面选择区域 + binding.coverCard.setVisibility(View.VISIBLE); + updateCoverPreview(); return; } @@ -741,14 +743,7 @@ public class PublishWorkActivity extends AppCompatActivity { android.util.Log.d("PublishWork", "封面URI: " + (selectedCoverUri != null ? selectedCoverUri.toString() : "null")); android.util.Log.d("PublishWork", "封面是否本地文件: " + (selectedCoverUri != null ? isLocalUri(selectedCoverUri) : "N/A")); - // 验证标题 - if (TextUtils.isEmpty(title)) { - Toast.makeText(this, "请输入作品标题", Toast.LENGTH_SHORT).show(); - binding.titleEditText.requestFocus(); - return; - } - - // 验证标题长度 + // 验证标题长度(标题允许为空) if (title.length() > 20) { Toast.makeText(this, "标题最多20字", Toast.LENGTH_SHORT).show(); binding.titleEditText.requestFocus(); @@ -762,9 +757,9 @@ public class PublishWorkActivity extends AppCompatActivity { return; } - // 验证媒体 - if (selectedMediaUris.isEmpty()) { - Toast.makeText(this, "请选择图片或视频", Toast.LENGTH_SHORT).show(); + // 验证媒体/封面:允许只选封面发布 + if (selectedMediaUris.isEmpty() && selectedCoverUri == null) { + Toast.makeText(this, "请选择封面", Toast.LENGTH_SHORT).show(); return; } @@ -780,7 +775,11 @@ public class PublishWorkActivity extends AppCompatActivity { progressDialog.show(); // 开始上传流程 - if (currentWorkType == WorkItem.WorkType.VIDEO && selectedVideoUri != null) { + if (selectedMediaUris.isEmpty() && selectedCoverUri != null) { + // 只选封面发布:按图片作品发布,图片列表允许为空 + currentWorkType = WorkItem.WorkType.IMAGE; + handleImageWorkUpload(title, description, progressDialog); + } else if (currentWorkType == WorkItem.WorkType.VIDEO && selectedVideoUri != null) { // 视频作品处理 handleVideoWorkUpload(title, description, progressDialog); } else { diff --git a/android-app/app/src/main/java/com/example/livestreaming/WorksAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/WorksAdapter.java index 780733ee..c9b9c577 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/WorksAdapter.java +++ b/android-app/app/src/main/java/com/example/livestreaming/WorksAdapter.java @@ -165,6 +165,16 @@ public class WorksAdapter extends RecyclerView.Adapter { if (listener != null) { 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 b6d3fa65..e515eafd 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 @@ -380,6 +380,14 @@ public interface ApiService { @POST("api/front/works/update") Call> updateWork(@Body WorksRequest body); + /** + * 获取附近作品列表(按距离从近到远排序) + */ + @GET("api/front/works/nearby") + Call>> getNearbyWorks( + @Query("page") int page, + @Query("limit") int limit); + @GET("api/front/works/detail/{id}") Call> getWorkDetail(@Path("id") long id); @@ -428,6 +436,14 @@ public interface ApiService { @POST("api/front/works/comment/delete/{commentId}") Call> deleteWorkComment(@Path("commentId") long commentId); + /** + * 获取热门作品列表 + */ + @GET("api/front/works/hot") + Call>> getHotWorks( + @Query("page") int page, + @Query("pageSize") int pageSize); + @POST("api/front/works/comment/like/{commentId}") Call> likeWorkComment(@Path("commentId") long commentId); 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 index 1faf2e2c..fd5643a0 100644 --- 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 @@ -27,6 +27,8 @@ public class WorksResponse { private Boolean isLiked; // 当前用户是否已点赞 private Boolean isCollected; // 当前用户是否已收藏 private Boolean isOwner; // 是否是当前用户的作品 + private Integer isHot; // 是否热门:1-是 0-否 + private String hotTime; // 设置热门的时间 public Long getId() { return id; @@ -200,4 +202,25 @@ public class WorksResponse { public void setIsOwner(Boolean isOwner) { this.isOwner = isOwner; } + + public Integer getIsHot() { + return isHot != null ? isHot : 0; + } + + public void setIsHot(Integer isHot) { + this.isHot = isHot; + } + + // 便利方法:判断是否热门 + public boolean isHot() { + return isHot != null && isHot == 1; + } + + public String getHotTime() { + return hotTime; + } + + public void setHotTime(String hotTime) { + this.hotTime = hotTime; + } } diff --git a/android-app/app/src/main/res/drawable/ic_fire_24.xml b/android-app/app/src/main/res/drawable/ic_fire_24.xml new file mode 100644 index 00000000..99508ff4 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_fire_24.xml @@ -0,0 +1,9 @@ + + + diff --git a/android-app/app/src/main/res/layout/activity_conversation.xml b/android-app/app/src/main/res/layout/activity_conversation.xml index 7b5fb33d..056cbd5a 100644 --- a/android-app/app/src/main/res/layout/activity_conversation.xml +++ b/android-app/app/src/main/res/layout/activity_conversation.xml @@ -123,6 +123,15 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent"> + + - + + + + + diff --git a/android-app/app/src/main/res/layout/item_works.xml b/android-app/app/src/main/res/layout/item_works.xml index f1264fe7..3e541fd4 100644 --- a/android-app/app/src/main/res/layout/item_works.xml +++ b/android-app/app/src/main/res/layout/item_works.xml @@ -35,6 +35,18 @@ android:visibility="gone" android:contentDescription="视频" /> + + + diff --git a/android-app/app/src/main/res/layout/layout_nearby_tab.xml b/android-app/app/src/main/res/layout/layout_nearby_tab.xml index 6ef18a0f..d223aa2c 100644 --- a/android-app/app/src/main/res/layout/layout_nearby_tab.xml +++ b/android-app/app/src/main/res/layout/layout_nearby_tab.xml @@ -81,9 +81,9 @@ android:id="@+id/nearbyCountText" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:text="" - android:textSize="12sp" + android:layout_marginTop="8dp" + android:text="正在搜索附近的人..." + android:textSize="13sp" android:textColor="#999999" /> @@ -120,7 +120,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:text="正在搜索附近的人..." + android:text="正在加载附近的人..." android:textSize="13sp" android:textColor="#999999" />