实现作品上热门功能(后端完成)
- 数据库:添加is_hot和hot_time字段 - Service层:实现toggleHot和getHotWorks方法 - 管理端API:添加设置热门和查询热门列表接口 - 用户端API:添加获取热门作品列表接口 - 实体类:更新Works和WorksResponse添加热门字段 - 文档:创建功能实现说明文档 后续需要: - 后台管理界面(Vue)添加热门设置按钮 - Android App添加热门Tab页
This commit is contained in:
parent
11bd48dedc
commit
2c91a66234
225
Log/指令/作品上热门功能实现说明.md
Normal file
225
Log/指令/作品上热门功能实现说明.md
Normal 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
|
||||
**状态:** 后端完成,前端待实现
|
||||
59
Log/环境/8-配置上传服务器.md
Normal file
59
Log/环境/8-配置上传服务器.md
Normal 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
|
||||
|
|
@ -148,4 +148,43 @@ public class WorksController {
|
|||
return CommonResult.failed(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置/取消热门
|
||||
* @param id Long 作品ID
|
||||
* @param isHot Boolean 是否热门:true-设置热门 false-取消热门
|
||||
*/
|
||||
// @PreAuthorize("hasAuthority('admin:works:update')") // 临时注释,等待添加权限
|
||||
@ApiOperation(value = "设置/取消热门")
|
||||
@RequestMapping(value = "/toggleHot", method = RequestMethod.POST)
|
||||
public CommonResult<String> toggleHot(@RequestParam Long id, @RequestParam Boolean isHot) {
|
||||
try {
|
||||
worksService.toggleHot(id, isHot);
|
||||
return CommonResult.success(isHot ? "设置热门成功" : "取消热门成功");
|
||||
} catch (Exception e) {
|
||||
log.error("设置热门失败", e);
|
||||
return CommonResult.failed(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门作品列表
|
||||
* @param pageParamRequest 分页参数
|
||||
*/
|
||||
// @PreAuthorize("hasAuthority('admin:works:list')") // 临时注释,等待添加权限
|
||||
@ApiOperation(value = "热门作品列表")
|
||||
@RequestMapping(value = "/hotList", method = RequestMethod.GET)
|
||||
public CommonResult<CommonPage<WorksResponse>> getHotList(@Validated PageParamRequest pageParamRequest) {
|
||||
try {
|
||||
CommonPage<WorksResponse> result = worksService.getHotWorks(
|
||||
pageParamRequest.getPage(),
|
||||
pageParamRequest.getLimit(),
|
||||
null
|
||||
);
|
||||
return CommonResult.success(result);
|
||||
} catch (Exception e) {
|
||||
log.error("获取热门作品列表失败", e);
|
||||
return CommonResult.failed(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -105,6 +105,15 @@ public class Works implements Serializable {
|
|||
@ApiModelProperty(value = "状态:1-正常 0-下架")
|
||||
private Integer status = 1;
|
||||
|
||||
@Column(name = "is_hot", nullable = false, columnDefinition = "TINYINT DEFAULT 0 COMMENT '是否热门:1-是 0-否'")
|
||||
@ApiModelProperty(value = "是否热门:1-是 0-否")
|
||||
private Integer isHot = 0;
|
||||
|
||||
@Temporal(TemporalType.TIMESTAMP)
|
||||
@Column(name = "hot_time", columnDefinition = "DATETIME COMMENT '设置热门的时间'")
|
||||
@ApiModelProperty(value = "设置热门的时间")
|
||||
private Date hotTime;
|
||||
|
||||
@TableLogic
|
||||
@Column(name = "is_deleted", nullable = false, columnDefinition = "TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除 1-已删除'")
|
||||
@ApiModelProperty(value = "逻辑删除:0-未删除 1-已删除")
|
||||
|
|
|
|||
|
|
@ -73,6 +73,12 @@ public class WorksResponse {
|
|||
@ApiModelProperty(value = "状态:1-正常 0-下架")
|
||||
private Integer status;
|
||||
|
||||
@ApiModelProperty(value = "是否热门:1-是 0-否")
|
||||
private Integer isHot;
|
||||
|
||||
@ApiModelProperty(value = "设置热门的时间")
|
||||
private Date hotTime;
|
||||
|
||||
@ApiModelProperty(value = "是否已点赞")
|
||||
private Boolean isLiked = false;
|
||||
|
||||
|
|
|
|||
|
|
@ -330,4 +330,25 @@ public class WorksController {
|
|||
return CommonResult.failed(e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门作品列表(不需要登录)
|
||||
*/
|
||||
@ApiOperation(value = "获取热门作品列表")
|
||||
@GetMapping("/hot")
|
||||
public CommonResult<CommonPage<WorksResponse>> getHotWorks(
|
||||
@RequestParam(value = "page", defaultValue = "1") Integer page,
|
||||
@RequestParam(value = "pageSize", defaultValue = "20") Integer pageSize) {
|
||||
|
||||
try {
|
||||
// 获取当前登录用户ID(如果已登录)
|
||||
Integer userId = userService.getUserId();
|
||||
|
||||
CommonPage<WorksResponse> result = worksService.getHotWorks(page, pageSize, userId);
|
||||
return CommonResult.success(result);
|
||||
} catch (Exception e) {
|
||||
log.error("获取热门作品列表失败", e);
|
||||
return CommonResult.failed(e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -82,4 +82,21 @@ public interface WorksService extends IService<Works> {
|
|||
* @param worksId 作品ID
|
||||
*/
|
||||
void increaseShareCount(Long worksId);
|
||||
|
||||
/**
|
||||
* 设置/取消热门
|
||||
* @param worksId 作品ID
|
||||
* @param isHot 是否热门:true-设置热门 false-取消热门
|
||||
* @return 是否成功
|
||||
*/
|
||||
Boolean toggleHot(Long worksId, Boolean isHot);
|
||||
|
||||
/**
|
||||
* 获取热门作品列表
|
||||
* @param page 页码
|
||||
* @param pageSize 每页数量
|
||||
* @param userId 当前用户ID(可为空)
|
||||
* @return 热门作品列表
|
||||
*/
|
||||
CommonPage<WorksResponse> getHotWorks(Integer page, Integer pageSize, Integer userId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -505,6 +505,8 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
|
|||
response.setCommentCount(works.getCommentCount());
|
||||
response.setShareCount(works.getShareCount());
|
||||
response.setStatus(works.getStatus());
|
||||
response.setIsHot(works.getIsHot());
|
||||
response.setHotTime(works.getHotTime());
|
||||
response.setCreateTime(works.getCreateTime());
|
||||
response.setUpdateTime(works.getUpdateTime());
|
||||
|
||||
|
|
@ -536,4 +538,61 @@ public class WorksServiceImpl extends ServiceImpl<WorksDao, Works> implements Wo
|
|||
|
||||
return response;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Boolean toggleHot(Long worksId, Boolean isHot) {
|
||||
// 查询作品
|
||||
Works works = getById(worksId);
|
||||
if (works == null || works.getIsDeleted() == 1) {
|
||||
throw new CrmebException("作品不存在");
|
||||
}
|
||||
|
||||
// 更新热门状态
|
||||
LambdaUpdateWrapper<Works> updateWrapper = new LambdaUpdateWrapper<>();
|
||||
updateWrapper.eq(Works::getId, worksId)
|
||||
.set(Works::getIsHot, isHot ? 1 : 0)
|
||||
.set(Works::getHotTime, isHot ? new java.util.Date() : null);
|
||||
|
||||
boolean updated = update(updateWrapper);
|
||||
if (!updated) {
|
||||
throw new CrmebException("更新热门状态失败");
|
||||
}
|
||||
|
||||
log.info("{}热门成功,作品ID:{}", isHot ? "设置" : "取消", worksId);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public CommonPage<WorksResponse> getHotWorks(Integer page, Integer pageSize, Integer userId) {
|
||||
log.info("=== 获取热门作品列表 === page={}, pageSize={}, userId={}", page, pageSize, userId);
|
||||
|
||||
// 构建查询条件
|
||||
LambdaQueryWrapper<Works> queryWrapper = new LambdaQueryWrapper<>();
|
||||
queryWrapper.eq(Works::getIsDeleted, 0)
|
||||
.eq(Works::getStatus, 1) // 只查询正常状态的作品
|
||||
.eq(Works::getIsHot, 1) // 只查询热门作品
|
||||
.orderByDesc(Works::getHotTime); // 按设置热门的时间倒序
|
||||
|
||||
// 分页查询
|
||||
Page<Works> worksPage = new Page<>(page, pageSize);
|
||||
worksPage = page(worksPage, queryWrapper);
|
||||
|
||||
log.info("查询到 {} 条热门作品记录", worksPage.getRecords().size());
|
||||
|
||||
// 转换为响应对象
|
||||
List<WorksResponse> responseList = worksPage.getRecords().stream()
|
||||
.map(works -> convertToResponse(works, userId))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 构建分页结果
|
||||
CommonPage<WorksResponse> result = new CommonPage<>();
|
||||
result.setList(responseList);
|
||||
result.setTotal(worksPage.getTotal());
|
||||
result.setPage((int) worksPage.getCurrent());
|
||||
result.setLimit((int) worksPage.getSize());
|
||||
result.setTotalPage((int) worksPage.getPages());
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
15
Zhibo/zhibo-h/sql/add_works_hot_fields.sql
Normal file
15
Zhibo/zhibo-h/sql/add_works_hot_fields.sql
Normal 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%';
|
||||
|
|
@ -214,6 +214,11 @@
|
|||
android:name="com.example.livestreaming.ConversationActivity"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name="com.example.livestreaming.BurnImageViewerActivity"
|
||||
android:exported="false"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity
|
||||
android:name="com.example.livestreaming.PlayerActivity"
|
||||
android:exported="false" />
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -16,7 +16,8 @@ public class ChatMessage {
|
|||
public enum MessageType {
|
||||
TEXT, // 文本消息
|
||||
IMAGE, // 图片消息
|
||||
VOICE // 语音消息
|
||||
VOICE, // 语音消息
|
||||
BURN_IMAGE
|
||||
}
|
||||
|
||||
private String messageId;
|
||||
|
|
@ -37,6 +38,11 @@ public class ChatMessage {
|
|||
private int imageWidth; // 图片宽度
|
||||
private int imageHeight; // 图片高度
|
||||
|
||||
private Integer burnSeconds;
|
||||
private Long viewedAt;
|
||||
private Long burnAt;
|
||||
private Boolean burned;
|
||||
|
||||
// 表情回应相关字段
|
||||
private List<MessageReaction> reactions; // 表情回应列表
|
||||
|
||||
|
|
@ -123,6 +129,16 @@ public class ChatMessage {
|
|||
return msg;
|
||||
}
|
||||
|
||||
public static ChatMessage createBurnImageMessage(String username, String localPath, Integer burnSeconds) {
|
||||
ChatMessage msg = new ChatMessage(username, "");
|
||||
msg.messageType = MessageType.BURN_IMAGE;
|
||||
msg.localMediaPath = localPath;
|
||||
msg.burnSeconds = burnSeconds;
|
||||
msg.burned = false;
|
||||
msg.status = "我".equals(username) ? MessageStatus.SENDING : MessageStatus.SENT;
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Getters and Setters
|
||||
public String getMessageId() {
|
||||
return messageId;
|
||||
|
|
@ -244,6 +260,38 @@ public class ChatMessage {
|
|||
this.imageHeight = imageHeight;
|
||||
}
|
||||
|
||||
public Integer getBurnSeconds() {
|
||||
return burnSeconds;
|
||||
}
|
||||
|
||||
public void setBurnSeconds(Integer burnSeconds) {
|
||||
this.burnSeconds = burnSeconds;
|
||||
}
|
||||
|
||||
public Long getViewedAt() {
|
||||
return viewedAt;
|
||||
}
|
||||
|
||||
public void setViewedAt(Long viewedAt) {
|
||||
this.viewedAt = viewedAt;
|
||||
}
|
||||
|
||||
public Long getBurnAt() {
|
||||
return burnAt;
|
||||
}
|
||||
|
||||
public void setBurnAt(Long burnAt) {
|
||||
this.burnAt = burnAt;
|
||||
}
|
||||
|
||||
public Boolean getBurned() {
|
||||
return burned;
|
||||
}
|
||||
|
||||
public void setBurned(Boolean burned) {
|
||||
this.burned = burned;
|
||||
}
|
||||
|
||||
public List<MessageReaction> getReactions() {
|
||||
return reactions;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import org.json.JSONArray;
|
|||
import org.json.JSONObject;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
|
@ -195,6 +196,53 @@ public class ConversationActivity extends AppCompatActivity {
|
|||
showMessageMenu(message, position, view);
|
||||
});
|
||||
|
||||
adapter.setOnImageClickListener((message, imageView) -> {
|
||||
if (message == null) return;
|
||||
|
||||
if (message.getMessageType() == ChatMessage.MessageType.BURN_IMAGE) {
|
||||
long mid = 0L;
|
||||
try {
|
||||
mid = Long.parseLong(message.getMessageId());
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
if (mid <= 0L) {
|
||||
Snackbar.make(binding.getRoot(), "消息ID无效", Snackbar.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
Intent it = new Intent(ConversationActivity.this, BurnImageViewerActivity.class);
|
||||
it.putExtra(BurnImageViewerActivity.EXTRA_MESSAGE_ID, mid);
|
||||
startActivity(it);
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.getMessageType() == ChatMessage.MessageType.IMAGE) {
|
||||
String url = message.getMediaUrl();
|
||||
if (!TextUtils.isEmpty(url)) {
|
||||
AvatarViewerDialog.create(ConversationActivity.this)
|
||||
.setAvatarUrl(url)
|
||||
.show();
|
||||
return;
|
||||
}
|
||||
|
||||
String localPath = message.getLocalMediaPath();
|
||||
if (!TextUtils.isEmpty(localPath)) {
|
||||
try {
|
||||
android.net.Uri uri;
|
||||
if (localPath.startsWith("content://") || localPath.startsWith("file://")) {
|
||||
uri = android.net.Uri.parse(localPath);
|
||||
} else {
|
||||
uri = android.net.Uri.fromFile(new File(localPath));
|
||||
}
|
||||
AvatarViewerDialog.create(ConversationActivity.this)
|
||||
.setAvatarUri(uri)
|
||||
.show();
|
||||
return;
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
|
||||
layoutManager.setStackFromEnd(false);
|
||||
binding.messagesRecyclerView.setLayoutManager(layoutManager);
|
||||
|
|
@ -452,6 +500,33 @@ public class ConversationActivity extends AppCompatActivity {
|
|||
String avatarUrl = item.optString("avatarUrl", "");
|
||||
boolean isSystem = item.optBoolean("isSystemMessage", false);
|
||||
|
||||
String messageType = item.optString("messageType", "text");
|
||||
String mediaUrl = item.optString("mediaUrl", "");
|
||||
int duration = item.optInt("duration", 0);
|
||||
|
||||
Integer burnSeconds = null;
|
||||
if (item.has("burnSeconds") && !item.isNull("burnSeconds")) {
|
||||
int v = item.optInt("burnSeconds", 0);
|
||||
if (v > 0) burnSeconds = v;
|
||||
}
|
||||
|
||||
Long viewedAt = null;
|
||||
if (item.has("viewedAt") && !item.isNull("viewedAt")) {
|
||||
long v = item.optLong("viewedAt", 0L);
|
||||
if (v > 0L) viewedAt = v;
|
||||
}
|
||||
|
||||
Long burnAt = null;
|
||||
if (item.has("burnAt") && !item.isNull("burnAt")) {
|
||||
long v = item.optLong("burnAt", 0L);
|
||||
if (v > 0L) burnAt = v;
|
||||
}
|
||||
|
||||
Boolean burned = null;
|
||||
if (item.has("burned") && !item.isNull("burned")) {
|
||||
burned = item.optBoolean("burned", false);
|
||||
}
|
||||
|
||||
// 判断是否是自己发送的消息
|
||||
// 每次都重新从 AuthStore 获取最新的 userId,确保登录后能正确获取
|
||||
String myUserId = AuthStore.getUserId(this);
|
||||
|
|
@ -504,6 +579,27 @@ public class ConversationActivity extends AppCompatActivity {
|
|||
ChatMessage chatMessage = new ChatMessage(messageId, displayName, message, timestamp, isSystem, msgStatus);
|
||||
chatMessage.setOutgoing(isMine); // 关键:设置消息方向,确保自己发送的消息显示在右侧
|
||||
chatMessage.setAvatarUrl(avatarUrl);
|
||||
|
||||
if ("image".equalsIgnoreCase(messageType)) {
|
||||
chatMessage.setMessageType(ChatMessage.MessageType.IMAGE);
|
||||
} else if ("voice".equalsIgnoreCase(messageType)) {
|
||||
chatMessage.setMessageType(ChatMessage.MessageType.VOICE);
|
||||
if (duration > 0) {
|
||||
chatMessage.setVoiceDuration(duration);
|
||||
}
|
||||
} else if ("burn_image".equalsIgnoreCase(messageType)) {
|
||||
chatMessage.setMessageType(ChatMessage.MessageType.BURN_IMAGE);
|
||||
chatMessage.setBurnSeconds(burnSeconds);
|
||||
chatMessage.setViewedAt(viewedAt);
|
||||
chatMessage.setBurnAt(burnAt);
|
||||
chatMessage.setBurned(burned);
|
||||
} else {
|
||||
chatMessage.setMessageType(ChatMessage.MessageType.TEXT);
|
||||
}
|
||||
|
||||
if (!TextUtils.isEmpty(mediaUrl)) {
|
||||
chatMessage.setMediaUrl(mediaUrl);
|
||||
}
|
||||
return chatMessage;
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "解析消息失败", e);
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
|
|||
switch (type) {
|
||||
case IMAGE:
|
||||
return isOutgoing ? TYPE_IMAGE_OUTGOING : TYPE_IMAGE_INCOMING;
|
||||
case BURN_IMAGE:
|
||||
return isOutgoing ? TYPE_IMAGE_OUTGOING : TYPE_IMAGE_INCOMING;
|
||||
case VOICE:
|
||||
return isOutgoing ? TYPE_VOICE_OUTGOING : TYPE_VOICE_INCOMING;
|
||||
case TEXT:
|
||||
|
|
@ -562,6 +564,11 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
|
|||
// 2. 如果为空但 message.getLocalMediaPath() 不为空,加载本地文件
|
||||
// 3. 都为空则显示占位图
|
||||
|
||||
if (message.getMessageType() != null && message.getMessageType() == ChatMessage.MessageType.BURN_IMAGE) {
|
||||
messageImageView.setImageResource(R.drawable.ic_visibility_24);
|
||||
return;
|
||||
}
|
||||
|
||||
String imageUrl = message.getMediaUrl();
|
||||
String localPath = message.getLocalMediaPath();
|
||||
|
||||
|
|
@ -708,6 +715,11 @@ public class ConversationMessagesAdapter extends ListAdapter<ChatMessage, Recycl
|
|||
private void loadImage(ChatMessage message) {
|
||||
if (messageImageView == null) return;
|
||||
|
||||
if (message.getMessageType() != null && message.getMessageType() == ChatMessage.MessageType.BURN_IMAGE) {
|
||||
messageImageView.setImageResource(R.drawable.ic_visibility_24);
|
||||
return;
|
||||
}
|
||||
|
||||
String imageUrl = message.getMediaUrl();
|
||||
String localPath = message.getLocalMediaPath();
|
||||
|
||||
|
|
|
|||
|
|
@ -170,6 +170,9 @@ public interface ApiService {
|
|||
@DELETE("api/front/conversations/messages/{id}")
|
||||
Call<ApiResponse<Boolean>> deleteMessage(@Path("id") long id);
|
||||
|
||||
@POST("api/front/conversations/messages/{id}/burn/view")
|
||||
Call<ApiResponse<BurnViewResponse>> viewBurnImage(@Path("id") long id);
|
||||
|
||||
// ==================== 好友管理 ====================
|
||||
|
||||
@GET("api/front/friends")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in New Issue
Block a user