实现附近作品功能重构:修复所有编译错误,完成从用户到作品的功能转换,优化错误处理和调试信息
This commit is contained in:
parent
2c91a66234
commit
3d0638da8a
578
.kiro/specs/android-hot-tab/design.md
Normal file
578
.kiro/specs/android-hot-tab/design.md
Normal file
|
|
@ -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<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页数据 |
|
||||||
|
| 上拉加载更多 | 滚动到底部 | 加载下一页数据 |
|
||||||
|
| 点击作品 | 点击作品卡片 | 跳转到作品详情页 |
|
||||||
|
|
||||||
84
.kiro/specs/android-hot-tab/requirements.md
Normal file
84
.kiro/specs/android-hot-tab/requirements.md
Normal file
|
|
@ -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 自动刷新热门作品列表
|
||||||
236
.kiro/specs/android-hot-tab/tasks.md
Normal file
236
.kiro/specs/android-hot-tab/tasks.md
Normal file
|
|
@ -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<WorksResponse>)
|
||||||
|
- 添加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.
|
||||||
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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<WorksResponse> 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
|
|
||||||
**状态:** 后端完成,前端待实现
|
|
||||||
|
|
@ -118,3 +118,37 @@ export function UpdateWorksStatus(pram) {
|
||||||
params: data,
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,16 @@
|
||||||
<el-tag v-else type="danger">已删除</el-tag>
|
<el-tag v-else type="danger">已删除</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 prop="createTime" label="创建时间" min-width="180" />
|
||||||
<el-table-column label="操作" width="150" fixed="right">
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
<template slot-scope="scope">
|
<template slot-scope="scope">
|
||||||
<router-link :to="{ path: '/contentManage/article/edit/' + scope.row.id }">
|
<router-link :to="{ path: '/contentManage/article/edit/' + scope.row.id }">
|
||||||
<a v-hasPermi="['admin:works:info']">编辑</a>
|
<a v-hasPermi="['admin:works:info']">编辑</a>
|
||||||
|
|
@ -79,6 +87,14 @@
|
||||||
<a @click="handleUpdateStatus(scope.row)" v-hasPermi="['admin:works:update']">
|
<a @click="handleUpdateStatus(scope.row)" v-hasPermi="['admin:works:update']">
|
||||||
{{ scope.row.status === 1 ? '下架' : '上架' }}
|
{{ scope.row.status === 1 ? '下架' : '上架' }}
|
||||||
</a>
|
</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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
@ -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) {
|
handleSizeChange(val) {
|
||||||
this.listPram.limit = val;
|
this.listPram.limit = val;
|
||||||
this.handlerGetListData(this.listPram);
|
this.handlerGetListData(this.listPram);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import io.swagger.annotations.ApiModelProperty;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
import javax.validation.constraints.NotBlank;
|
import javax.validation.constraints.NotBlank;
|
||||||
import javax.validation.constraints.NotNull;
|
|
||||||
import javax.validation.constraints.Size;
|
import javax.validation.constraints.Size;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -19,7 +18,6 @@ public class WorksRequest {
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@ApiModelProperty(value = "作品标题", required = true)
|
@ApiModelProperty(value = "作品标题", required = true)
|
||||||
@NotBlank(message = "作品标题不能为空")
|
|
||||||
@Size(max = 200, message = "作品标题最多200个字符")
|
@Size(max = 200, message = "作品标题最多200个字符")
|
||||||
private String title;
|
private String title;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,26 @@ 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 编辑作品(需要登录)
|
* 编辑作品(需要登录)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,15 @@ public interface WorksService extends IService<Works> {
|
||||||
*/
|
*/
|
||||||
CommonPage<WorksResponse> getUserWorks(Integer userId, Integer page, Integer pageSize);
|
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
|
* @param worksId 作品ID
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,20 @@ public class UploadServiceImpl implements UploadService {
|
||||||
systemAttachmentService.save(systemAttachment);
|
systemAttachmentService.save(systemAttachment);
|
||||||
return resultFile;
|
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();
|
CloudVo cloudVo = new CloudVo();
|
||||||
// 判断是否保存本地
|
// 判断是否保存本地
|
||||||
String fileIsSave = systemConfigService.getValueByKeyException(SysConfigConstants.CONFIG_FILE_IS_SAVE);
|
String fileIsSave = systemConfigService.getValueByKeyException(SysConfigConstants.CONFIG_FILE_IS_SAVE);
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,11 @@ import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -58,11 +62,8 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
|
||||||
|
|
||||||
// 验证作品类型和媒体资源
|
// 验证作品类型和媒体资源
|
||||||
if ("IMAGE".equalsIgnoreCase(request.getType())) {
|
if ("IMAGE".equalsIgnoreCase(request.getType())) {
|
||||||
// 图片作品必须有图片列表
|
// 图片作品允许只上传封面(图片列表可为空),但如果上传了图片,最多9张
|
||||||
if (request.getImageUrls() == null || request.getImageUrls().isEmpty()) {
|
if (request.getImageUrls() != null && request.getImageUrls().size() > 9) {
|
||||||
throw new CrmebException("图片作品必须上传至少一张图片");
|
|
||||||
}
|
|
||||||
if (request.getImageUrls().size() > 9) {
|
|
||||||
throw new CrmebException("图片作品最多只能上传9张图片");
|
throw new CrmebException("图片作品最多只能上传9张图片");
|
||||||
}
|
}
|
||||||
} else if ("VIDEO".equalsIgnoreCase(request.getType())) {
|
} else if ("VIDEO".equalsIgnoreCase(request.getType())) {
|
||||||
|
|
@ -85,7 +86,8 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
|
||||||
// 创建作品对象
|
// 创建作品对象
|
||||||
Works works = new Works();
|
Works works = new Works();
|
||||||
works.setUid(userId);
|
works.setUid(userId);
|
||||||
works.setTitle(request.getTitle());
|
// title 字段数据库 NOT NULL,允许为空时写入空字符串
|
||||||
|
works.setTitle(StrUtil.blankToDefault(request.getTitle(), ""));
|
||||||
works.setDescription(request.getDescription());
|
works.setDescription(request.getDescription());
|
||||||
works.setType(request.getType().toUpperCase()); // 统一转为大写
|
works.setType(request.getType().toUpperCase()); // 统一转为大写
|
||||||
works.setCoverImage(request.getCoverUrl());
|
works.setCoverImage(request.getCoverUrl());
|
||||||
|
|
@ -195,7 +197,8 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
|
||||||
|
|
||||||
// 更新作品信息
|
// 更新作品信息
|
||||||
if (request.getTitle() != null) {
|
if (request.getTitle() != null) {
|
||||||
works.setTitle(request.getTitle());
|
// title 字段数据库 NOT NULL,允许为空时写入空字符串
|
||||||
|
works.setTitle(StrUtil.blankToDefault(request.getTitle(), ""));
|
||||||
}
|
}
|
||||||
if (request.getDescription() != null) {
|
if (request.getDescription() != null) {
|
||||||
works.setDescription(request.getDescription());
|
works.setDescription(request.getDescription());
|
||||||
|
|
@ -217,6 +220,10 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
|
||||||
|
|
||||||
// 处理图片列表
|
// 处理图片列表
|
||||||
if (request.getImageUrls() != null) {
|
if (request.getImageUrls() != null) {
|
||||||
|
// 图片列表允许为空(仅封面发布)
|
||||||
|
if (!request.getImageUrls().isEmpty() && request.getImageUrls().size() > 9) {
|
||||||
|
throw new CrmebException("图片作品最多只能上传9张图片");
|
||||||
|
}
|
||||||
if (request.getImageUrls().isEmpty()) {
|
if (request.getImageUrls().isEmpty()) {
|
||||||
works.setImages(null);
|
works.setImages(null);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -454,6 +461,117 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
|
||||||
return searchWorks(request, userId);
|
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
|
@Override
|
||||||
public void increaseViewCount(Long worksId) {
|
public void increaseViewCount(Long worksId) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,54 @@
|
||||||
-- 添加作品热门相关字段
|
-- 添加作品热门相关字段
|
||||||
-- 执行时间:2026-01-08
|
-- 执行时间:2026-01-08
|
||||||
|
|
||||||
USE `crmeb`;
|
USE `zhibo`;
|
||||||
|
|
||||||
-- 在 works 表添加热门相关字段
|
-- 检查字段是否已存在,如果不存在则添加
|
||||||
ALTER TABLE `works`
|
-- 添加 is_hot 字段(如果不存在)
|
||||||
ADD COLUMN `is_hot` tinyint DEFAULT 0 COMMENT '是否热门:1-是 0-否' AFTER `status`,
|
SET @col_exists = 0;
|
||||||
ADD COLUMN `hot_time` datetime DEFAULT NULL COMMENT '设置热门的时间' AFTER `is_hot`;
|
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 `works` ADD INDEX `idx_is_hot` (`is_hot`);
|
'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 字段(如果不存在)
|
||||||
SHOW COLUMNS FROM `works` LIKE '%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 = '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;
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,11 @@ import android.content.ClipData;
|
||||||
import android.content.ClipboardManager;
|
import android.content.ClipboardManager;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
|
import android.provider.OpenableColumns;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.KeyEvent;
|
import android.view.KeyEvent;
|
||||||
|
|
@ -19,7 +21,11 @@ import androidx.appcompat.widget.PopupMenu;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
|
||||||
import com.example.livestreaming.databinding.ActivityConversationBinding;
|
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.AuthStore;
|
||||||
|
import com.example.livestreaming.net.FileUploadResponse;
|
||||||
import com.google.android.material.snackbar.Snackbar;
|
import com.google.android.material.snackbar.Snackbar;
|
||||||
|
|
||||||
import org.json.JSONArray;
|
import org.json.JSONArray;
|
||||||
|
|
@ -27,6 +33,8 @@ import org.json.JSONObject;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.FileOutputStream;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
@ -38,6 +46,7 @@ import okhttp3.OkHttpClient;
|
||||||
import okhttp3.Request;
|
import okhttp3.Request;
|
||||||
import okhttp3.RequestBody;
|
import okhttp3.RequestBody;
|
||||||
import okhttp3.Response;
|
import okhttp3.Response;
|
||||||
|
import okhttp3.MultipartBody;
|
||||||
|
|
||||||
public class ConversationActivity extends AppCompatActivity {
|
public class ConversationActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
|
@ -67,6 +76,9 @@ public class ConversationActivity extends AppCompatActivity {
|
||||||
private String otherUserName;
|
private String otherUserName;
|
||||||
private String otherUserAvatarUrl;
|
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) {
|
public static void start(Context context, String conversationId, String title) {
|
||||||
Intent intent = new Intent(context, ConversationActivity.class);
|
Intent intent = new Intent(context, ConversationActivity.class);
|
||||||
intent.putExtra(EXTRA_CONVERSATION_ID, conversationId);
|
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) -> {
|
binding.messageInput.setOnEditorActionListener((v, actionId, event) -> {
|
||||||
if (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER) {
|
if (event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER) {
|
||||||
sendMessage();
|
sendMessage();
|
||||||
|
|
@ -826,6 +845,202 @@ public class ConversationActivity extends AppCompatActivity {
|
||||||
// 设置通话按钮点击事件
|
// 设置通话按钮点击事件
|
||||||
setupCallButtons();
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置通话按钮
|
* 设置通话按钮
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,16 +3,28 @@ package com.example.livestreaming;
|
||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
import android.text.Editable;
|
import android.text.Editable;
|
||||||
import android.text.TextWatcher;
|
import android.text.TextWatcher;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.activity.result.ActivityResultLauncher;
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
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.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.example.livestreaming.net.AuthStore;
|
||||||
import com.google.android.material.tabs.TabLayout;
|
import com.google.android.material.tabs.TabLayout;
|
||||||
|
|
||||||
|
|
@ -41,9 +53,15 @@ public class MyFriendsActivity extends AppCompatActivity {
|
||||||
private final List<FriendItem> allFriends = new ArrayList<>();
|
private final List<FriendItem> allFriends = new ArrayList<>();
|
||||||
private final List<FriendRequestItem> allRequests = new ArrayList<>();
|
private final List<FriendRequestItem> allRequests = new ArrayList<>();
|
||||||
private final List<FriendItem> allBlocked = new ArrayList<>();
|
private final List<FriendItem> allBlocked = new ArrayList<>();
|
||||||
private int currentTab = 0; // 0: 好友列表, 1: 好友请求, 2: 黑名单
|
private int currentTab = 0; // 0: 好友列表, 1: 好友请求, 2: 附近, 3: 黑名单
|
||||||
private OkHttpClient httpClient;
|
private OkHttpClient httpClient;
|
||||||
|
|
||||||
|
// 附近Tab相关
|
||||||
|
private NearbyUsersAdapter nearbyUsersAdapter;
|
||||||
|
private final List<NearbyUser> nearbyUsers = new ArrayList<>();
|
||||||
|
private TianDiTuLocationService locationService;
|
||||||
|
private ActivityResultLauncher<String[]> requestLocationPermissionLauncher;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
|
@ -51,12 +69,31 @@ public class MyFriendsActivity extends AppCompatActivity {
|
||||||
setContentView(binding.getRoot());
|
setContentView(binding.getRoot());
|
||||||
|
|
||||||
httpClient = new OkHttpClient();
|
httpClient = new OkHttpClient();
|
||||||
|
locationService = new TianDiTuLocationService(this);
|
||||||
|
setupLaunchers();
|
||||||
|
|
||||||
setupViews();
|
setupViews();
|
||||||
setupTabs();
|
setupTabs();
|
||||||
loadFriendList();
|
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() {
|
private void setupViews() {
|
||||||
binding.backButton.setOnClickListener(v -> finish());
|
binding.backButton.setOnClickListener(v -> finish());
|
||||||
|
|
||||||
|
|
@ -119,6 +156,7 @@ public class MyFriendsActivity extends AppCompatActivity {
|
||||||
private void setupTabs() {
|
private void setupTabs() {
|
||||||
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("好友列表"));
|
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("好友列表"));
|
||||||
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("好友请求"));
|
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("好友请求"));
|
||||||
|
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("附近"));
|
||||||
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("黑名单"));
|
binding.tabLayout.addTab(binding.tabLayout.newTab().setText("黑名单"));
|
||||||
|
|
||||||
binding.tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
|
binding.tabLayout.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
|
||||||
|
|
@ -139,23 +177,274 @@ public class MyFriendsActivity extends AppCompatActivity {
|
||||||
binding.friendsRecyclerView.setVisibility(View.VISIBLE);
|
binding.friendsRecyclerView.setVisibility(View.VISIBLE);
|
||||||
binding.requestsRecyclerView.setVisibility(View.GONE);
|
binding.requestsRecyclerView.setVisibility(View.GONE);
|
||||||
binding.blockedRecyclerView.setVisibility(View.GONE);
|
binding.blockedRecyclerView.setVisibility(View.GONE);
|
||||||
|
binding.nearbyTabContainer.getRoot().setVisibility(View.GONE);
|
||||||
binding.searchContainer.setVisibility(View.VISIBLE);
|
binding.searchContainer.setVisibility(View.VISIBLE);
|
||||||
loadFriendList();
|
loadFriendList();
|
||||||
} else if (tabIndex == 1) {
|
} else if (tabIndex == 1) {
|
||||||
binding.friendsRecyclerView.setVisibility(View.GONE);
|
binding.friendsRecyclerView.setVisibility(View.GONE);
|
||||||
binding.requestsRecyclerView.setVisibility(View.VISIBLE);
|
binding.requestsRecyclerView.setVisibility(View.VISIBLE);
|
||||||
binding.blockedRecyclerView.setVisibility(View.GONE);
|
binding.blockedRecyclerView.setVisibility(View.GONE);
|
||||||
|
binding.nearbyTabContainer.getRoot().setVisibility(View.GONE);
|
||||||
binding.searchContainer.setVisibility(View.GONE);
|
binding.searchContainer.setVisibility(View.GONE);
|
||||||
loadFriendRequests();
|
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 {
|
} else {
|
||||||
binding.friendsRecyclerView.setVisibility(View.GONE);
|
binding.friendsRecyclerView.setVisibility(View.GONE);
|
||||||
binding.requestsRecyclerView.setVisibility(View.GONE);
|
binding.requestsRecyclerView.setVisibility(View.GONE);
|
||||||
binding.blockedRecyclerView.setVisibility(View.VISIBLE);
|
binding.blockedRecyclerView.setVisibility(View.VISIBLE);
|
||||||
|
binding.nearbyTabContainer.getRoot().setVisibility(View.GONE);
|
||||||
binding.searchContainer.setVisibility(View.GONE);
|
binding.searchContainer.setVisibility(View.GONE);
|
||||||
loadBlockedList();
|
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() {
|
private void loadFriendList() {
|
||||||
String token = AuthStore.getToken(this);
|
String token = AuthStore.getToken(this);
|
||||||
if (token == null) {
|
if (token == null) {
|
||||||
|
|
|
||||||
|
|
@ -630,7 +630,9 @@ public class PublishWorkActivity extends AppCompatActivity {
|
||||||
binding.emptyMediaContainer.setVisibility(View.VISIBLE);
|
binding.emptyMediaContainer.setVisibility(View.VISIBLE);
|
||||||
binding.mediaRecyclerView.setVisibility(View.GONE);
|
binding.mediaRecyclerView.setVisibility(View.GONE);
|
||||||
binding.videoPreviewContainer.setVisibility(View.GONE);
|
binding.videoPreviewContainer.setVisibility(View.GONE);
|
||||||
binding.coverCard.setVisibility(View.GONE);
|
// 允许只选封面发布:无媒体时也展示封面选择区域
|
||||||
|
binding.coverCard.setVisibility(View.VISIBLE);
|
||||||
|
updateCoverPreview();
|
||||||
return;
|
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", "封面URI: " + (selectedCoverUri != null ? selectedCoverUri.toString() : "null"));
|
||||||
android.util.Log.d("PublishWork", "封面是否本地文件: " + (selectedCoverUri != null ? isLocalUri(selectedCoverUri) : "N/A"));
|
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) {
|
if (title.length() > 20) {
|
||||||
Toast.makeText(this, "标题最多20字", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "标题最多20字", Toast.LENGTH_SHORT).show();
|
||||||
binding.titleEditText.requestFocus();
|
binding.titleEditText.requestFocus();
|
||||||
|
|
@ -762,9 +757,9 @@ public class PublishWorkActivity extends AppCompatActivity {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证媒体
|
// 验证媒体/封面:允许只选封面发布
|
||||||
if (selectedMediaUris.isEmpty()) {
|
if (selectedMediaUris.isEmpty() && selectedCoverUri == null) {
|
||||||
Toast.makeText(this, "请选择图片或视频", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "请选择封面", Toast.LENGTH_SHORT).show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -780,7 +775,11 @@ public class PublishWorkActivity extends AppCompatActivity {
|
||||||
progressDialog.show();
|
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);
|
handleVideoWorkUpload(title, description, progressDialog);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,16 @@ public class WorksAdapter extends RecyclerView.Adapter<WorksAdapter.WorksViewHol
|
||||||
typeIcon.setVisibility(View.GONE);
|
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 -> {
|
itemView.setOnClickListener(v -> {
|
||||||
if (listener != null) {
|
if (listener != null) {
|
||||||
|
|
|
||||||
|
|
@ -380,6 +380,14 @@ public interface ApiService {
|
||||||
@POST("api/front/works/update")
|
@POST("api/front/works/update")
|
||||||
Call<ApiResponse<Boolean>> updateWork(@Body WorksRequest body);
|
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}")
|
@GET("api/front/works/detail/{id}")
|
||||||
Call<ApiResponse<WorksResponse>> getWorkDetail(@Path("id") long id);
|
Call<ApiResponse<WorksResponse>> getWorkDetail(@Path("id") long id);
|
||||||
|
|
||||||
|
|
@ -428,6 +436,14 @@ public interface ApiService {
|
||||||
@POST("api/front/works/comment/delete/{commentId}")
|
@POST("api/front/works/comment/delete/{commentId}")
|
||||||
Call<ApiResponse<Boolean>> deleteWorkComment(@Path("commentId") long 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}")
|
@POST("api/front/works/comment/like/{commentId}")
|
||||||
Call<ApiResponse<Boolean>> likeWorkComment(@Path("commentId") long commentId);
|
Call<ApiResponse<Boolean>> likeWorkComment(@Path("commentId") long commentId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ public class WorksResponse {
|
||||||
private Boolean isLiked; // 当前用户是否已点赞
|
private Boolean isLiked; // 当前用户是否已点赞
|
||||||
private Boolean isCollected; // 当前用户是否已收藏
|
private Boolean isCollected; // 当前用户是否已收藏
|
||||||
private Boolean isOwner; // 是否是当前用户的作品
|
private Boolean isOwner; // 是否是当前用户的作品
|
||||||
|
private Integer isHot; // 是否热门:1-是 0-否
|
||||||
|
private String hotTime; // 设置热门的时间
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
|
|
@ -200,4 +202,25 @@ public class WorksResponse {
|
||||||
public void setIsOwner(Boolean isOwner) {
|
public void setIsOwner(Boolean isOwner) {
|
||||||
this.isOwner = 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
android-app/app/src/main/res/drawable/ic_fire_24.xml
Normal file
9
android-app/app/src/main/res/drawable/ic_fire_24.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<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>
|
||||||
|
|
@ -123,6 +123,15 @@
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="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
|
<EditText
|
||||||
android:id="@+id/messageInput"
|
android:id="@+id/messageInput"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/topTabs">
|
app:layout_constraintTop_toBottomOf="@id/topTabs">
|
||||||
|
|
||||||
<!-- 分类标签 - 显示5个标签 -->
|
<!-- 分类标签 - 显示6个标签 -->
|
||||||
<com.google.android.material.tabs.TabLayout
|
<com.google.android.material.tabs.TabLayout
|
||||||
android:id="@+id/categoryTabs"
|
android:id="@+id/categoryTabs"
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
|
|
@ -174,6 +174,11 @@
|
||||||
app:tabSelectedTextColor="#333333"
|
app:tabSelectedTextColor="#333333"
|
||||||
app:tabTextColor="#999999">
|
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
|
<com.google.android.material.tabs.TabItem
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,14 @@
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
app:layout_behavior="@string/appbar_scrolling_view_behavior" />
|
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
|
<com.example.livestreaming.EmptyStateView
|
||||||
android:id="@+id/emptyStateView"
|
android:id="@+id/emptyStateView"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
|
|
|
||||||
|
|
@ -268,7 +268,7 @@
|
||||||
android:layout_marginStart="16dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginEnd="16dp"
|
android:layout_marginEnd="16dp"
|
||||||
android:layout_marginBottom="16dp"
|
android:layout_marginBottom="16dp"
|
||||||
android:visibility="gone"
|
android:visibility="visible"
|
||||||
app:cardCornerRadius="12dp"
|
app:cardCornerRadius="12dp"
|
||||||
app:cardElevation="2dp"
|
app:cardElevation="2dp"
|
||||||
app:cardBackgroundColor="#FFFFFF">
|
app:cardBackgroundColor="#FFFFFF">
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@
|
||||||
android:visibility="gone"
|
android:visibility="gone"
|
||||||
android:contentDescription="视频" />
|
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>
|
</FrameLayout>
|
||||||
|
|
||||||
<!-- 作品信息 -->
|
<!-- 作品信息 -->
|
||||||
|
|
|
||||||
|
|
@ -81,9 +81,9 @@
|
||||||
android:id="@+id/nearbyCountText"
|
android:id="@+id/nearbyCountText"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:text=""
|
android:text="正在搜索附近的人..."
|
||||||
android:textSize="12sp"
|
android:textSize="13sp"
|
||||||
android:textColor="#999999" />
|
android:textColor="#999999" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
@ -120,7 +120,7 @@
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="8dp"
|
android:layout_marginTop="8dp"
|
||||||
android:text="正在搜索附近的人..."
|
android:text="正在加载附近的人..."
|
||||||
android:textSize="13sp"
|
android:textSize="13sp"
|
||||||
android:textColor="#999999" />
|
android:textColor="#999999" />
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user