实现作品上热门功能(后端完成)

- 数据库:添加is_hot和hot_time字段
- Service层:实现toggleHot和getHotWorks方法
- 管理端API:添加设置热门和查询热门列表接口
- 用户端API:添加获取热门作品列表接口
- 实体类:更新Works和WorksResponse添加热门字段
- 文档:创建功能实现说明文档

后续需要:
- 后台管理界面(Vue)添加热门设置按钮
- Android App添加热门Tab页
This commit is contained in:
xiao12feng8 2026-01-08 16:44:12 +08:00
parent 11bd48dedc
commit 2c91a66234
17 changed files with 856 additions and 2 deletions

View File

@ -0,0 +1,225 @@
# 作品上热门功能实现说明
## 功能概述
实现了最简单的"作品上热门"功能,管理员可以在后台设置/取消作品的热门状态用户可以在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
**状态:** 后端完成,前端待实现

View File

@ -0,0 +1,59 @@
安装软件
# 安装 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

View File

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

View File

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

View File

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

View File

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

View File

@ -82,4 +82,21 @@ public interface WorksService extends IService<Works> {
* @param worksId 作品ID * @param worksId 作品ID
*/ */
void increaseShareCount(Long worksId); void increaseShareCount(Long worksId);
/**
* 设置/取消热门
* @param worksId 作品ID
* @param isHot 是否热门true-设置热门 false-取消热门
* @return 是否成功
*/
Boolean toggleHot(Long worksId, Boolean isHot);
/**
* 获取热门作品列表
* @param page 页码
* @param pageSize 每页数量
* @param userId 当前用户ID可为空
* @return 热门作品列表
*/
CommonPage<WorksResponse> getHotWorks(Integer page, Integer pageSize, Integer userId);
} }

View File

@ -505,6 +505,8 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
response.setCommentCount(works.getCommentCount()); response.setCommentCount(works.getCommentCount());
response.setShareCount(works.getShareCount()); response.setShareCount(works.getShareCount());
response.setStatus(works.getStatus()); response.setStatus(works.getStatus());
response.setIsHot(works.getIsHot());
response.setHotTime(works.getHotTime());
response.setCreateTime(works.getCreateTime()); response.setCreateTime(works.getCreateTime());
response.setUpdateTime(works.getUpdateTime()); response.setUpdateTime(works.getUpdateTime());
@ -536,4 +538,61 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
return response; return response;
} }
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean toggleHot(Long worksId, Boolean isHot) {
// 查询作品
Works works = getById(worksId);
if (works == null || works.getIsDeleted() == 1) {
throw new CrmebException("作品不存在");
}
// 更新热门状态
LambdaUpdateWrapper<Works> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(Works::getId, worksId)
.set(Works::getIsHot, isHot ? 1 : 0)
.set(Works::getHotTime, isHot ? new java.util.Date() : null);
boolean updated = update(updateWrapper);
if (!updated) {
throw new CrmebException("更新热门状态失败");
}
log.info("{}热门成功作品ID{}", isHot ? "设置" : "取消", worksId);
return true;
}
@Override
public CommonPage<WorksResponse> getHotWorks(Integer page, Integer pageSize, Integer userId) {
log.info("=== 获取热门作品列表 === page={}, pageSize={}, userId={}", page, pageSize, userId);
// 构建查询条件
LambdaQueryWrapper<Works> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(Works::getIsDeleted, 0)
.eq(Works::getStatus, 1) // 只查询正常状态的作品
.eq(Works::getIsHot, 1) // 只查询热门作品
.orderByDesc(Works::getHotTime); // 按设置热门的时间倒序
// 分页查询
Page<Works> worksPage = new Page<>(page, pageSize);
worksPage = page(worksPage, queryWrapper);
log.info("查询到 {} 条热门作品记录", worksPage.getRecords().size());
// 转换为响应对象
List<WorksResponse> responseList = worksPage.getRecords().stream()
.map(works -> convertToResponse(works, userId))
.collect(Collectors.toList());
// 构建分页结果
CommonPage<WorksResponse> result = new CommonPage<>();
result.setList(responseList);
result.setTotal(worksPage.getTotal());
result.setPage((int) worksPage.getCurrent());
result.setLimit((int) worksPage.getSize());
result.setTotalPage((int) worksPage.getPages());
return result;
}
} }

View File

@ -0,0 +1,15 @@
-- 添加作品热门相关字段
-- 执行时间2026-01-08
USE `crmeb`;
-- 在 works 表添加热门相关字段
ALTER TABLE `works`
ADD COLUMN `is_hot` tinyint DEFAULT 0 COMMENT '是否热门1-是 0-否' AFTER `status`,
ADD COLUMN `hot_time` datetime DEFAULT NULL COMMENT '设置热门的时间' AFTER `is_hot`;
-- 添加索引以提高查询性能
ALTER TABLE `works` ADD INDEX `idx_is_hot` (`is_hot`);
-- 查看修改结果
SHOW COLUMNS FROM `works` LIKE '%hot%';

View File

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

View File

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

View File

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

View File

@ -26,6 +26,7 @@ import org.json.JSONArray;
import org.json.JSONObject; import org.json.JSONObject;
import java.io.IOException; import java.io.IOException;
import java.io.File;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -195,6 +196,53 @@ public class ConversationActivity extends AppCompatActivity {
showMessageMenu(message, position, view); showMessageMenu(message, position, view);
}); });
adapter.setOnImageClickListener((message, imageView) -> {
if (message == null) return;
if (message.getMessageType() == ChatMessage.MessageType.BURN_IMAGE) {
long mid = 0L;
try {
mid = Long.parseLong(message.getMessageId());
} catch (Exception ignored) {
}
if (mid <= 0L) {
Snackbar.make(binding.getRoot(), "消息ID无效", Snackbar.LENGTH_SHORT).show();
return;
}
Intent it = new Intent(ConversationActivity.this, BurnImageViewerActivity.class);
it.putExtra(BurnImageViewerActivity.EXTRA_MESSAGE_ID, mid);
startActivity(it);
return;
}
if (message.getMessageType() == ChatMessage.MessageType.IMAGE) {
String url = message.getMediaUrl();
if (!TextUtils.isEmpty(url)) {
AvatarViewerDialog.create(ConversationActivity.this)
.setAvatarUrl(url)
.show();
return;
}
String localPath = message.getLocalMediaPath();
if (!TextUtils.isEmpty(localPath)) {
try {
android.net.Uri uri;
if (localPath.startsWith("content://") || localPath.startsWith("file://")) {
uri = android.net.Uri.parse(localPath);
} else {
uri = android.net.Uri.fromFile(new File(localPath));
}
AvatarViewerDialog.create(ConversationActivity.this)
.setAvatarUri(uri)
.show();
return;
} catch (Exception ignored) {
}
}
}
});
LinearLayoutManager layoutManager = new LinearLayoutManager(this); LinearLayoutManager layoutManager = new LinearLayoutManager(this);
layoutManager.setStackFromEnd(false); layoutManager.setStackFromEnd(false);
binding.messagesRecyclerView.setLayoutManager(layoutManager); binding.messagesRecyclerView.setLayoutManager(layoutManager);
@ -452,6 +500,33 @@ public class ConversationActivity extends AppCompatActivity {
String avatarUrl = item.optString("avatarUrl", ""); String avatarUrl = item.optString("avatarUrl", "");
boolean isSystem = item.optBoolean("isSystemMessage", false); boolean isSystem = item.optBoolean("isSystemMessage", false);
String messageType = item.optString("messageType", "text");
String mediaUrl = item.optString("mediaUrl", "");
int duration = item.optInt("duration", 0);
Integer burnSeconds = null;
if (item.has("burnSeconds") && !item.isNull("burnSeconds")) {
int v = item.optInt("burnSeconds", 0);
if (v > 0) burnSeconds = v;
}
Long viewedAt = null;
if (item.has("viewedAt") && !item.isNull("viewedAt")) {
long v = item.optLong("viewedAt", 0L);
if (v > 0L) viewedAt = v;
}
Long burnAt = null;
if (item.has("burnAt") && !item.isNull("burnAt")) {
long v = item.optLong("burnAt", 0L);
if (v > 0L) burnAt = v;
}
Boolean burned = null;
if (item.has("burned") && !item.isNull("burned")) {
burned = item.optBoolean("burned", false);
}
// 判断是否是自己发送的消息 // 判断是否是自己发送的消息
// 每次都重新从 AuthStore 获取最新的 userId确保登录后能正确获取 // 每次都重新从 AuthStore 获取最新的 userId确保登录后能正确获取
String myUserId = AuthStore.getUserId(this); String myUserId = AuthStore.getUserId(this);
@ -504,6 +579,27 @@ public class ConversationActivity extends AppCompatActivity {
ChatMessage chatMessage = new ChatMessage(messageId, displayName, message, timestamp, isSystem, msgStatus); ChatMessage chatMessage = new ChatMessage(messageId, displayName, message, timestamp, isSystem, msgStatus);
chatMessage.setOutgoing(isMine); // 关键设置消息方向确保自己发送的消息显示在右侧 chatMessage.setOutgoing(isMine); // 关键设置消息方向确保自己发送的消息显示在右侧
chatMessage.setAvatarUrl(avatarUrl); chatMessage.setAvatarUrl(avatarUrl);
if ("image".equalsIgnoreCase(messageType)) {
chatMessage.setMessageType(ChatMessage.MessageType.IMAGE);
} else if ("voice".equalsIgnoreCase(messageType)) {
chatMessage.setMessageType(ChatMessage.MessageType.VOICE);
if (duration > 0) {
chatMessage.setVoiceDuration(duration);
}
} else if ("burn_image".equalsIgnoreCase(messageType)) {
chatMessage.setMessageType(ChatMessage.MessageType.BURN_IMAGE);
chatMessage.setBurnSeconds(burnSeconds);
chatMessage.setViewedAt(viewedAt);
chatMessage.setBurnAt(burnAt);
chatMessage.setBurned(burned);
} else {
chatMessage.setMessageType(ChatMessage.MessageType.TEXT);
}
if (!TextUtils.isEmpty(mediaUrl)) {
chatMessage.setMediaUrl(mediaUrl);
}
return chatMessage; return chatMessage;
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "解析消息失败", e); Log.e(TAG, "解析消息失败", e);

View File

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

View File

@ -170,6 +170,9 @@ public interface ApiService {
@DELETE("api/front/conversations/messages/{id}") @DELETE("api/front/conversations/messages/{id}")
Call<ApiResponse<Boolean>> deleteMessage(@Path("id") long id); Call<ApiResponse<Boolean>> deleteMessage(@Path("id") long id);
@POST("api/front/conversations/messages/{id}/burn/view")
Call<ApiResponse<BurnViewResponse>> viewBurnImage(@Path("id") long id);
// ==================== 好友管理 ==================== // ==================== 好友管理 ====================
@GET("api/front/friends") @GET("api/front/friends")

View File

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

View File

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