Compare commits

..

No commits in common. "7b7ca4f405bbec831e5cebe795026efe68b4a6c7" and "987e67dcb0149ebca0cd41388ea81f99b84595b5" have entirely different histories.

35 changed files with 489 additions and 2952 deletions

View File

@ -1,578 +0,0 @@
# 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<WorksResponse>` 数据
6. **WorksAdapter** → 渲染作品列表
7. **UI** → 显示热门作品,每个作品卡片显示🔥标识
## Components and Interfaces
### 1. MainActivity 修改
#### 新增成员变量
```java
// 热门作品相关
private final List<WorksResponse> 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<ApiResponse<PageResponse<WorksResponse>>>() {
@Override
public void onResponse(Call<ApiResponse<PageResponse<WorksResponse>>> call,
Response<ApiResponse<PageResponse<WorksResponse>>> response) {
isLoadingHotWorks = false;
binding.swipeRefresh.setRefreshing(false);
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
PageResponse<WorksResponse> 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<ApiResponse<PageResponse<WorksResponse>>> call, Throwable t) {
isLoadingHotWorks = false;
binding.swipeRefresh.setRefreshing(false);
showErrorState("网络错误,请重试");
}
});
}
/**
* 更新热门作品UI
*/
private void updateHotWorksUI() {
if (hotWorks.isEmpty()) {
showEmptyState("暂无热门作品");
} else {
hideEmptyState();
hideErrorState();
// 转换为FeedItem列表
List<FeedItem> 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
<com.google.android.material.tabs.TabLayout
android:id="@+id/categoryTabs"
...>
<!-- 新增热门Tab -->
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="热门" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="推荐" />
<!-- 其他Tab保持不变 -->
...
</com.google.android.material.tabs.TabLayout>
```
#### 修改 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
<!-- 热门标识 -->
<ImageView
android:id="@+id/hotBadge"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="8dp"
android:src="@drawable/ic_fire_24"
android:visibility="gone"
app:tint="#FF4500" />
```
#### 修改 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<ApiResponse<PageResponse<WorksResponse>>> 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<T>
```java
public class PageResponse<T> {
private List<T> list; // 数据列表
private Integer total; // 总数
private Integer page; // 当前页码
private Integer pageSize; // 每页大小
private Integer totalPage; // 总页数
}
```
### ApiResponse<T>
```java
public class ApiResponse<T> {
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<ApiResponse<PageResponse<WorksResponse>>> 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<WorksResponse>
- 测试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页数据 |
| 上拉加载更多 | 滚动到底部 | 加载下一页数据 |
| 点击作品 | 点击作品卡片 | 跳转到作品详情页 |

View File

@ -1,84 +0,0 @@
# 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 自动刷新热门作品列表

View File

@ -1,236 +0,0 @@
# 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<WorksResponse>
- 添加currentHotWorksPageint初始值1
- 添加isLoadingHotWorksboolean初始值false
- 添加hasMoreHotWorksboolean初始值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.

View File

@ -118,37 +118,3 @@ 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,
});
}

View File

@ -69,16 +69,8 @@
<el-tag v-else type="danger">已删除</el-tag>
</template>
</el-table-column>
<el-table-column prop="isHot" label="热门" min-width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.isHot === 1" type="danger" effect="dark">
<i class="el-icon-star-on"></i> 热门
</el-tag>
<span v-else style="color: #999">-</span>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" min-width="180" />
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="操作" width="150" fixed="right">
<template slot-scope="scope">
<router-link :to="{ path: '/contentManage/article/edit/' + scope.row.id }">
<a v-hasPermi="['admin:works:info']">编辑</a>
@ -87,14 +79,6 @@
<a @click="handleUpdateStatus(scope.row)" v-hasPermi="['admin:works:update']">
{{ scope.row.status === 1 ? '下架' : '上架' }}
</a>
<el-divider direction="vertical"></el-divider>
<a
@click="handleToggleHot(scope.row)"
v-hasPermi="['admin:works:update']"
:style="{ color: scope.row.isHot === 1 ? '#F56C6C' : '#409EFF' }"
>
{{ scope.row.isHot === 1 ? '取消热门' : '设为热门' }}
</a>
</template>
</el-table-column>
</el-table>
@ -180,18 +164,6 @@ 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);

View File

@ -148,43 +148,4 @@ public class WorksController {
return CommonResult.failed(e.getMessage());
}
}
/**
* 设置/取消热门
* @param id Long 作品ID
* @param isHot Boolean 是否热门true-设置热门 false-取消热门
*/
// @PreAuthorize("hasAuthority('admin:works:update')") // 临时注释等待添加权限
@ApiOperation(value = "设置/取消热门")
@RequestMapping(value = "/toggleHot", method = RequestMethod.POST)
public CommonResult<String> toggleHot(@RequestParam Long id, @RequestParam Boolean isHot) {
try {
worksService.toggleHot(id, isHot);
return CommonResult.success(isHot ? "设置热门成功" : "取消热门成功");
} catch (Exception e) {
log.error("设置热门失败", e);
return CommonResult.failed(e.getMessage());
}
}
/**
* 获取热门作品列表
* @param pageParamRequest 分页参数
*/
// @PreAuthorize("hasAuthority('admin:works:list')") // 临时注释等待添加权限
@ApiOperation(value = "热门作品列表")
@RequestMapping(value = "/hotList", method = RequestMethod.GET)
public CommonResult<CommonPage<WorksResponse>> getHotList(@Validated PageParamRequest pageParamRequest) {
try {
CommonPage<WorksResponse> result = worksService.getHotWorks(
pageParamRequest.getPage(),
pageParamRequest.getLimit(),
null
);
return CommonResult.success(result);
} catch (Exception e) {
log.error("获取热门作品列表失败", e);
return CommonResult.failed(e.getMessage());
}
}
}

View File

@ -105,15 +105,6 @@ public class Works implements Serializable {
@ApiModelProperty(value = "状态1-正常 0-下架")
private Integer status = 1;
@Column(name = "is_hot", nullable = false, columnDefinition = "TINYINT DEFAULT 0 COMMENT '是否热门1-是 0-否'")
@ApiModelProperty(value = "是否热门1-是 0-否")
private Integer isHot = 0;
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "hot_time", columnDefinition = "DATETIME COMMENT '设置热门的时间'")
@ApiModelProperty(value = "设置热门的时间")
private Date hotTime;
@TableLogic
@Column(name = "is_deleted", nullable = false, columnDefinition = "TINYINT DEFAULT 0 COMMENT '逻辑删除0-未删除 1-已删除'")
@ApiModelProperty(value = "逻辑删除0-未删除 1-已删除")

View File

@ -5,6 +5,7 @@ import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
@ -18,6 +19,7 @@ public class WorksRequest {
private Long id;
@ApiModelProperty(value = "作品标题", required = true)
@NotBlank(message = "作品标题不能为空")
@Size(max = 200, message = "作品标题最多200个字符")
private String title;

View File

@ -73,12 +73,6 @@ public class WorksResponse {
@ApiModelProperty(value = "状态1-正常 0-下架")
private Integer status;
@ApiModelProperty(value = "是否热门1-是 0-否")
private Integer isHot;
@ApiModelProperty(value = "设置热门的时间")
private Date hotTime;
@ApiModelProperty(value = "是否已点赞")
private Boolean isLiked = false;

View File

@ -71,26 +71,6 @@ public class WorksController {
}
}
@ApiOperation(value = "获取附近作品列表")
@GetMapping("/nearby")
public CommonResult<CommonPage<WorksResponse>> 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<WorksResponse> result = worksService.getNearbyWorks(page, limit, userId);
return CommonResult.success(result);
} catch (Exception e) {
log.error("获取附近作品列表失败", e);
return CommonResult.failed(e.getMessage());
}
}
/**
* 编辑作品需要登录
*/
@ -350,25 +330,4 @@ public class WorksController {
return CommonResult.failed(e.getMessage());
}
}
/**
* 获取热门作品列表不需要登录
*/
@ApiOperation(value = "获取热门作品列表")
@GetMapping("/hot")
public CommonResult<CommonPage<WorksResponse>> getHotWorks(
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
try {
// 获取当前登录用户ID如果已登录
Integer userId = userService.getUserId();
CommonPage<WorksResponse> result = worksService.getHotWorks(page, pageSize, userId);
return CommonResult.success(result);
} catch (Exception e) {
log.error("获取热门作品列表失败", e);
return CommonResult.failed(e.getMessage());
}
}
}

View File

@ -71,15 +71,6 @@ public interface WorksService extends IService<Works> {
*/
CommonPage<WorksResponse> getUserWorks(Integer userId, Integer page, Integer pageSize);
/**
* 获取附近作品列表按距离从近到远排序
* @param page 页码
* @param limit 每页数量
* @param userId 当前用户ID
* @return 附近作品列表
*/
CommonPage<WorksResponse> getNearbyWorks(Integer page, Integer limit, Integer userId);
/**
* 增加浏览次数
* @param worksId 作品ID
@ -91,21 +82,4 @@ public interface WorksService extends IService<Works> {
* @param worksId 作品ID
*/
void increaseShareCount(Long worksId);
/**
* 设置/取消热门
* @param worksId 作品ID
* @param isHot 是否热门true-设置热门 false-取消热门
* @return 是否成功
*/
Boolean toggleHot(Long worksId, Boolean isHot);
/**
* 获取热门作品列表
* @param page 页码
* @param pageSize 每页数量
* @param userId 当前用户ID可为空
* @return 热门作品列表
*/
CommonPage<WorksResponse> getHotWorks(Integer page, Integer pageSize, Integer userId);
}

View File

@ -320,20 +320,6 @@ 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);

View File

@ -25,11 +25,7 @@ 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;
/**
@ -62,8 +58,11 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
// 验证作品类型和媒体资源
if ("IMAGE".equalsIgnoreCase(request.getType())) {
// 图片作品允许只上传封面图片列表可为空但如果上传了图片最多9张
if (request.getImageUrls() != null && request.getImageUrls().size() > 9) {
// 图片作品必须有图片列表
if (request.getImageUrls() == null || request.getImageUrls().isEmpty()) {
throw new CrmebException("图片作品必须上传至少一张图片");
}
if (request.getImageUrls().size() > 9) {
throw new CrmebException("图片作品最多只能上传9张图片");
}
} else if ("VIDEO".equalsIgnoreCase(request.getType())) {
@ -86,8 +85,7 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
// 创建作品对象
Works works = new Works();
works.setUid(userId);
// title 字段数据库 NOT NULL允许为空时写入空字符串
works.setTitle(StrUtil.blankToDefault(request.getTitle(), ""));
works.setTitle(request.getTitle());
works.setDescription(request.getDescription());
works.setType(request.getType().toUpperCase()); // 统一转为大写
works.setCoverImage(request.getCoverUrl());
@ -197,8 +195,7 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
// 更新作品信息
if (request.getTitle() != null) {
// title 字段数据库 NOT NULL允许为空时写入空字符串
works.setTitle(StrUtil.blankToDefault(request.getTitle(), ""));
works.setTitle(request.getTitle());
}
if (request.getDescription() != null) {
works.setDescription(request.getDescription());
@ -220,10 +217,6 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> 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 {
@ -461,117 +454,6 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
return searchWorks(request, userId);
}
@Override
public CommonPage<WorksResponse> 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<User> userWrapper = new LambdaQueryWrapper<>();
userWrapper.eq(User::getStatus, true)
.ne(User::getUid, userId)
.isNotNull(User::getLatitude)
.isNotNull(User::getLongitude)
.last("LIMIT 2000");
List<User> users = userService.list(userWrapper);
Map<Integer, Double> 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<Integer> 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<WorksResponse> 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<Integer> candidateUserIds = sortedUserIds.subList(0, userPickCount);
int worksPickCount = Math.max(limit * 200, 2000);
Page<Works> worksPage = new Page<>(1, worksPickCount);
LambdaQueryWrapper<Works> worksWrapper = new LambdaQueryWrapper<>();
worksWrapper.eq(Works::getIsDeleted, 0)
.eq(Works::getStatus, 1)
.ne(Works::getUid, userId)
.in(Works::getUid, candidateUserIds)
.orderByDesc(Works::getCreateTime);
Page<Works> candidateWorksPage = page(worksPage, worksWrapper);
List<Works> candidateWorks = candidateWorksPage.getRecords() != null ? candidateWorksPage.getRecords() : Collections.emptyList();
List<Works> 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<WorksResponse> list;
if (start >= total) {
list = new ArrayList<>();
} else {
list = sortedWorks.subList(start, end)
.stream()
.map(w -> convertToResponse(w, userId))
.collect(Collectors.toList());
}
CommonPage<WorksResponse> 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 {
@ -623,8 +505,6 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
response.setCommentCount(works.getCommentCount());
response.setShareCount(works.getShareCount());
response.setStatus(works.getStatus());
response.setIsHot(works.getIsHot());
response.setHotTime(works.getHotTime());
response.setCreateTime(works.getCreateTime());
response.setUpdateTime(works.getUpdateTime());
@ -656,61 +536,4 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
return response;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean toggleHot(Long worksId, Boolean isHot) {
// 查询作品
Works works = getById(worksId);
if (works == null || works.getIsDeleted() == 1) {
throw new CrmebException("作品不存在");
}
// 更新热门状态
LambdaUpdateWrapper<Works> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(Works::getId, worksId)
.set(Works::getIsHot, isHot ? 1 : 0)
.set(Works::getHotTime, isHot ? new java.util.Date() : null);
boolean updated = update(updateWrapper);
if (!updated) {
throw new CrmebException("更新热门状态失败");
}
log.info("{}热门成功作品ID{}", isHot ? "设置" : "取消", worksId);
return true;
}
@Override
public CommonPage<WorksResponse> getHotWorks(Integer page, Integer pageSize, Integer userId) {
log.info("=== 获取热门作品列表 === page={}, pageSize={}, userId={}", page, pageSize, userId);
// 构建查询条件
LambdaQueryWrapper<Works> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Works::getIsDeleted, 0)
.eq(Works::getStatus, 1) // 只查询正常状态的作品
.eq(Works::getIsHot, 1) // 只查询热门作品
.orderByDesc(Works::getHotTime); // 按设置热门的时间倒序
// 分页查询
Page<Works> worksPage = new Page<>(page, pageSize);
worksPage = page(worksPage, queryWrapper);
log.info("查询到 {} 条热门作品记录", worksPage.getRecords().size());
// 转换为响应对象
List<WorksResponse> responseList = worksPage.getRecords().stream()
.map(works -> convertToResponse(works, userId))
.collect(Collectors.toList());
// 构建分页结果
CommonPage<WorksResponse> result = new CommonPage<>();
result.setList(responseList);
result.setTotal(worksPage.getTotal());
result.setPage((int) worksPage.getCurrent());
result.setLimit((int) worksPage.getSize());
result.setTotalPage((int) worksPage.getPages());
return result;
}
}

View File

@ -1,54 +0,0 @@
-- 添加作品热门相关字段
-- 执行时间2026-01-08
USE `zhibo`;
-- 检查字段是否已存在,如果不存在则添加
-- 添加 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';
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;
-- 添加 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;

View File

@ -214,11 +214,6 @@
android:name="com.example.livestreaming.ConversationActivity"
android:exported="false" />
<activity
android:name="com.example.livestreaming.BurnImageViewerActivity"
android:exported="false"
android:screenOrientation="portrait" />
<activity
android:name="com.example.livestreaming.PlayerActivity"
android:exported="false" />

View File

@ -1,153 +0,0 @@
package com.example.livestreaming;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.view.WindowManager;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import com.bumptech.glide.Glide;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ApiService;
import com.example.livestreaming.net.BurnViewResponse;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class BurnImageViewerActivity extends AppCompatActivity {
public static final String EXTRA_MESSAGE_ID = "extra_message_id";
private ImageView imageView;
private TextView countdownText;
private TextView tipText;
private final Handler handler = new Handler(Looper.getMainLooper());
private Runnable countdownRunnable;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE);
setContentView(R.layout.activity_burn_image_viewer);
imageView = findViewById(R.id.imageView);
countdownText = findViewById(R.id.countdownText);
tipText = findViewById(R.id.tipText);
View rootLayout = findViewById(R.id.rootLayout);
if (rootLayout != null) {
rootLayout.setOnClickListener(v -> finish());
}
long messageId = getIntent().getLongExtra(EXTRA_MESSAGE_ID, 0L);
if (messageId <= 0L) {
showTipAndFinish("消息ID无效");
return;
}
ApiService api = ApiClient.getService(this);
if (api == null) {
showTipAndFinish("请先登录");
return;
}
api.viewBurnImage(messageId).enqueue(new Callback<ApiResponse<BurnViewResponse>>() {
@Override
public void onResponse(Call<ApiResponse<BurnViewResponse>> call, Response<ApiResponse<BurnViewResponse>> response) {
if (!response.isSuccessful() || response.body() == null || !response.body().isOk()) {
String msg = response.body() != null ? response.body().getMessage() : "查看失败";
showTipAndFinish(msg);
return;
}
BurnViewResponse data = response.body().getData();
if (data == null) {
showTipAndFinish("查看失败");
return;
}
if (data.getBurned() != null && data.getBurned()) {
showTipAndFinish("已销毁");
return;
}
String url = data.getMediaUrl();
if (url == null || url.trim().isEmpty()) {
showTipAndFinish("不可查看");
return;
}
if (imageView != null) {
Glide.with(BurnImageViewerActivity.this)
.load(url)
.into(imageView);
}
Long burnAt = data.getBurnAt();
Integer burnSeconds = data.getBurnSeconds();
startCountdown(burnAt, burnSeconds);
}
@Override
public void onFailure(Call<ApiResponse<BurnViewResponse>> call, Throwable t) {
showTipAndFinish("网络错误");
}
});
}
private void startCountdown(Long burnAt, Integer burnSeconds) {
long now = System.currentTimeMillis();
long endAt = burnAt != null ? burnAt : (burnSeconds != null ? now + burnSeconds * 1000L : now);
if (countdownText != null) {
countdownText.setVisibility(View.VISIBLE);
}
if (countdownRunnable != null) {
handler.removeCallbacks(countdownRunnable);
}
countdownRunnable = new Runnable() {
@Override
public void run() {
long leftMs = endAt - System.currentTimeMillis();
if (leftMs <= 0L) {
finish();
return;
}
long sec = (leftMs + 999L) / 1000L;
if (countdownText != null) {
countdownText.setText(sec + "s");
}
handler.postDelayed(this, 200L);
}
};
handler.post(countdownRunnable);
}
private void showTipAndFinish(String tip) {
if (tipText != null) {
tipText.setVisibility(View.VISIBLE);
tipText.setText(tip);
}
handler.postDelayed(this::finish, 800L);
}
@Override
protected void onDestroy() {
if (countdownRunnable != null) {
handler.removeCallbacks(countdownRunnable);
}
super.onDestroy();
}
}

View File

@ -16,8 +16,7 @@ public class ChatMessage {
public enum MessageType {
TEXT, // 文本消息
IMAGE, // 图片消息
VOICE, // 语音消息
BURN_IMAGE
VOICE // 语音消息
}
private String messageId;
@ -37,12 +36,7 @@ public class ChatMessage {
private int voiceDuration; // 语音时长
private int imageWidth; // 图片宽度
private int imageHeight; // 图片高度
private Integer burnSeconds;
private Long viewedAt;
private Long burnAt;
private Boolean burned;
// 表情回应相关字段
private List<MessageReaction> reactions; // 表情回应列表
@ -129,16 +123,6 @@ public class ChatMessage {
return msg;
}
public static ChatMessage createBurnImageMessage(String username, String localPath, Integer burnSeconds) {
ChatMessage msg = new ChatMessage(username, "");
msg.messageType = MessageType.BURN_IMAGE;
msg.localMediaPath = localPath;
msg.burnSeconds = burnSeconds;
msg.burned = false;
msg.status = "".equals(username) ? MessageStatus.SENDING : MessageStatus.SENT;
return msg;
}
// Getters and Setters
public String getMessageId() {
return messageId;
@ -260,38 +244,6 @@ public class ChatMessage {
this.imageHeight = imageHeight;
}
public Integer getBurnSeconds() {
return burnSeconds;
}
public void setBurnSeconds(Integer burnSeconds) {
this.burnSeconds = burnSeconds;
}
public Long getViewedAt() {
return viewedAt;
}
public void setViewedAt(Long viewedAt) {
this.viewedAt = viewedAt;
}
public Long getBurnAt() {
return burnAt;
}
public void setBurnAt(Long burnAt) {
this.burnAt = burnAt;
}
public Boolean getBurned() {
return burned;
}
public void setBurned(Boolean burned) {
this.burned = burned;
}
public List<MessageReaction> getReactions() {
return reactions;
}

View File

@ -4,11 +4,9 @@ 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;
@ -21,20 +19,13 @@ 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;
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;
@ -46,7 +37,6 @@ import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
import okhttp3.MultipartBody;
public class ConversationActivity extends AppCompatActivity {
@ -76,9 +66,6 @@ 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);
@ -208,53 +195,6 @@ public class ConversationActivity extends AppCompatActivity {
showMessageMenu(message, position, view);
});
adapter.setOnImageClickListener((message, imageView) -> {
if (message == null) return;
if (message.getMessageType() == ChatMessage.MessageType.BURN_IMAGE) {
long mid = 0L;
try {
mid = Long.parseLong(message.getMessageId());
} catch (Exception ignored) {
}
if (mid <= 0L) {
Snackbar.make(binding.getRoot(), "消息ID无效", Snackbar.LENGTH_SHORT).show();
return;
}
Intent it = new Intent(ConversationActivity.this, BurnImageViewerActivity.class);
it.putExtra(BurnImageViewerActivity.EXTRA_MESSAGE_ID, mid);
startActivity(it);
return;
}
if (message.getMessageType() == ChatMessage.MessageType.IMAGE) {
String url = message.getMediaUrl();
if (!TextUtils.isEmpty(url)) {
AvatarViewerDialog.create(ConversationActivity.this)
.setAvatarUrl(url)
.show();
return;
}
String localPath = message.getLocalMediaPath();
if (!TextUtils.isEmpty(localPath)) {
try {
android.net.Uri uri;
if (localPath.startsWith("content://") || localPath.startsWith("file://")) {
uri = android.net.Uri.parse(localPath);
} else {
uri = android.net.Uri.fromFile(new File(localPath));
}
AvatarViewerDialog.create(ConversationActivity.this)
.setAvatarUri(uri)
.show();
return;
} catch (Exception ignored) {
}
}
}
});
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
layoutManager.setStackFromEnd(false);
binding.messagesRecyclerView.setLayoutManager(layoutManager);
@ -512,33 +452,6 @@ public class ConversationActivity extends AppCompatActivity {
String avatarUrl = item.optString("avatarUrl", "");
boolean isSystem = item.optBoolean("isSystemMessage", false);
String messageType = item.optString("messageType", "text");
String mediaUrl = item.optString("mediaUrl", "");
int duration = item.optInt("duration", 0);
Integer burnSeconds = null;
if (item.has("burnSeconds") && !item.isNull("burnSeconds")) {
int v = item.optInt("burnSeconds", 0);
if (v > 0) burnSeconds = v;
}
Long viewedAt = null;
if (item.has("viewedAt") && !item.isNull("viewedAt")) {
long v = item.optLong("viewedAt", 0L);
if (v > 0L) viewedAt = v;
}
Long burnAt = null;
if (item.has("burnAt") && !item.isNull("burnAt")) {
long v = item.optLong("burnAt", 0L);
if (v > 0L) burnAt = v;
}
Boolean burned = null;
if (item.has("burned") && !item.isNull("burned")) {
burned = item.optBoolean("burned", false);
}
// 判断是否是自己发送的消息
// 每次都重新从 AuthStore 获取最新的 userId确保登录后能正确获取
String myUserId = AuthStore.getUserId(this);
@ -591,27 +504,6 @@ public class ConversationActivity extends AppCompatActivity {
ChatMessage chatMessage = new ChatMessage(messageId, displayName, message, timestamp, isSystem, msgStatus);
chatMessage.setOutgoing(isMine); // 关键设置消息方向确保自己发送的消息显示在右侧
chatMessage.setAvatarUrl(avatarUrl);
if ("image".equalsIgnoreCase(messageType)) {
chatMessage.setMessageType(ChatMessage.MessageType.IMAGE);
} else if ("voice".equalsIgnoreCase(messageType)) {
chatMessage.setMessageType(ChatMessage.MessageType.VOICE);
if (duration > 0) {
chatMessage.setVoiceDuration(duration);
}
} else if ("burn_image".equalsIgnoreCase(messageType)) {
chatMessage.setMessageType(ChatMessage.MessageType.BURN_IMAGE);
chatMessage.setBurnSeconds(burnSeconds);
chatMessage.setViewedAt(viewedAt);
chatMessage.setBurnAt(burnAt);
chatMessage.setBurned(burned);
} else {
chatMessage.setMessageType(ChatMessage.MessageType.TEXT);
}
if (!TextUtils.isEmpty(mediaUrl)) {
chatMessage.setMediaUrl(mediaUrl);
}
return chatMessage;
} catch (Exception e) {
Log.e(TAG, "解析消息失败", e);
@ -823,13 +715,6 @@ 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();
@ -845,202 +730,6 @@ 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<ApiResponse<FileUploadResponse>>() {
@Override
public void onResponse(retrofit2.Call<ApiResponse<FileUploadResponse>> call, retrofit2.Response<ApiResponse<FileUploadResponse>> 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<ApiResponse<FileUploadResponse>> 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;
}
/**
* 设置通话按钮

View File

@ -79,8 +79,6 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
switch (type) {
case IMAGE:
return isOutgoing ? TYPE_IMAGE_OUTGOING : TYPE_IMAGE_INCOMING;
case BURN_IMAGE:
return isOutgoing ? TYPE_IMAGE_OUTGOING : TYPE_IMAGE_INCOMING;
case VOICE:
return isOutgoing ? TYPE_VOICE_OUTGOING : TYPE_VOICE_INCOMING;
case TEXT:
@ -564,11 +562,6 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
// 2. 如果为空但 message.getLocalMediaPath() 不为空加载本地文件
// 3. 都为空则显示占位图
if (message.getMessageType() != null && message.getMessageType() == ChatMessage.MessageType.BURN_IMAGE) {
messageImageView.setImageResource(R.drawable.ic_visibility_24);
return;
}
String imageUrl = message.getMediaUrl();
String localPath = message.getLocalMediaPath();
@ -714,11 +707,6 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
private void loadImage(ChatMessage message) {
if (messageImageView == null) return;
if (message.getMessageType() != null && message.getMessageType() == ChatMessage.MessageType.BURN_IMAGE) {
messageImageView.setImageResource(R.drawable.ic_visibility_24);
return;
}
String imageUrl = message.getMediaUrl();
String localPath = message.getLocalMediaPath();

View File

@ -3,28 +3,16 @@ 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;
@ -53,15 +41,9 @@ public class MyFriendsActivity extends AppCompatActivity {
private final List<FriendItem> allFriends = new ArrayList<>();
private final List<FriendRequestItem> allRequests = new ArrayList<>();
private final List<FriendItem> allBlocked = new ArrayList<>();
private int currentTab = 0; // 0: 好友列表, 1: 好友请求, 2: 附近, 3: 黑名单
private int currentTab = 0; // 0: 好友列表, 1: 好友请求, 2: 黑名单
private OkHttpClient httpClient;
// 附近Tab相关
private NearbyUsersAdapter nearbyUsersAdapter;
private final List<NearbyUser> nearbyUsers = new ArrayList<>();
private TianDiTuLocationService locationService;
private ActivityResultLauncher<String[]> requestLocationPermissionLauncher;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@ -69,31 +51,12 @@ 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());
@ -156,7 +119,6 @@ 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() {
@ -177,274 +139,23 @@ 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<String, Object> body = new java.util.HashMap<>();
body.put("targetUserId", userId);
body.put("message", "我想加你为好友");
ApiClient.getService(getApplicationContext()).sendFriendRequest(body)
.enqueue(new retrofit2.Callback<ApiResponse<Boolean>>() {
@Override
public void onResponse(retrofit2.Call<ApiResponse<Boolean>> call, retrofit2.Response<ApiResponse<Boolean>> 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<ApiResponse<Boolean>> 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<ApiResponse<Object>>() {
@Override
public void onResponse(retrofit2.Call<ApiResponse<Object>> call, retrofit2.Response<ApiResponse<Object>> response) {
loadNearbyUsersSimple(false);
}
@Override
public void onFailure(retrofit2.Call<ApiResponse<Object>> 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<ApiResponse<CommunityResponse.NearbyUserSimpleList>>() {
@Override
public void onResponse(retrofit2.Call<ApiResponse<CommunityResponse.NearbyUserSimpleList>> call,
retrofit2.Response<ApiResponse<CommunityResponse.NearbyUserSimpleList>> 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<CommunityResponse.NearbyUserSimple> 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<ApiResponse<CommunityResponse.NearbyUserSimpleList>> 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) {

View File

@ -630,9 +630,7 @@ public class PublishWorkActivity extends AppCompatActivity {
binding.emptyMediaContainer.setVisibility(View.VISIBLE);
binding.mediaRecyclerView.setVisibility(View.GONE);
binding.videoPreviewContainer.setVisibility(View.GONE);
// 允许只选封面发布无媒体时也展示封面选择区域
binding.coverCard.setVisibility(View.VISIBLE);
updateCoverPreview();
binding.coverCard.setVisibility(View.GONE);
return;
}
@ -743,7 +741,14 @@ 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();
@ -757,9 +762,9 @@ public class PublishWorkActivity extends AppCompatActivity {
return;
}
// 验证媒体/封面允许只选封面发布
if (selectedMediaUris.isEmpty() && selectedCoverUri == null) {
Toast.makeText(this, "请选择封面", Toast.LENGTH_SHORT).show();
// 验证媒体
if (selectedMediaUris.isEmpty()) {
Toast.makeText(this, "请选择图片或视频", Toast.LENGTH_SHORT).show();
return;
}
@ -775,11 +780,7 @@ public class PublishWorkActivity extends AppCompatActivity {
progressDialog.show();
// 开始上传流程
if (selectedMediaUris.isEmpty() && selectedCoverUri != null) {
// 只选封面发布按图片作品发布图片列表允许为空
currentWorkType = WorkItem.WorkType.IMAGE;
handleImageWorkUpload(title, description, progressDialog);
} else if (currentWorkType == WorkItem.WorkType.VIDEO && selectedVideoUri != null) {
if (currentWorkType == WorkItem.WorkType.VIDEO && selectedVideoUri != null) {
// 视频作品处理
handleVideoWorkUpload(title, description, progressDialog);
} else {

View File

@ -165,16 +165,6 @@ public class WorksAdapter extends RecyclerView.Adapter<WorksAdapter.WorksViewHol
typeIcon.setVisibility(View.GONE);
}
// 显示热门标识
ImageView hotBadge = itemView.findViewById(R.id.hotBadge);
if (hotBadge != null) {
if (works.isHot()) {
hotBadge.setVisibility(View.VISIBLE);
} else {
hotBadge.setVisibility(View.GONE);
}
}
// 设置点击事件
itemView.setOnClickListener(v -> {
if (listener != null) {

View File

@ -170,9 +170,6 @@ public interface ApiService {
@DELETE("api/front/conversations/messages/{id}")
Call<ApiResponse<Boolean>> deleteMessage(@Path("id") long id);
@POST("api/front/conversations/messages/{id}/burn/view")
Call<ApiResponse<BurnViewResponse>> viewBurnImage(@Path("id") long id);
// ==================== 好友管理 ====================
@GET("api/front/friends")
@ -380,14 +377,6 @@ public interface ApiService {
@POST("api/front/works/update")
Call<ApiResponse<Boolean>> updateWork(@Body WorksRequest body);
/**
* 获取附近作品列表按距离从近到远排序
*/
@GET("api/front/works/nearby")
Call<ApiResponse<PageResponse<WorksResponse>>> getNearbyWorks(
@Query("page") int page,
@Query("limit") int limit);
@GET("api/front/works/detail/{id}")
Call<ApiResponse<WorksResponse>> getWorkDetail(@Path("id") long id);
@ -436,14 +425,6 @@ public interface ApiService {
@POST("api/front/works/comment/delete/{commentId}")
Call<ApiResponse<Boolean>> deleteWorkComment(@Path("commentId") long commentId);
/**
* 获取热门作品列表
*/
@GET("api/front/works/hot")
Call<ApiResponse<PageResponse<WorksResponse>>> getHotWorks(
@Query("page") int page,
@Query("pageSize") int pageSize);
@POST("api/front/works/comment/like/{commentId}")
Call<ApiResponse<Boolean>> likeWorkComment(@Path("commentId") long commentId);

View File

@ -1,48 +0,0 @@
package com.example.livestreaming.net;
import com.google.gson.annotations.SerializedName;
public class BurnViewResponse {
@SerializedName("messageId")
private Long messageId;
@SerializedName("mediaUrl")
private String mediaUrl;
@SerializedName("burnSeconds")
private Integer burnSeconds;
@SerializedName("viewedAt")
private Long viewedAt;
@SerializedName("burnAt")
private Long burnAt;
@SerializedName("burned")
private Boolean burned;
public Long getMessageId() {
return messageId;
}
public String getMediaUrl() {
return mediaUrl;
}
public Integer getBurnSeconds() {
return burnSeconds;
}
public Long getViewedAt() {
return viewedAt;
}
public Long getBurnAt() {
return burnAt;
}
public Boolean getBurned() {
return burned;
}
}

View File

@ -27,8 +27,6 @@ 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;
@ -202,25 +200,4 @@ 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;
}
}

View File

@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M13.5,0.67s0.74,2.65 0.74,4.8c0,2.06 -1.35,3.73 -3.41,3.73 -2.07,0 -3.63,-1.67 -3.63,-3.73l0.03,-0.36C5.21,7.51 4,10.62 4,14c0,4.42 3.58,8 8,8s8,-3.58 8,-8C20,8.61 17.41,3.8 13.5,0.67zM11.71,19c-1.78,0 -3.22,-1.4 -3.22,-3.14 0,-1.62 1.05,-2.76 2.81,-3.12 1.77,-0.36 3.6,-1.21 4.62,-2.58 0.39,1.29 0.59,2.65 0.59,4.04 0,2.65 -2.15,4.8 -4.8,4.8z"/>
</vector>

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/rootLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#000000">
<ImageView
android:id="@+id/imageView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:contentDescription="image"
android:scaleType="fitCenter" />
<TextView
android:id="@+id/countdownText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="top|end"
android:layout_margin="16dp"
android:background="#66000000"
android:paddingStart="10dp"
android:paddingEnd="10dp"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:visibility="gone" />
<TextView
android:id="@+id/tipText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textColor="#FFFFFF"
android:textSize="15sp"
android:visibility="gone" />
</FrameLayout>

View File

@ -123,15 +123,6 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<ImageButton
android:id="@+id/burnImageButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="阅后即焚图片"
android:padding="8dp"
android:src="@drawable/ic_lock_12" />
<EditText
android:id="@+id/messageInput"
android:layout_width="0dp"

View File

@ -160,7 +160,7 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/topTabs">
<!-- 分类标签 - 显示6个标签 -->
<!-- 分类标签 - 显示5个标签 -->
<com.google.android.material.tabs.TabLayout
android:id="@+id/categoryTabs"
android:layout_width="0dp"
@ -174,11 +174,6 @@
app:tabSelectedTextColor="#333333"
app:tabTextColor="#999999">
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="热门" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"

View File

@ -146,14 +146,6 @@
android:visibility="gone"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<include
android:id="@+id/nearbyTabContainer"
layout="@layout/layout_nearby_tab"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:visibility="gone"
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
<com.example.livestreaming.EmptyStateView
android:id="@+id/emptyStateView"
android:layout_width="wrap_content"

View File

@ -268,7 +268,7 @@
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
android:visibility="visible"
android:visibility="gone"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:cardBackgroundColor="#FFFFFF">

View File

@ -35,18 +35,6 @@
android:visibility="gone"
android:contentDescription="视频" />
<!-- 热门标识 -->
<ImageView
android:id="@+id/hotBadge"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="top|end"
android:layout_margin="8dp"
android:src="@drawable/ic_fire_24"
android:visibility="gone"
android:contentDescription="热门"
app:tint="#FF4500" />
</FrameLayout>
<!-- 作品信息 -->

View File

@ -81,9 +81,9 @@
android:id="@+id/nearbyCountText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="正在搜索附近的人..."
android:textSize="13sp"
android:layout_marginStart="8dp"
android:text=""
android:textSize="12sp"
android:textColor="#999999" />
</LinearLayout>
@ -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" />
</LinearLayout>