feat: update gift system and IM features

This commit is contained in:
ShiQi 2026-01-04 19:00:02 +08:00
parent 909d1994a1
commit b4a0eee625
68 changed files with 2393 additions and 23516 deletions

View File

@ -393,7 +393,7 @@ Authorization: Bearer <token>
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Integer | 是 | 直播间ID |
| id | String | 是 | 直播间ID |
**响应示例**:
```json
@ -419,7 +419,7 @@ Authorization: Bearer <token>
**路径参数**:
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| id | Integer | 是 | 直播间ID |
| id | String | 是 | 直播间ID |
**响应示例**:
```json
@ -483,6 +483,11 @@ Authorization: Bearer <token>
|--------|------|------|------|
| roomId | String | 是 | 直播间ID |
**重要说明**:
- 此接口路径不包含 `/front` 前缀
- 完整路径为: `POST /api/live/online/broadcast/{roomId}`
- 用于向WebSocket客户端广播直播间在线人数更新
**响应示例**:
```json
{
@ -1632,6 +1637,7 @@ Authorization: Bearer <token>
|--------|------|------|------|
| id | Long | 是 | 作品ID |
| title | String | 否 | 作品标题 |
| type | String | 否 | 作品类型 |
| description | String | 否 | 作品描述 |
| coverUrl | String | 否 | 封面图片URL |
| categoryId | Integer | 否 | 分类ID |

View File

@ -84,7 +84,11 @@
:preview-src-list="[scope.row.image]"
style="width: 50px; height: 50px"
fit="cover"
/>
>
<div slot="error" class="image-slot">
<i class="el-icon-picture-outline"></i>
</div>
</el-image>
</template>
</el-table-column>
<el-table-column label="礼物名称" align="center" prop="name" />
@ -126,7 +130,8 @@
:total="total"
:page.sync="queryParams.page"
:limit.sync="queryParams.limit"
@pagination="getList"
@size-change="getList"
@current-change="getList"
/>
</el-card>
@ -190,6 +195,7 @@
<script>
import { giftListApi, giftAddApi, giftUpdateApi, giftDeleteApi, giftStatusApi } from '@/api/gift';
import { fileImageApi } from '@/api/systemSetting';
import DataPagination from '@/components/common/DataPagination';
export default {
@ -240,27 +246,37 @@ export default {
getList() {
this.loading = true;
giftListApi(this.queryParams).then(response => {
const data = response.data || {};
this.giftList = data.list || [];
this.total = data.total || 0;
console.log('礼物列表响应:', response);
// axios res.data response CommonPage
// response = { list: [], total: 0, page: 1, limit: 10, totalPage: 1 }
const list = response.list || [];
// status 1 0
this.giftList = list.map(item => ({
...item,
status: Number(item.status) //
}));
this.total = response.total || 0;
console.log('礼物列表数据:', this.giftList);
console.log('总数:', this.total);
this.loading = false;
}).catch(() => {
}).catch(error => {
console.error('获取礼物列表失败:', error);
this.$message.error('获取礼物列表失败');
this.loading = false;
});
},
getStatistics() {
//
giftListApi({ page: 1, limit: 9999 }).then(response => {
console.log('统计数据响应:', response);
const data = response.data || {};
const list = data.list || [];
console.log('礼物列表:', list);
console.log('列表长度:', list.length);
const list = response.list || [];
this.statistics.total = list.length;
this.statistics.enabled = list.filter(item => item.status === true).length;
this.statistics.disabled = list.filter(item => item.status === false).length;
this.statistics.totalValue = list.reduce((sum, item) => sum + (parseFloat(item.diamondPrice) || 0), 0);
console.log('统计结果:', this.statistics);
this.statistics.enabled = list.filter(item => item.status === 1 || item.status === true).length;
this.statistics.disabled = list.filter(item => item.status === 0 || item.status === false).length;
this.statistics.totalValue = list.reduce((sum, item) => sum + (parseFloat(item.diamondPrice) || 0), 0).toFixed(2);
}).catch(error => {
console.error('获取统计数据失败:', error);
});
@ -369,11 +385,36 @@ export default {
});
},
handleUpload(param) {
//
const formData = new FormData();
formData.append('file', param.file);
//
this.$message.info('图片上传功能需要配置上传接口');
formData.append('multipart', param.file); // multipart
//
const loading = this.$loading({
lock: true,
text: '上传中...',
spinner: 'el-icon-loading',
background: 'rgba(0, 0, 0, 0.7)'
});
fileImageApi(formData, { model: 'gift', pid: 0 })
.then(response => {
loading.close();
console.log('上传响应:', response);
//
if (response) {
// response FileResultVo URL
this.form.image = response.url || response.fileUrl || response;
this.$message.success('上传成功');
} else {
this.$message.error('上传失败,返回数据格式错误');
}
})
.catch(error => {
loading.close();
console.error('上传失败:', error);
this.$message.error('上传失败: ' + (error.message || '未知错误'));
});
},
beforeUpload(file) {
const isImage = file.type.indexOf('image/') === 0;
@ -385,6 +426,57 @@ export default {
this.$message.error('上传图片大小不能超过 2MB!');
}
return isImage && isLt2M;
},
getImageUrl(url) {
if (!url) {
console.log('图片URL为空');
return '';
}
console.log('原始图片URL:', url);
console.log('URL类型:', typeof url);
//
let urlStr = String(url).trim();
console.log('转换后的URL:', urlStr);
// URL
// http://domain/http://domain/crmebimage/... -> crmebimage/...
if (urlStr.indexOf('http://') > 0 || urlStr.indexOf('https://') > 0) {
console.log('检测到重复拼接的URL尝试修复');
// http:// https://
const secondHttpIndex = urlStr.indexOf('http://', 1);
const secondHttpsIndex = urlStr.indexOf('https://', 1);
if (secondHttpIndex > 0) {
urlStr = urlStr.substring(secondHttpIndex);
console.log('修复后的URL:', urlStr);
} else if (secondHttpsIndex > 0) {
urlStr = urlStr.substring(secondHttpsIndex);
console.log('修复后的URL:', urlStr);
}
}
// URLhttp:// https://
if (urlStr.indexOf('http://') === 0 || urlStr.indexOf('https://') === 0) {
console.log('检测到完整URL直接返回');
return urlStr;
}
//
// /
let path = urlStr;
if (path.charAt(0) === '/') {
path = path.substring(1);
}
// 使
const baseUrl = window.location.origin;
const fullUrl = baseUrl + '/' + path;
console.log('拼接后的完整URL:', fullUrl);
return fullUrl;
}
}
};
@ -448,4 +540,15 @@ export default {
height: 120px;
display: block;
}
.image-slot {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: #f5f7fa;
color: #909399;
font-size: 30px;
}
</style>

View File

@ -95,7 +95,7 @@
</template>
<script>
import { wishTreeListApi, wishTreeInfoApi, wishTreeMessageListApi, wishTreeMessageDeleteApi, wishTreeMessageStatusApi } from '@/api/wishtree';
import { wishTreeListApi, wishTreeInfoApi, wishTreeMessageListApi, wishTreeMessageDeleteApi, wishTreeMessageStatusApi } from '@/api/wishTree';
export default {
name: 'WishTreeMessage',

View File

@ -89,7 +89,7 @@
</template>
<script>
import { wishTreeListApi, wishTreeNodeListApi, wishTreeNodeSaveApi, wishTreeNodeUpdateApi, wishTreeNodeDeleteApi, wishTreeNodeStatusApi } from '@/api/wishtree';
import { wishTreeListApi, wishTreeNodeListApi, wishTreeNodeSaveApi, wishTreeNodeUpdateApi, wishTreeNodeDeleteApi, wishTreeNodeStatusApi } from '@/api/wishTree';
export default {
name: 'WishTreeNode',

View File

@ -93,7 +93,7 @@
</template>
<script>
import { wishTreeInfoApi, wishTreeMessageListApi, wishTreeMessageDeleteApi, wishTreeMessageStatusApi } from '@/api/wishtree';
import { wishTreeInfoApi, wishTreeMessageListApi, wishTreeMessageDeleteApi, wishTreeMessageStatusApi } from '@/api/wishTree';
export default {
name: 'WishTreeDetail',

View File

@ -104,7 +104,7 @@
</template>
<script>
import { wishTreeListApi, wishTreeInfoApi, wishTreeSaveApi, wishTreeUpdateApi, wishTreeDeleteApi, wishTreeActivateApi } from '@/api/wishtree';
import { wishTreeListApi, wishTreeInfoApi, wishTreeSaveApi, wishTreeUpdateApi, wishTreeDeleteApi, wishTreeActivateApi } from '@/api/wishTree';
export default {
name: 'WishTreeList',

View File

@ -61,7 +61,7 @@
</template>
<script>
import { backgroundList, backgroundSave, backgroundDelete, festivalList } from '@/api/wishtree';
import { backgroundList, backgroundSave, backgroundDelete, festivalList } from '@/api/wishTree';
export default {
name: 'WishtreeBackground',

View File

@ -78,7 +78,7 @@
</template>
<script>
import { festivalList, festivalSave, festivalDelete, festivalStatus } from '@/api/wishtree';
import { festivalList, festivalSave, festivalDelete, festivalStatus } from '@/api/wishTree';
export default {
name: 'WishtreeFestival',

View File

@ -86,7 +86,7 @@
</template>
<script>
import { getStatistics } from '@/api/wishtree';
import { getStatistics } from '@/api/wishTree';
import * as echarts from 'echarts';
export default {

View File

@ -74,7 +74,7 @@
</template>
<script>
import { wishList, wishAudit, wishDelete, festivalList } from '@/api/wishtree';
import { wishList, wishAudit, wishDelete, festivalList } from '@/api/wishTree';
export default {
name: 'WishtreeWish',

View File

@ -53,6 +53,14 @@ module.exports = {
'/api': {
target: 'http://localhost:30001',
changeOrigin: true
},
'/crmebimage': {
target: 'http://localhost:30001',
changeOrigin: true
},
'/file': {
target: 'http://localhost:30001',
changeOrigin: true
}
}
},
@ -64,6 +72,9 @@ module.exports = {
alias: {
'@': resolve('src'),
},
extensions: ['.js', '.vue', '.json'],
// 在Windows上强制不区分大小写
symlinks: false,
},
},
chainWebpack(config) {

View File

@ -92,13 +92,26 @@ public class WebConfig implements WebMvcConfigurer {
registry.addResourceHandler("/webjars/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/");
/** 本地文件上传路径 */
registry.addResourceHandler(UploadConstants.UPLOAD_FILE_KEYWORD + "/**")
.addResourceLocations("file:" + crmebConfig.getImagePath() + "/" + UploadConstants.UPLOAD_FILE_KEYWORD + "/");
/** 本地文件上传路径 - 使用jar包所在目录 */
String uploadPath = crmebConfig.getAbsoluteImagePath();
registry.addResourceHandler(UploadConstants.UPLOAD_AFTER_FILE_KEYWORD + "/**")
.addResourceLocations("file:" +crmebConfig.getImagePath() + "/" + UploadConstants.UPLOAD_AFTER_FILE_KEYWORD + "/" );
// 添加 image 路径映射修复图片上传显示问题
registry.addResourceHandler("/image/**")
.addResourceLocations("file:" + uploadPath + "image" + File.separator);
// 添加 video 路径映射
registry.addResourceHandler("/video/**")
.addResourceLocations("file:" + uploadPath + "video" + File.separator);
// 添加 voice 路径映射
registry.addResourceHandler("/voice/**")
.addResourceLocations("file:" + uploadPath + "voice" + File.separator);
registry.addResourceHandler("/crmebimage/**")
.addResourceLocations("file:" + uploadPath + "crmebimage" + File.separator);
registry.addResourceHandler("/file/**")
.addResourceLocations("file:" + uploadPath + "file" + File.separator);
}
@Bean

View File

@ -346,6 +346,8 @@ public class GiftAdminController {
@RequestParam(value = "page", defaultValue = "1") Integer page,
@RequestParam(value = "limit", defaultValue = "20") Integer limit) {
log.info("礼物列表查询 - 参数: name={}, status={}, page={}, limit={}", name, status, page, limit);
StringBuilder sql = new StringBuilder();
sql.append("SELECT id, name, image, diamond_price as diamondPrice, intimacy, status, ");
sql.append("is_heartbeat as isHeartbeat, buy_type as buyType, belong, remark, ");
@ -375,23 +377,32 @@ public class GiftAdminController {
countParams.add(status);
}
sql.append(" ORDER BY sort ASC, id DESC ");
sql.append(" ORDER BY sort ASC, id ASC ");
Long total = jdbcTemplate.queryForObject(countSql.toString(), Long.class, countParams.toArray());
if (total == null) {
total = 0L;
}
int offset = (page - 1) * limit;
sql.append(" LIMIT ? OFFSET ? ");
params.add(limit);
params.add(offset);
log.info("礼物列表查询 - SQL: {}", sql.toString());
log.info("礼物列表查询 - 参数: {}", params);
List<Map<String, Object>> list = jdbcTemplate.queryForList(sql.toString(), params.toArray());
// 打印日志用于调试
log.info("礼物列表查询 - 总数: {}, 当前页: {}, 每页: {}, offset: {}, 列表大小: {}", total, page, limit, offset, list.size());
CommonPage<Map<String, Object>> result = new CommonPage<>();
result.setList(list);
result.setTotal(total != null ? total : 0L);
result.setTotal(total);
result.setPage(page);
result.setLimit(limit);
result.setTotalPage((int) Math.ceil((double) (total != null ? total : 0) / limit));
result.setTotalPage((int) Math.ceil((double) total / limit));
return CommonResult.success(result);
}
@ -403,14 +414,19 @@ public class GiftAdminController {
@PostMapping("/add")
public CommonResult<String> addGift(@RequestBody Map<String, Object> request) {
try {
log.info("添加礼物 - 请求参数: {}", request);
String name = (String) request.get("name");
String image = (String) request.get("image");
BigDecimal diamondPrice = new BigDecimal(request.get("diamondPrice").toString());
Integer intimacy = request.get("intimacy") != null ? Integer.parseInt(request.get("intimacy").toString()) : 0;
Integer level = request.get("level") != null ? Integer.parseInt(request.get("level").toString()) : 1;
Integer isHeartbeat = request.get("isHeartbeat") != null ? Integer.parseInt(request.get("isHeartbeat").toString()) : 0;
// 处理布尔值或整数值
Integer isHeartbeat = convertToInteger(request.get("isHeartbeat"), 0);
Integer sort = request.get("sort") != null ? Integer.parseInt(request.get("sort").toString()) : 0;
Integer status = request.get("status") != null ? Integer.parseInt(request.get("status").toString()) : 1;
Integer status = convertToInteger(request.get("status"), 1);
String remark = (String) request.get("remark");
String buyType = request.get("buyType") != null ? (String) request.get("buyType") : "钻石";
String belong = request.get("belong") != null ? (String) request.get("belong") : "平台";
@ -420,6 +436,7 @@ public class GiftAdminController {
jdbcTemplate.update(sql, name, image, diamondPrice, intimacy, status, isHeartbeat,
buyType, belong, remark, level, sort);
log.info("添加礼物成功 - 名称: {}", name);
return CommonResult.success("添加成功");
} catch (Exception e) {
log.error("添加礼物失败", e);
@ -434,15 +451,20 @@ public class GiftAdminController {
@PostMapping("/update")
public CommonResult<String> updateGift(@RequestBody Map<String, Object> request) {
try {
log.info("更新礼物 - 请求参数: {}", request);
Integer id = Integer.parseInt(request.get("id").toString());
String name = (String) request.get("name");
String image = (String) request.get("image");
BigDecimal diamondPrice = new BigDecimal(request.get("diamondPrice").toString());
Integer intimacy = request.get("intimacy") != null ? Integer.parseInt(request.get("intimacy").toString()) : 0;
Integer level = request.get("level") != null ? Integer.parseInt(request.get("level").toString()) : 1;
Integer isHeartbeat = request.get("isHeartbeat") != null ? Integer.parseInt(request.get("isHeartbeat").toString()) : 0;
// 处理布尔值或整数值
Integer isHeartbeat = convertToInteger(request.get("isHeartbeat"), 0);
Integer sort = request.get("sort") != null ? Integer.parseInt(request.get("sort").toString()) : 0;
Integer status = request.get("status") != null ? Integer.parseInt(request.get("status").toString()) : 1;
Integer status = convertToInteger(request.get("status"), 1);
String remark = (String) request.get("remark");
String buyType = request.get("buyType") != null ? (String) request.get("buyType") : "钻石";
String belong = request.get("belong") != null ? (String) request.get("belong") : "平台";
@ -452,6 +474,7 @@ public class GiftAdminController {
jdbcTemplate.update(sql, name, image, diamondPrice, intimacy, status, isHeartbeat,
buyType, belong, remark, level, sort, id);
log.info("更新礼物成功 - ID: {}", id);
return CommonResult.success("更新成功");
} catch (Exception e) {
log.error("更新礼物失败", e);
@ -459,6 +482,35 @@ public class GiftAdminController {
}
}
/**
* 将对象转换为整数支持布尔值字符串整数
*/
private Integer convertToInteger(Object value, Integer defaultValue) {
if (value == null) {
return defaultValue;
}
// 如果是布尔值
if (value instanceof Boolean) {
return ((Boolean) value) ? 1 : 0;
}
// 如果是字符串
String strValue = value.toString().toLowerCase();
if ("true".equals(strValue)) {
return 1;
} else if ("false".equals(strValue)) {
return 0;
}
// 尝试解析为整数
try {
return Integer.parseInt(strValue);
} catch (NumberFormatException e) {
return defaultValue;
}
}
/**
* 删除礼物
*/
@ -490,12 +542,15 @@ public class GiftAdminController {
@PostMapping("/status")
public CommonResult<String> updateGiftStatus(@RequestBody Map<String, Object> request) {
try {
log.info("更新礼物状态 - 请求参数: {}", request);
Integer id = Integer.parseInt(request.get("id").toString());
Integer status = Integer.parseInt(request.get("status").toString());
Integer status = convertToInteger(request.get("status"), 1);
String sql = "UPDATE eb_gift SET status = ? WHERE id = ?";
jdbcTemplate.update(sql, status, id);
log.info("更新礼物状态成功 - ID: {}, 状态: {}", id, status);
return CommonResult.success("状态更新成功");
} catch (Exception e) {
log.error("更新礼物状态失败", e);

View File

@ -39,7 +39,18 @@ public class ResponseRouter {
return data;
}
// 排除上传接口上传接口返回的URL不需要添加前缀
// 因为前端会直接保存到数据库下次查询时会再次处理
if (path.contains("/upload/image") || path.contains("/upload/file")) {
return data;
}
//根据需要处理返回值 && !data.contains("data:image/png;base64")
// 如果数据已经包含完整的URLhttp://或https://则不处理
if (data.contains("http://") || data.contains("https://")) {
return data;
}
if ((data.contains(UploadConstants.UPLOAD_FILE_KEYWORD + "/"))
|| data.contains(UploadConstants.DOWNLOAD_FILE_KEYWORD) || data.contains(UploadConstants.UPLOAD_AFTER_FILE_KEYWORD)) {
if (data.contains(UploadConstants.DOWNLOAD_FILE_KEYWORD + "/" + UploadConstants.UPLOAD_MODEL_PATH_EXCEL)) {

View File

@ -5,9 +5,9 @@ crmeb:
wechat-api-url: #请求微信接口中专服务器
wechat-js-api-debug: false #微信js api系列是否开启调试模式
wechat-js-api-beta: true #微信js api是否是beta版本
asyncConfig: true #是否同步config表数据到redis
asyncConfig: false #是否同步config表数据到redis - 改为false直接从数据库读取
asyncWeChatProgramTempList: false #是否同步小程序公共模板库
imagePath: /Users/23730/Desktop/projectr/single_java/ # 服务器图片路径配置 斜杠结尾
imagePath: upload/ # 服务器图片路径配置 - jar包同级目录的upload文件夹
demoSite: true # 是否演示站点 所有手机号码都会掩码
activityStyleCachedTime: 10 #活动边框缓存周期 秒为单位生产环境适当5-10分钟即可
ignored: #安全路径白名单

View File

@ -107,6 +107,67 @@ public class CrmebConfig {
return imagePath;
}
/**
* 获取图片存储的绝对路径
* 如果配置的是相对路径则转换为jar包所在目录的绝对路径
*/
public String getAbsoluteImagePath() {
if (imagePath == null || imagePath.isEmpty()) {
String result = getJarDirectory() + "/upload/";
System.out.println("图片路径(默认): " + result);
return result;
}
// 如果是绝对路径Windows: C:/ Linux: /直接返回
if (imagePath.startsWith("/") || imagePath.matches("^[a-zA-Z]:.*")) {
System.out.println("图片路径(绝对): " + imagePath);
return imagePath;
}
// 相对路径转换为jar包所在目录的绝对路径
String absolutePath = getJarDirectory() + "/" + imagePath;
// 确保以斜杠结尾
if (!absolutePath.endsWith("/") && !absolutePath.endsWith("\\")) {
absolutePath += "/";
}
System.out.println("图片路径(相对转绝对): " + absolutePath);
return absolutePath;
}
/**
* 获取jar包所在目录
*/
private String getJarDirectory() {
try {
String path = CrmebConfig.class.getProtectionDomain().getCodeSource().getLocation().getPath();
// 解码URL编码的路径
path = java.net.URLDecoder.decode(path, "UTF-8");
System.out.println("=== 调试信息 ===");
System.out.println("原始路径: " + path);
// 如果是jar包去掉jar文件名
if (path.endsWith(".jar")) {
path = path.substring(0, path.lastIndexOf("/"));
System.out.println("去掉jar文件名后: " + path);
}
// 去掉开头的斜杠Windows
if (path.startsWith("/") && path.matches("^/[a-zA-Z]:.*")) {
path = path.substring(1);
System.out.println("去掉开头斜杠后: " + path);
}
System.out.println("最终jar目录: " + path);
System.out.println("===============");
return path;
} catch (Exception e) {
System.out.println("获取jar目录失败使用user.dir: " + System.getProperty("user.dir"));
// 如果获取失败使用当前工作目录
return System.getProperty("user.dir");
}
}
public void setImagePath(String imagePath) {
this.imagePath = imagePath;
}

View File

@ -34,6 +34,9 @@ public class SendGiftResponse implements Serializable {
@ApiModelProperty(value = "剩余钻石数")
private BigDecimal remainingDiamond;
@ApiModelProperty(value = "新余额(剩余金币数,兼容字段)")
private BigDecimal newBalance;
@ApiModelProperty(value = "增加的亲密度")
private Integer intimacy;

View File

@ -274,13 +274,31 @@ public class LiveRoomController {
@Autowired
private com.zbkj.service.service.GiftRecordService giftRecordService;
@Autowired
private com.zbkj.front.service.GiftWebSocketService giftWebSocketService;
@Autowired
private com.zbkj.service.service.GiftService giftService;
@ApiOperation(value = "赠送礼物(需要登录)")
@PostMapping("/rooms/{roomId}/gift")
public CommonResult<com.zbkj.common.response.SendGiftResponse> sendGift(
@PathVariable Integer roomId,
@RequestBody @Validated com.zbkj.common.request.SendGiftRequest request) {
// 🔍 添加调试日志
log.info("========================================");
log.info("🎁 收到礼物赠送请求");
log.info("roomId: {}", roomId);
log.info("request.giftId: {}", request.getGiftId());
log.info("request.receiverId: {}", request.getReceiverId());
log.info("request.giftCount: {}", request.getGiftCount());
log.info("========================================");
// 获取当前登录用户ID
Integer currentUserId = frontTokenComponent.getUserId();
log.info("currentUserId: {}", currentUserId);
if (currentUserId == null) {
return CommonResult.failed("请先登录");
}
@ -295,19 +313,53 @@ public class LiveRoomController {
return CommonResult.failed("直播间不存在");
}
// TODO: 后续可能需要恢复此验证
// 不能给自己送礼物
if (currentUserId.equals(request.getReceiverId())) {
return CommonResult.failed("不能给自己赠送礼物");
}
// if (currentUserId.equals(request.getReceiverId())) {
// return CommonResult.failed("不能给自己赠送礼物");
// }
try {
// 调用服务层赠送礼物
com.zbkj.common.response.SendGiftResponse response =
giftRecordService.sendGift(roomId, currentUserId, request);
log.info("✅ 礼物赠送成功");
// 🎁 通过WebSocket推送礼物消息到直播间
try {
// 获取用户信息
com.zbkj.common.model.user.User sender = userService.getById(currentUserId);
com.zbkj.common.model.user.User receiver = userService.getById(request.getReceiverId());
com.zbkj.common.model.gift.Gift gift = giftService.getGiftById(request.getGiftId());
if (sender != null && receiver != null && gift != null) {
giftWebSocketService.broadcastGift(
String.valueOf(roomId),
gift.getId(),
gift.getName(),
request.getGiftCount(),
currentUserId,
sender.getNickname(),
request.getReceiverId(),
receiver.getNickname(),
response.getTotalDiamond().intValue()
);
log.info("✅ 礼物WebSocket推送成功: roomId={}, giftName={}, sender={}",
roomId, gift.getName(), sender.getNickname());
} else {
log.warn("⚠️ 无法推送礼物消息:用户或礼物信息不完整");
}
} catch (Exception e) {
log.error("❌ 礼物WebSocket推送失败", e);
// 不影响主流程继续返回成功
}
return CommonResult.success(response);
} catch (com.zbkj.common.exception.CrmebException e) {
log.error("❌ 礼物赠送失败: {}", e.getMessage());
return CommonResult.failed(e.getMessage());
} catch (Exception e) {
log.error("❌ 礼物赠送异常", e);
return CommonResult.failed("赠送礼物失败: " + e.getMessage());
}
}

View File

@ -72,10 +72,10 @@ public class UserController {
log.info("请求参数: {}", request);
log.info("昵称: {}", request.getNickname());
log.info("头像: {}", request.getAvatar());
log.info("个人签名: {}", request.getBio());
log.info("个人签名: {}", request.getMark());
log.info("生日: {}", request.getBirthday());
log.info("性别: {}", request.getGender());
log.info("所在地: {}", request.getLocation());
log.info("性别: {}", request.getSex());
log.info("所在地: {}", request.getAddres());
try {
boolean result = userService.editUser(request);
@ -101,7 +101,6 @@ public class UserController {
UserCenterResponse response = userService.getUserCenter();
log.info("========== 返回用户信息 ==========");
log.info("昵称: {}", response.getNickname());
log.info("个人签名: {}", response.getBio());
log.info("生日: {}", response.getBirthday());
log.info("性别: {}", response.getSex());
log.info("所在地: {}", response.getAddres());

View File

@ -68,7 +68,7 @@ public class GiftRecordServiceImpl extends ServiceImpl<GiftRecordDao, GiftRecord
// 5. 检查余额假设使用now_money字段作为钻石余额
if (sender.getNowMoney().compareTo(totalDiamond) < 0) {
throw new CrmebException("钻石余额不足");
throw new CrmebException("金币余额不足");
}
// 6. 扣除赠送者钻石
@ -106,7 +106,12 @@ public class GiftRecordServiceImpl extends ServiceImpl<GiftRecordDao, GiftRecord
response.setGiftName(gift.getName());
response.setGiftCount(request.getGiftCount());
response.setTotalDiamond(totalDiamond);
response.setRemainingDiamond(sender.getNowMoney().subtract(totalDiamond));
// 计算剩余余额
BigDecimal remainingBalance = sender.getNowMoney().subtract(totalDiamond);
response.setRemainingDiamond(remainingBalance);
response.setNewBalance(remainingBalance); // 兼容 Android 客户端
response.setIntimacy(record.getIntimacy());
response.setSendTime(record.getCreateTime().getTime());

View File

@ -86,12 +86,20 @@ public class SystemAttachmentServiceImpl extends ServiceImpl<SystemAttachmentDao
*/
@Override
public String prefixImage(String path) {
// 如果路径为空或已经包含http://或https://直接返回
if (StringUtils.isBlank(path) || path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
// 如果那些域名不需要加则跳过
return path.replace(UploadConstants.UPLOAD_FILE_KEYWORD+"/", getCdnUrl() + "/"+ UploadConstants.UPLOAD_FILE_KEYWORD+"/");
}
@Override
public String prefixUploadf(String path) {
// 如果路径为空或已经包含http://或https://直接返回
if (StringUtils.isBlank(path) || path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
// 如果那些域名不需要加则跳过
return path.replace("crmebimage/" + UploadConstants.UPLOAD_AFTER_FILE_KEYWORD+"/", getCdnUrl() + "/" +"crmebimage/" + UploadConstants.UPLOAD_AFTER_FILE_KEYWORD+"/");
}
@ -103,6 +111,10 @@ public class SystemAttachmentServiceImpl extends ServiceImpl<SystemAttachmentDao
*/
@Override
public String prefixFile(String path) {
// 如果路径为空或已经包含http://或https://直接返回
if (StringUtils.isBlank(path) || path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
if (path.contains(Constants.WECHAT_SOURCE_CODE_FILE_NAME)) {
String cdnUrl = systemConfigService.getValueByKey("local" + "UploadUrl");
return path.replace("crmebimage/", cdnUrl + "/crmebimage/");

View File

@ -180,8 +180,8 @@ public class UploadServiceImpl implements UploadService {
fileName = StrUtil.subPre(fileName, 90).concat(".").concat(extName);
}
// 服务器存储地址
String rootPath = crmebConfig.getImagePath().trim();
// 服务器存储地址 - 使用绝对路径
String rootPath = crmebConfig.getAbsoluteImagePath();
// 模块
String modelPath = "public/" + model + "/";
// 类型
@ -356,8 +356,8 @@ public class UploadServiceImpl implements UploadService {
fileName = StrUtil.subPre(fileName, 90).concat(".").concat(extName);
}
// 服务器存储地址
String rootPath = crmebConfig.getImagePath().trim();
// 服务器存储地址 - 使用绝对路径
String rootPath = crmebConfig.getAbsoluteImagePath();
String modelPath = "public/" + model + "/";
String type = "video/";
@ -431,8 +431,8 @@ public class UploadServiceImpl implements UploadService {
fileName = StrUtil.subPre(fileName, 90).concat(".").concat(extName);
}
// 服务器存储地址
String rootPath = crmebConfig.getImagePath().trim();
// 服务器存储地址 - 使用绝对路径
String rootPath = crmebConfig.getAbsoluteImagePath();
String modelPath = "public/" + model + "/";
String type = "voice/";

View File

@ -343,7 +343,8 @@ public class UserServiceImpl extends ServiceImpl<UserDao, User> implements UserS
}
/**
* 更新用户金额
* 更新用户金额并发安全版本
* 使用数据库原子操作避免并发竞态条件
*
* @param user 用户
* @param price 金额
@ -352,17 +353,31 @@ public class UserServiceImpl extends ServiceImpl<UserDao, User> implements UserS
*/
@Override
public Boolean updateNowMoney(User user, BigDecimal price, String type) {
LambdaUpdateWrapper<User> lambdaUpdateWrapper = Wrappers.lambdaUpdate();
// 🔒 使用 UpdateWrapper 直接在 SQL 层面进行原子操作
// 这样可以避免并发时的竞态条件
// 原理UPDATE eb_user SET now_money = now_money +/- price WHERE uid = ?
UpdateWrapper<User> updateWrapper = new UpdateWrapper<>();
if (type.equals("add")) {
lambdaUpdateWrapper.set(User::getNowMoney, user.getNowMoney().add(price));
// SQL: UPDATE eb_user SET now_money = now_money + price WHERE uid = ?
updateWrapper.setSql(StrUtil.format("now_money = now_money + {}", price));
} else {
lambdaUpdateWrapper.set(User::getNowMoney, user.getNowMoney().subtract(price));
// SQL: UPDATE eb_user SET now_money = now_money - price WHERE uid = ? AND now_money - price >= 0
updateWrapper.setSql(StrUtil.format("now_money = now_money - {}", price));
// 确保余额不会变成负数 SQL 层面检查
updateWrapper.last(StrUtil.format(" AND (now_money - {} >= 0)", price));
}
lambdaUpdateWrapper.eq(User::getUid, user.getUid());
if (type.equals("sub")) {
lambdaUpdateWrapper.apply(StrUtil.format(" now_money - {} >= 0", price));
updateWrapper.eq("uid", user.getUid());
boolean result = update(updateWrapper);
// 如果是扣减操作且更新失败说明余额不足
if (!result && type.equals("sub")) {
logger.warn("⚠️ 用户 {} 余额不足,无法扣除 {} 元", user.getUid(), price);
}
return update(lambdaUpdateWrapper);
return result;
}
/**
@ -576,6 +591,12 @@ public class UserServiceImpl extends ServiceImpl<UserDao, User> implements UserS
BeanUtils.copyProperties(currentUser, userCenterResponse);
// 设置用户ID
userCenterResponse.setUid(currentUser.getUid());
// 手动映射 mark -> bio个人签名
if (currentUser.getMark() != null) {
userCenterResponse.setBio(currentUser.getMark());
}
// 优惠券数量
userCenterResponse.setCouponCount(storeCouponUserService.getUseCount(currentUser.getUid()));
// 收藏数量

File diff suppressed because one or more lines are too long

View File

@ -1,13 +0,0 @@
CREATE TABLE IF NOT EXISTS `eb_live_room` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`uid` int(11) NOT NULL,
`title` varchar(255) NOT NULL,
`streamer_name` varchar(255) NOT NULL,
`stream_key` varchar(64) NOT NULL,
`is_live` tinyint(1) NOT NULL DEFAULT 0,
`create_time` datetime DEFAULT NULL,
`started_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_stream_key` (`stream_key`),
KEY `idx_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -1,57 +0,0 @@
-- 好友关系表
CREATE TABLE IF NOT EXISTS `eb_friend` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL COMMENT '用户ID',
`friend_id` int(11) NOT NULL COMMENT '好友ID',
`status` tinyint(1) NOT NULL DEFAULT 1 COMMENT '状态: 1=正常, 0=已删除',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_friend` (`user_id`, `friend_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_friend_id` (`friend_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友关系表';
-- 好友请求表
CREATE TABLE IF NOT EXISTS `eb_friend_request` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`from_user_id` int(11) NOT NULL COMMENT '发送者用户ID',
`to_user_id` int(11) NOT NULL COMMENT '接收者用户ID',
`message` varchar(255) DEFAULT '' COMMENT '请求消息',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态: 0=待处理, 1=已接受, 2=已拒绝',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_from_user` (`from_user_id`),
KEY `idx_to_user` (`to_user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友请求表';
-- 私聊会话表
CREATE TABLE IF NOT EXISTS `eb_conversation` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user1_id` int(11) NOT NULL COMMENT '用户1 ID (较小的ID)',
`user2_id` int(11) NOT NULL COMMENT '用户2 ID (较大的ID)',
`last_message_id` int(11) DEFAULT NULL COMMENT '最后一条消息ID',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_users` (`user1_id`, `user2_id`),
KEY `idx_user1` (`user1_id`),
KEY `idx_user2` (`user2_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊会话表';
-- 私聊消息表
CREATE TABLE IF NOT EXISTS `eb_conversation_message` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`conversation_id` int(11) NOT NULL COMMENT '会话ID',
`sender_id` int(11) NOT NULL COMMENT '发送者ID',
`content` text NOT NULL COMMENT '消息内容',
`msg_type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '消息类型: 1=文本, 2=图片, 3=语音',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态: 0=未读, 1=已读',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_conversation` (`conversation_id`),
KEY `idx_sender` (`sender_id`),
KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊消息表';

View File

@ -1,6 +0,0 @@
-- 创建 zhibo 数据库
CREATE DATABASE IF NOT EXISTS `zhibo` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
-- 使用 zhibo 数据库
USE `zhibo`;

File diff suppressed because one or more lines are too long

View File

@ -103,6 +103,9 @@ dependencies {
implementation("com.github.bumptech.glide:glide:4.16.0")
annotationProcessor("com.github.bumptech.glide:compiler:4.16.0")
// SVG支持
implementation("com.caverock:androidsvg-aar:1.4")
implementation("de.hdodenhof:circleimageview:3.1.0")
// FlexboxLayout for message reactions

View File

@ -0,0 +1,35 @@
package com.example.livestreaming;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.viewpager2.adapter.FragmentStateAdapter;
/**
* 余额页面的ViewPager适配器
* 包含充值记录和消费记录两个Tab
*/
public class BalancePagerAdapter extends FragmentStateAdapter {
public BalancePagerAdapter(@NonNull FragmentActivity fragmentActivity) {
super(fragmentActivity);
}
@NonNull
@Override
public Fragment createFragment(int position) {
switch (position) {
case 0:
return new RechargeRecordFragment();
case 1:
return new ConsumeRecordFragment();
default:
return new RechargeRecordFragment();
}
}
@Override
public int getItemCount() {
return 2; // 充值记录 + 消费记录
}
}

View File

@ -0,0 +1,43 @@
package com.example.livestreaming;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
/**
* 消费记录Fragment
*/
public class ConsumeRecordFragment extends Fragment {
private RecyclerView recyclerView;
private TextView tvEmpty;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_record_list, container, false);
recyclerView = view.findViewById(R.id.recycler_view);
tvEmpty = view.findViewById(R.id.tv_empty);
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
// TODO: 加载消费记录数据
showEmpty();
return view;
}
private void showEmpty() {
recyclerView.setVisibility(View.GONE);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("暂无消费记录");
}
}

View File

@ -28,6 +28,16 @@ public class Gift {
this.level = level;
}
// 新增构造函数 - 支持URL
public Gift(String id, String name, int price, String iconUrl, int level) {
this.id = id;
this.name = name;
this.price = price;
this.iconUrl = iconUrl;
this.iconResId = R.drawable.ic_gift_24; // 默认占位图
this.level = level;
}
public String getId() {
return id;
}
@ -90,4 +100,11 @@ public class Gift {
public String getFormattedPrice() {
return price + " 金币";
}
/**
* 判断是否有图标URL
*/
public boolean hasIconUrl() {
return iconUrl != null && !iconUrl.isEmpty();
}
}

View File

@ -1,5 +1,6 @@
package com.example.livestreaming;
import android.graphics.drawable.PictureDrawable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -9,11 +10,17 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.bumptech.glide.RequestBuilder;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.example.livestreaming.glide.SvgSoftwareLayerSetter;
import java.util.ArrayList;
import java.util.List;
/**
* 礼物列表适配器
* 支持PNGJPG和SVG格式图标
*/
public class GiftAdapter extends RecyclerView.Adapter<GiftAdapter.GiftViewHolder> {
@ -77,7 +84,23 @@ public class GiftAdapter extends RecyclerView.Adapter<GiftAdapter.GiftViewHolder
public void bind(Gift gift) {
giftName.setText(gift.getName());
giftPrice.setText(gift.getFormattedPrice());
if (gift.hasIconUrl()) {
String iconUrl = gift.getIconUrl();
android.util.Log.d("GiftAdapter", "加载图标: " + gift.getName() + ", URL: " + iconUrl);
// 检查是否是SVG格式
if (iconUrl.toLowerCase().endsWith(".svg") || iconUrl.toLowerCase().contains(".svg?")) {
// 加载SVG格式
loadSvgImage(iconUrl, gift.getName());
} else {
// 加载PNG/JPG等格式
loadRegularImage(iconUrl, gift.getName());
}
} else {
android.util.Log.w("GiftAdapter", "没有iconUrl: " + gift.getName());
giftIcon.setImageResource(gift.getIconResId());
}
// 选中状态
boolean isSelected = selectedGift != null && selectedGift.getId().equals(gift.getId());
@ -93,5 +116,69 @@ public class GiftAdapter extends RecyclerView.Adapter<GiftAdapter.GiftViewHolder
}
});
}
/**
* 加载SVG格式图片
*/
private void loadSvgImage(String iconUrl, String giftName) {
RequestBuilder<PictureDrawable> requestBuilder = Glide.with(itemView.getContext())
.as(PictureDrawable.class)
.listener(new com.bumptech.glide.request.RequestListener<PictureDrawable>() {
@Override
public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
com.bumptech.glide.request.target.Target<PictureDrawable> target,
boolean isFirstResource) {
android.util.Log.e("GiftAdapter", "SVG加载失败: " + giftName + ", URL: " + iconUrl, e);
// 加载失败时显示默认图标
giftIcon.setImageResource(R.drawable.ic_gift_24);
return true;
}
@Override
public boolean onResourceReady(PictureDrawable resource,
Object model,
com.bumptech.glide.request.target.Target<PictureDrawable> target,
com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
android.util.Log.d("GiftAdapter", "SVG加载成功: " + giftName);
return false;
}
});
requestBuilder.load(iconUrl).into(new SvgSoftwareLayerSetter(giftIcon));
}
/**
* 加载常规格式图片PNG/JPG等
*/
private void loadRegularImage(String iconUrl, String giftName) {
Glide.with(itemView.getContext())
.load(iconUrl)
.placeholder(R.drawable.ic_gift_24)
.error(R.drawable.ic_gift_24)
.diskCacheStrategy(DiskCacheStrategy.ALL)
.listener(new com.bumptech.glide.request.RequestListener<android.graphics.drawable.Drawable>() {
@Override
public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.Drawable> target,
boolean isFirstResource) {
android.util.Log.e("GiftAdapter", "图片加载失败: " + giftName + ", URL: " + iconUrl, e);
return false;
}
@Override
public boolean onResourceReady(android.graphics.drawable.Drawable resource,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.Drawable> target,
com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
android.util.Log.d("GiftAdapter", "图片加载成功: " + giftName);
return false;
}
})
.into(giftIcon);
}
}
}

View File

@ -0,0 +1,625 @@
package com.example.livestreaming;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Color;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.AccelerateDecelerateInterpolator;
import android.view.animation.DecelerateInterpolator;
import android.view.animation.OvershootInterpolator;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.TextView;
import java.util.LinkedList;
import java.util.Queue;
import java.util.Random;
/**
* 礼物动画管理器
* 负责显示不同等级的礼物特效
*/
public class GiftAnimationManager {
private final Context context;
private final View rootView;
private final Handler handler;
private final Queue<GiftAnimationData> animationQueue;
private boolean isAnimating = false;
private final Random random = new Random();
// 礼物等级定义
private static final int LEVEL_BASIC = 1; // 0-20金币
private static final int LEVEL_NORMAL = 2; // 21-100金币
private static final int LEVEL_ADVANCED = 3; // 101-1000金币
private static final int LEVEL_PREMIUM = 4; // 1001-3000金币
private static final int LEVEL_LEGENDARY = 5; // 3000+金币
public GiftAnimationManager(Context context, View rootView) {
this.context = context;
this.rootView = rootView;
this.handler = new Handler(Looper.getMainLooper());
this.animationQueue = new LinkedList<>();
}
/**
* 显示礼物动画
*/
public void showGiftAnimation(String giftName, int giftPrice, int count,
String senderName, String giftIconUrl) {
showGiftAnimation(giftName, giftPrice, count, senderName, giftIconUrl, null);
}
/**
* 显示礼物动画带头像
*/
public void showGiftAnimation(String giftName, int giftPrice, int count,
String senderName, String giftIconUrl, String senderAvatarUrl) {
GiftAnimationData data = new GiftAnimationData(
giftName, giftPrice, count, senderName, giftIconUrl, senderAvatarUrl
);
animationQueue.offer(data);
if (!isAnimating) {
processNextAnimation();
}
}
/**
* 处理下一个动画
*/
private void processNextAnimation() {
if (animationQueue.isEmpty()) {
isAnimating = false;
return;
}
isAnimating = true;
GiftAnimationData data = animationQueue.poll();
int level = getGiftLevel(data.giftPrice);
switch (level) {
case LEVEL_BASIC:
playBasicAnimation(data);
break;
case LEVEL_NORMAL:
playNormalAnimation(data);
break;
case LEVEL_ADVANCED:
playAdvancedAnimation(data);
break;
case LEVEL_PREMIUM:
playPremiumAnimation(data);
break;
case LEVEL_LEGENDARY:
playLegendaryAnimation(data);
break;
}
}
/**
* 获取礼物等级
*/
private int getGiftLevel(int price) {
if (price <= 20) return LEVEL_BASIC;
if (price <= 100) return LEVEL_NORMAL;
if (price <= 1000) return LEVEL_ADVANCED;
if (price <= 3000) return LEVEL_PREMIUM;
return LEVEL_LEGENDARY;
}
/**
* 基础动画 (0-20金币) - 简单滑入滑出
*/
private void playBasicAnimation(GiftAnimationData data) {
View effectLayout = rootView.findViewById(R.id.giftEffectLayout);
View giftInfoCard = rootView.findViewById(R.id.giftInfoCard);
setupGiftInfo(data);
effectLayout.setVisibility(View.VISIBLE);
effectLayout.setAlpha(0f);
effectLayout.setTranslationX(-300f);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(effectLayout, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(effectLayout, "translationX", -300f, 0f)
);
animSet.setDuration(400);
animSet.setInterpolator(new DecelerateInterpolator());
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
handler.postDelayed(() -> hideAnimation(effectLayout), 2000);
}
});
animSet.start();
}
/**
* 普通动画 (21-100金币) - 滑入+缩放+粒子
*/
private void playNormalAnimation(GiftAnimationData data) {
View effectLayout = rootView.findViewById(R.id.giftEffectLayout);
ImageView giftIcon = rootView.findViewById(R.id.giftIcon);
setupGiftInfo(data);
effectLayout.setVisibility(View.VISIBLE);
effectLayout.setAlpha(0f);
effectLayout.setTranslationX(-300f);
giftIcon.setScaleX(0.5f);
giftIcon.setScaleY(0.5f);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(effectLayout, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(effectLayout, "translationX", -300f, 0f),
ObjectAnimator.ofFloat(giftIcon, "scaleX", 0.5f, 1.2f, 1f),
ObjectAnimator.ofFloat(giftIcon, "scaleY", 0.5f, 1.2f, 1f)
);
animSet.setDuration(500);
animSet.setInterpolator(new OvershootInterpolator());
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
createSimpleParticles();
handler.postDelayed(() -> hideAnimation(effectLayout), 2500);
}
});
animSet.start();
}
/**
* 高级动画 (101-1000金币) - 华丽滑入+旋转+大量粒子
*/
private void playAdvancedAnimation(GiftAnimationData data) {
View effectLayout = rootView.findViewById(R.id.giftEffectLayout);
ImageView giftIcon = rootView.findViewById(R.id.giftIcon);
View giftInfoCard = rootView.findViewById(R.id.giftInfoCard);
setupGiftInfo(data);
effectLayout.setVisibility(View.VISIBLE);
effectLayout.setAlpha(0f);
effectLayout.setTranslationX(-400f);
giftIcon.setScaleX(0.3f);
giftIcon.setScaleY(0.3f);
giftIcon.setRotation(-180f);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(effectLayout, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(effectLayout, "translationX", -400f, 0f),
ObjectAnimator.ofFloat(giftIcon, "scaleX", 0.3f, 1.3f, 1f),
ObjectAnimator.ofFloat(giftIcon, "scaleY", 0.3f, 1.3f, 1f),
ObjectAnimator.ofFloat(giftIcon, "rotation", -180f, 0f)
);
animSet.setDuration(600);
animSet.setInterpolator(new OvershootInterpolator());
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
createAdvancedParticles();
pulseAnimation(giftIcon);
handler.postDelayed(() -> hideAnimation(effectLayout), 3000);
}
});
animSet.start();
}
/**
* 豪华动画 (1001-3000金币) - 震撼登场+闪光+爆炸粒子
*/
private void playPremiumAnimation(GiftAnimationData data) {
View effectLayout = rootView.findViewById(R.id.giftEffectLayout);
ImageView giftIcon = rootView.findViewById(R.id.giftIcon);
View giftInfoCard = rootView.findViewById(R.id.giftInfoCard);
FrameLayout fullscreenEffect = rootView.findViewById(R.id.fullscreenEffectContainer);
setupGiftInfo(data);
// 显示全屏闪光效果
fullscreenEffect.setVisibility(View.VISIBLE);
fullscreenEffect.setBackgroundColor(Color.argb(100, 255, 215, 0));
effectLayout.setVisibility(View.VISIBLE);
effectLayout.setAlpha(0f);
effectLayout.setScaleX(0.5f);
effectLayout.setScaleY(0.5f);
giftIcon.setRotation(-360f);
// 闪光动画
ObjectAnimator flashAnim = ObjectAnimator.ofFloat(fullscreenEffect, "alpha", 0.8f, 0f);
flashAnim.setDuration(500);
flashAnim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
fullscreenEffect.setVisibility(View.GONE);
}
});
flashAnim.start();
// 主动画
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(effectLayout, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(effectLayout, "scaleX", 0.5f, 1.2f, 1f),
ObjectAnimator.ofFloat(effectLayout, "scaleY", 0.5f, 1.2f, 1f),
ObjectAnimator.ofFloat(giftIcon, "rotation", -360f, 0f)
);
animSet.setDuration(700);
animSet.setInterpolator(new OvershootInterpolator(1.5f));
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
createPremiumParticles();
continuousPulse(giftIcon);
handler.postDelayed(() -> hideAnimation(effectLayout), 3500);
}
});
animSet.start();
}
/**
* 传说动画 (3000+金币) - 超级震撼全屏特效
*/
private void playLegendaryAnimation(GiftAnimationData data) {
View effectLayout = rootView.findViewById(R.id.giftEffectLayout);
ImageView giftIcon = rootView.findViewById(R.id.giftIcon);
View giftInfoCard = rootView.findViewById(R.id.giftInfoCard);
FrameLayout fullscreenEffect = rootView.findViewById(R.id.fullscreenEffectContainer);
setupGiftInfo(data);
// 全屏彩虹渐变效果
fullscreenEffect.setVisibility(View.VISIBLE);
ValueAnimator colorAnim = ValueAnimator.ofArgb(
Color.argb(150, 255, 0, 0),
Color.argb(150, 255, 165, 0),
Color.argb(150, 255, 255, 0),
Color.argb(150, 0, 255, 0),
Color.argb(150, 0, 0, 255),
Color.argb(150, 139, 0, 255)
);
colorAnim.setDuration(1000);
colorAnim.addUpdateListener(animation -> {
fullscreenEffect.setBackgroundColor((int) animation.getAnimatedValue());
});
colorAnim.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
ObjectAnimator fadeOut = ObjectAnimator.ofFloat(fullscreenEffect, "alpha", 1f, 0f);
fadeOut.setDuration(500);
fadeOut.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
fullscreenEffect.setVisibility(View.GONE);
}
});
fadeOut.start();
}
});
colorAnim.start();
effectLayout.setVisibility(View.VISIBLE);
effectLayout.setAlpha(0f);
effectLayout.setScaleX(0.3f);
effectLayout.setScaleY(0.3f);
effectLayout.setRotation(-180f);
giftIcon.setRotation(-720f);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(effectLayout, "alpha", 0f, 1f),
ObjectAnimator.ofFloat(effectLayout, "scaleX", 0.3f, 1.3f, 1f),
ObjectAnimator.ofFloat(effectLayout, "scaleY", 0.3f, 1.3f, 1f),
ObjectAnimator.ofFloat(effectLayout, "rotation", -180f, 0f),
ObjectAnimator.ofFloat(giftIcon, "rotation", -720f, 0f)
);
animSet.setDuration(1000);
animSet.setInterpolator(new OvershootInterpolator(2f));
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
createLegendaryParticles();
explosivePulse(giftIcon);
handler.postDelayed(() -> hideAnimation(effectLayout), 4000);
}
});
animSet.start();
}
/**
* 设置礼物信息
*/
private void setupGiftInfo(GiftAnimationData data) {
TextView senderName = rootView.findViewById(R.id.senderName);
TextView giftName = rootView.findViewById(R.id.giftName);
ImageView giftIcon = rootView.findViewById(R.id.giftIcon);
ImageView senderAvatar = rootView.findViewById(R.id.senderAvatar);
TextView comboCount = rootView.findViewById(R.id.comboCount);
senderName.setText(data.senderName);
giftName.setText(data.giftName);
// 加载礼物图标优先使用网络图片
if (data.giftIconUrl != null && !data.giftIconUrl.isEmpty()) {
// 使用Glide加载网络图片
com.bumptech.glide.Glide.with(context)
.load(data.giftIconUrl)
.placeholder(getGiftIconResource(data.giftName))
.error(getGiftIconResource(data.giftName))
.into(giftIcon);
} else {
// 使用本地资源
giftIcon.setImageResource(getGiftIconResource(data.giftName));
}
// 加载用户头像如果有的话
if (data.senderAvatarUrl != null && !data.senderAvatarUrl.isEmpty()) {
com.bumptech.glide.Glide.with(context)
.load(data.senderAvatarUrl)
.placeholder(R.drawable.ic_user_24)
.error(R.drawable.ic_user_24)
.circleCrop()
.into(senderAvatar);
} else {
senderAvatar.setImageResource(R.drawable.ic_user_24);
}
if (data.count > 1) {
comboCount.setVisibility(View.VISIBLE);
comboCount.setText("x" + data.count);
} else {
comboCount.setVisibility(View.GONE);
}
}
/**
* 获取礼物图标资源
*/
private int getGiftIconResource(String giftName) {
// 根据礼物名称返回对应的图标资源
// 这里简化处理实际应该从网络加载
switch (giftName) {
case "小心心": return R.drawable.ic_gift_heart;
case "玫瑰花": return R.drawable.ic_gift_rose;
case "皇冠": return R.drawable.ic_gift_crown;
case "火箭": return R.drawable.ic_gift_rocket;
case "跑车": return R.drawable.ic_gift_car;
case "游艇": return R.drawable.ic_gift_yacht;
case "城堡": return R.drawable.ic_gift_castle;
default: return R.drawable.ic_gift_24;
}
}
/**
* 隐藏动画
*/
private void hideAnimation(View effectLayout) {
ObjectAnimator fadeOut = ObjectAnimator.ofFloat(effectLayout, "alpha", 1f, 0f);
fadeOut.setDuration(300);
fadeOut.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
effectLayout.setVisibility(View.GONE);
processNextAnimation();
}
});
fadeOut.start();
}
/**
* 创建简单粒子效果
*/
private void createSimpleParticles() {
FrameLayout particleContainer = rootView.findViewById(R.id.particleContainer);
for (int i = 0; i < 10; i++) {
createParticle(particleContainer, Color.argb(200, 255, 215, 0), 20);
}
}
/**
* 创建高级粒子效果
*/
private void createAdvancedParticles() {
FrameLayout particleContainer = rootView.findViewById(R.id.particleContainer);
int[] colors = {
Color.argb(200, 255, 215, 0),
Color.argb(200, 255, 140, 0),
Color.argb(200, 255, 69, 0)
};
for (int i = 0; i < 30; i++) {
int color = colors[random.nextInt(colors.length)];
createParticle(particleContainer, color, 30);
}
}
/**
* 创建豪华粒子效果
*/
private void createPremiumParticles() {
FrameLayout particleContainer = rootView.findViewById(R.id.particleContainer);
int[] colors = {
Color.argb(220, 255, 215, 0),
Color.argb(220, 255, 0, 0),
Color.argb(220, 255, 105, 180),
Color.argb(220, 138, 43, 226)
};
for (int i = 0; i < 50; i++) {
int color = colors[random.nextInt(colors.length)];
createParticle(particleContainer, color, 40);
}
}
/**
* 创建传说粒子效果
*/
private void createLegendaryParticles() {
FrameLayout particleContainer = rootView.findViewById(R.id.particleContainer);
int[] colors = {
Color.argb(255, 255, 0, 0),
Color.argb(255, 255, 165, 0),
Color.argb(255, 255, 255, 0),
Color.argb(255, 0, 255, 0),
Color.argb(255, 0, 0, 255),
Color.argb(255, 139, 0, 255)
};
for (int i = 0; i < 80; i++) {
int color = colors[random.nextInt(colors.length)];
createParticle(particleContainer, color, 50);
}
}
/**
* 创建单个粒子
*/
private void createParticle(FrameLayout container, int color, int maxSize) {
View particle = new View(context);
int size = random.nextInt(maxSize - 10) + 10;
FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(size, size);
particle.setLayoutParams(params);
particle.setBackgroundColor(color);
// 随机起始位置
int startX = random.nextInt(container.getWidth());
int startY = container.getHeight() / 2;
particle.setX(startX);
particle.setY(startY);
container.addView(particle);
// 随机目标位置
float targetX = startX + (random.nextFloat() - 0.5f) * 400;
float targetY = startY - random.nextInt(300) - 100;
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(particle, "x", startX, targetX),
ObjectAnimator.ofFloat(particle, "y", startY, targetY),
ObjectAnimator.ofFloat(particle, "alpha", 1f, 0f),
ObjectAnimator.ofFloat(particle, "rotation", 0f, random.nextInt(720) - 360)
);
animSet.setDuration(random.nextInt(1000) + 1000);
animSet.setInterpolator(new DecelerateInterpolator());
animSet.addListener(new AnimatorListenerAdapter() {
@Override
public void onAnimationEnd(Animator animation) {
container.removeView(particle);
}
});
animSet.start();
}
/**
* 脉冲动画
*/
private void pulseAnimation(View view) {
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(
ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.2f, 1f),
ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.2f, 1f)
);
animSet.setDuration(500);
animSet.start();
}
/**
* 持续脉冲动画
*/
private void continuousPulse(View view) {
ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.15f, 1f);
ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.15f, 1f);
scaleX.setDuration(400);
scaleX.setRepeatCount(3);
scaleY.setDuration(400);
scaleY.setRepeatCount(3);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(scaleX, scaleY);
animSet.start();
}
/**
* 爆炸式脉冲动画
*/
private void explosivePulse(View view) {
ObjectAnimator scaleX = ObjectAnimator.ofFloat(view, "scaleX", 1f, 1.3f, 1f);
ObjectAnimator scaleY = ObjectAnimator.ofFloat(view, "scaleY", 1f, 1.3f, 1f);
ObjectAnimator rotation = ObjectAnimator.ofFloat(view, "rotation", 0f, 360f);
scaleX.setDuration(600);
scaleX.setRepeatCount(2);
scaleY.setDuration(600);
scaleY.setRepeatCount(2);
rotation.setDuration(600);
rotation.setRepeatCount(2);
AnimatorSet animSet = new AnimatorSet();
animSet.playTogether(scaleX, scaleY, rotation);
animSet.setInterpolator(new AccelerateDecelerateInterpolator());
animSet.start();
}
/**
* 礼物动画数据类
*/
private static class GiftAnimationData {
String giftName;
int giftPrice;
int count;
String senderName;
String giftIconUrl;
String senderAvatarUrl;
GiftAnimationData(String giftName, int giftPrice, int count,
String senderName, String giftIconUrl) {
this.giftName = giftName;
this.giftPrice = giftPrice;
this.count = count;
this.senderName = senderName;
this.giftIconUrl = giftIconUrl;
this.senderAvatarUrl = null;
}
GiftAnimationData(String giftName, int giftPrice, int count,
String senderName, String giftIconUrl, String senderAvatarUrl) {
this.giftName = giftName;
this.giftPrice = giftPrice;
this.count = count;
this.senderName = senderName;
this.giftIconUrl = giftIconUrl;
this.senderAvatarUrl = senderAvatarUrl;
}
}
}

View File

@ -0,0 +1,126 @@
package com.example.livestreaming;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.cardview.widget.CardView;
import androidx.recyclerview.widget.RecyclerView;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 充值套餐适配器
*/
public class RechargePackageAdapter extends RecyclerView.Adapter<RechargePackageAdapter.ViewHolder> {
private List<Map<String, Object>> packages;
private int selectedPosition = -1;
private OnItemClickListener listener;
public interface OnItemClickListener {
void onItemClick(Integer packageId);
}
public RechargePackageAdapter(List<Map<String, Object>> packages) {
this.packages = packages != null ? packages : new ArrayList<>();
}
public void setOnItemClickListener(OnItemClickListener listener) {
this.listener = listener;
}
public void updateData(List<Map<String, Object>> newPackages) {
this.packages = newPackages != null ? newPackages : new ArrayList<>();
notifyDataSetChanged();
}
@NonNull
@Override
public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_recharge_package, parent, false);
return new ViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ViewHolder holder, int position) {
Map<String, Object> pkg = packages.get(position);
// 获取套餐信息
Integer id = (Integer) pkg.get("id");
String name = (String) pkg.get("name");
Object amountObj = pkg.get("amount");
Object priceObj = pkg.get("price");
String label = (String) pkg.get("label");
double amount = amountObj instanceof Number ? ((Number) amountObj).doubleValue() : 0;
double price = priceObj instanceof Number ? ((Number) priceObj).doubleValue() : 0;
// 设置数据
holder.tvAmount.setText(String.format("%.0f", amount));
holder.tvPrice.setText(String.format("¥%.2f", price));
if (label != null && !label.isEmpty()) {
holder.tvLabel.setVisibility(View.VISIBLE);
holder.tvLabel.setText(label);
} else {
holder.tvLabel.setVisibility(View.GONE);
}
// 设置选中状态
boolean isSelected = position == selectedPosition;
holder.cardView.setCardBackgroundColor(
holder.itemView.getContext().getResources().getColor(
isSelected ? R.color.colorPrimary : android.R.color.white
)
);
holder.tvAmount.setTextColor(
holder.itemView.getContext().getResources().getColor(
isSelected ? android.R.color.white : R.color.colorPrimary
)
);
holder.tvPrice.setTextColor(
holder.itemView.getContext().getResources().getColor(
isSelected ? android.R.color.white : R.color.text_secondary
)
);
// 点击事件
holder.itemView.setOnClickListener(v -> {
int oldPosition = selectedPosition;
selectedPosition = holder.getAdapterPosition();
if (oldPosition != -1) {
notifyItemChanged(oldPosition);
}
notifyItemChanged(selectedPosition);
if (listener != null && id != null) {
listener.onItemClick(id);
}
});
}
@Override
public int getItemCount() {
return packages.size();
}
static class ViewHolder extends RecyclerView.ViewHolder {
CardView cardView;
TextView tvAmount;
TextView tvPrice;
TextView tvLabel;
ViewHolder(@NonNull View itemView) {
super(itemView);
cardView = (CardView) itemView;
tvAmount = itemView.findViewById(R.id.tv_amount);
tvPrice = itemView.findViewById(R.id.tv_price);
tvLabel = itemView.findViewById(R.id.tv_label);
}
}
}

View File

@ -0,0 +1,43 @@
package com.example.livestreaming;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
/**
* 充值记录Fragment
*/
public class RechargeRecordFragment extends Fragment {
private RecyclerView recyclerView;
private TextView tvEmpty;
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
View view = inflater.inflate(R.layout.fragment_record_list, container, false);
recyclerView = view.findViewById(R.id.recycler_view);
tvEmpty = view.findViewById(R.id.tv_empty);
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
// TODO: 加载充值记录数据
showEmpty();
return view;
}
private void showEmpty() {
recyclerView.setVisibility(View.GONE);
tvEmpty.setVisibility(View.VISIBLE);
tvEmpty.setText("暂无充值记录");
}
}

View File

@ -18,10 +18,12 @@ import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.OptIn;
import androidx.appcompat.app.AppCompatActivity;
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.GridLayoutManager;
@ -151,6 +153,7 @@ public class RoomDetailActivity extends AppCompatActivity {
private GiftAdapter giftAdapter;
private List<Gift> availableGifts;
private int userCoinBalance = 0; // 用户金币余额从后端加载
private GiftAnimationManager giftAnimationManager; // 礼物动画管理器
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
@ -180,6 +183,20 @@ public class RoomDetailActivity extends AppCompatActivity {
setupChat();
setupGifts();
// 初始化礼物动画管理器
android.util.Log.d("RoomDetail", "=== 初始化礼物动画管理器 ===");
// 从binding的根视图中查找
View giftAnimationOverlay = binding.getRoot().findViewById(R.id.giftAnimationOverlay);
android.util.Log.d("RoomDetail", "giftAnimationOverlay = " + giftAnimationOverlay);
if (giftAnimationOverlay != null) {
giftAnimationManager = new GiftAnimationManager(this, giftAnimationOverlay);
android.util.Log.d("RoomDetail", "✅ 礼物动画管理器初始化成功");
} else {
android.util.Log.e("RoomDetail", "❌ 找不到giftAnimationOverlay布局");
}
// 加载房间信息
loadRoomInfo();
@ -452,11 +469,14 @@ public class RoomDetailActivity extends AppCompatActivity {
// 停止之前的重连任务
stopChatReconnect();
String wsUrl = getWsChatBaseUrl() + roomId;
android.util.Log.d("ChatWebSocket", "准备连接WebSocket: " + wsUrl);
chatWsClient = new OkHttpClient.Builder()
.pingInterval(30, java.util.concurrent.TimeUnit.SECONDS) // OkHttp 内置 ping
.build();
Request request = new Request.Builder()
.url(getWsChatBaseUrl() + roomId)
.url(wsUrl)
.build();
chatWebSocket = chatWsClient.newWebSocket(request, new WebSocketListener() {
@ -472,9 +492,11 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
public void onMessage(WebSocket webSocket, String text) {
// 收到消息解析并显示
android.util.Log.d("ChatWebSocket", "收到消息: " + text);
try {
JSONObject json = new JSONObject(text);
String type = json.optString("type", "");
android.util.Log.d("ChatWebSocket", "消息类型: " + type);
if ("chat".equals(type)) {
String nickname = json.optString("nickname", "匿名");
@ -490,11 +512,14 @@ public class RoomDetailActivity extends AppCompatActivity {
String senderNickname = json.optString("senderNickname", "匿名");
int totalPrice = json.optInt("totalPrice", 0);
android.util.Log.d("WebSocket", "收到礼物消息: " + json.toString());
handler.post(() -> {
String giftMsg = senderNickname + " 送出了 " + count + "" + giftName;
addChatMessage(new ChatMessage(giftMsg, true)); // 系统消息样式
// TODO: 显示礼物动画效果
// 显示礼物动画效果
android.util.Log.d("WebSocket", "准备显示礼物动画: " + giftName);
showGiftAnimation(giftName, count, senderNickname);
});
} else if ("gift_combo".equals(type)) {
@ -1183,7 +1208,7 @@ public class RoomDetailActivity extends AppCompatActivity {
// 防止重复显示连接消息
private boolean hasShownConnectedMessage = false;
private void startHls(String url, @Nullable String altUrl) {
@OptIn(markerClass = UnstableApi.class) private void startHls(String url, @Nullable String altUrl) {
releaseIjkPlayer();
if (binding != null) {
binding.flvTextureView.setVisibility(View.GONE);
@ -1560,6 +1585,8 @@ public class RoomDetailActivity extends AppCompatActivity {
* 从后端加载礼物列表
*/
private void loadGiftsFromBackend() {
android.util.Log.d("RoomDetail", "=== 开始加载礼物列表 ===");
ApiService apiService = ApiClient.getService(getApplicationContext());
Call<ApiResponse<List<GiftResponse>>> call = apiService.getGiftList();
@ -1569,32 +1596,41 @@ public class RoomDetailActivity extends AppCompatActivity {
Response<ApiResponse<List<GiftResponse>>> response) {
if (response.isSuccessful() && response.body() != null) {
ApiResponse<List<GiftResponse>> apiResponse = response.body();
android.util.Log.d("RoomDetail", "礼物列表响应码: " + apiResponse.getCode());
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
availableGifts = new ArrayList<>();
for (GiftResponse giftResponse : apiResponse.getData()) {
// 使用URL构造函数从服务器加载图标
String iconUrl = giftResponse.getIconUrl();
android.util.Log.d("RoomDetail", "礼物: " + giftResponse.getName() +
", 价格: " + giftResponse.getPrice() +
", iconUrl: " + iconUrl);
Gift gift = new Gift(
String.valueOf(giftResponse.getId()),
giftResponse.getName(),
giftResponse.getPrice().intValue(),
R.drawable.ic_gift_rose, // 默认图标实际应从URL加载
iconUrl, // 使用URL而不是硬编码
giftResponse.getLevel() != null ? giftResponse.getLevel() : 1
);
availableGifts.add(gift);
}
android.util.Log.d("RoomDetail", "成功加载 " + availableGifts.size() + " 个礼物");
android.util.Log.d("RoomDetail", "✅ 成功加载 " + availableGifts.size() + " 个礼物");
android.util.Log.d("RoomDetail", "礼物列表: " + getGiftNames());
} else {
android.util.Log.w("RoomDetail", "加载礼物列表失败: " + apiResponse.getMessage());
android.util.Log.w("RoomDetail", "加载礼物列表失败: " + apiResponse.getMessage());
setDefaultGifts();
}
} else {
android.util.Log.w("RoomDetail", "加载礼物列表失败");
android.util.Log.w("RoomDetail", "加载礼物列表失败,响应码: " + response.code());
setDefaultGifts();
}
}
@Override
public void onFailure(Call<ApiResponse<List<GiftResponse>>> call, Throwable t) {
android.util.Log.e("RoomDetail", "加载礼物列表失败: " + t.getMessage());
android.util.Log.e("RoomDetail", "❌ 加载礼物列表网络错误: " + t.getMessage(), t);
setDefaultGifts();
}
});
@ -1608,6 +1644,80 @@ public class RoomDetailActivity extends AppCompatActivity {
// 不再使用模拟数据只从后端接口获取真实礼物数据
}
/**
* 加载礼物图标到ImageView支持PNG和SVG格式
* @param imageView 目标ImageView
* @param gift 礼物对象
*/
private void loadGiftIcon(android.widget.ImageView imageView, Gift gift) {
if (gift.hasIconUrl()) {
String iconUrl = gift.getIconUrl();
android.util.Log.d("RoomDetail", "加载礼物图标: " + gift.getName() + ", URL: " + iconUrl);
// 检查是否是SVG格式
if (iconUrl.toLowerCase().endsWith(".svg") || iconUrl.toLowerCase().contains(".svg?")) {
// 加载SVG格式
com.bumptech.glide.RequestBuilder<android.graphics.drawable.PictureDrawable> requestBuilder =
com.bumptech.glide.Glide.with(this)
.as(android.graphics.drawable.PictureDrawable.class)
.listener(new com.bumptech.glide.request.RequestListener<android.graphics.drawable.PictureDrawable>() {
@Override
public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.PictureDrawable> target,
boolean isFirstResource) {
android.util.Log.e("RoomDetail", "SVG加载失败: " + gift.getName(), e);
imageView.setImageResource(R.drawable.ic_gift_24);
return true;
}
@Override
public boolean onResourceReady(android.graphics.drawable.PictureDrawable resource,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.PictureDrawable> target,
com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
android.util.Log.d("RoomDetail", "SVG加载成功: " + gift.getName());
return false;
}
});
requestBuilder.load(iconUrl).into(new com.example.livestreaming.glide.SvgSoftwareLayerSetter(imageView));
} else {
// 加载PNG/JPG等格式
com.bumptech.glide.Glide.with(this)
.load(iconUrl)
.placeholder(R.drawable.ic_gift_24)
.error(R.drawable.ic_gift_24)
.diskCacheStrategy(com.bumptech.glide.load.engine.DiskCacheStrategy.ALL)
.listener(new com.bumptech.glide.request.RequestListener<android.graphics.drawable.Drawable>() {
@Override
public boolean onLoadFailed(@androidx.annotation.Nullable com.bumptech.glide.load.engine.GlideException e,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.Drawable> target,
boolean isFirstResource) {
android.util.Log.e("RoomDetail", "图片加载失败: " + gift.getName(), e);
return false;
}
@Override
public boolean onResourceReady(android.graphics.drawable.Drawable resource,
Object model,
com.bumptech.glide.request.target.Target<android.graphics.drawable.Drawable> target,
com.bumptech.glide.load.DataSource dataSource,
boolean isFirstResource) {
android.util.Log.d("RoomDetail", "图片加载成功: " + gift.getName());
return false;
}
})
.into(imageView);
}
} else {
// 如果没有URL使用本地资源
imageView.setImageResource(gift.getIconResId());
}
}
/**
* 显示礼物选择弹窗
*/
@ -1651,7 +1761,10 @@ public class RoomDetailActivity extends AppCompatActivity {
// 礼物选择监听
giftAdapter.setOnGiftClickListener(gift -> {
selectedGiftLayout.setVisibility(View.VISIBLE);
selectedGiftIcon.setImageResource(gift.getIconResId());
// 使用辅助方法加载礼物图标
loadGiftIcon(selectedGiftIcon, gift);
selectedGiftName.setText(gift.getName());
selectedGiftPrice.setText(gift.getFormattedPrice());
giftCount[0] = 1;
@ -1997,14 +2110,34 @@ public class RoomDetailActivity extends AppCompatActivity {
return;
}
// 获取主播用户ID
Integer streamerId = room.getStreamerId();
if (streamerId == null) {
streamerId = room.getUid();
}
if (streamerId == null) {
Toast.makeText(this, "无法获取主播信息", Toast.LENGTH_SHORT).show();
android.util.Log.e("RoomDetail", "主播ID为空: roomId=" + roomId + ", room=" + room);
return;
}
// 🔍 详细日志
android.util.Log.d("RoomDetail", "========================================");
android.util.Log.d("RoomDetail", "📤 准备发送礼物请求");
android.util.Log.d("RoomDetail", "roomId: " + roomId);
android.util.Log.d("RoomDetail", "streamerId: " + streamerId);
android.util.Log.d("RoomDetail", "giftId: " + selectedGift.getId());
android.util.Log.d("RoomDetail", "count: " + count);
android.util.Log.d("RoomDetail", "API URL: " + ApiClient.getCurrentBaseUrl(getApplicationContext()) + "api/front/live/rooms/" + roomId + "/gift");
android.util.Log.d("RoomDetail", "========================================");
ApiService apiService = ApiClient.getService(getApplicationContext());
// 获取主播ID使用房间ID作为主播ID或者从房间信息中获取
Integer streamerId = Integer.parseInt(roomId);
SendGiftRequest request = new SendGiftRequest(
Integer.parseInt(selectedGift.getId()),
streamerId,
count
streamerId, // receiverId - 接收者用户ID主播ID
count // giftCount - 礼物数量
);
Call<ApiResponse<SendGiftResponse>> call = apiService.sendRoomGift(roomId, request);
@ -2013,20 +2146,46 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override
public void onResponse(Call<ApiResponse<SendGiftResponse>> call,
Response<ApiResponse<SendGiftResponse>> response) {
android.util.Log.d("RoomDetail", "📥 收到响应: HTTP " + response.code());
if (response.isSuccessful() && response.body() != null) {
ApiResponse<SendGiftResponse> apiResponse = response.body();
if (apiResponse.getCode() == 200 && apiResponse.getData() != null) {
SendGiftResponse giftResponse = apiResponse.getData();
// 更新余额
// 🔒 安全更新余额添加空值检查
if (giftResponse.getNewBalance() != null) {
userCoinBalance = giftResponse.getNewBalance().intValue();
coinBalance.setText(String.valueOf(userCoinBalance));
android.util.Log.d("RoomDetail", "✅ 余额更新成功: " + userCoinBalance);
} else {
android.util.Log.w("RoomDetail", "⚠️ newBalance 为 null尝试重新加载余额");
// 降级处理重新加载余额
loadUserBalance(coinBalance);
}
// 在聊天区显示赠送消息
String giftMessage = String.format("送出了 %d 个 %s", count, selectedGift.getName());
addChatMessage(new ChatMessage("", giftMessage, true));
Toast.makeText(RoomDetailActivity.this, "赠送成功!", Toast.LENGTH_SHORT).show();
// 立即显示礼物特效动画
String currentUserNickname = ""; // 可以从用户信息中获取
String currentUserAvatar = AuthStore.getAvatar(RoomDetailActivity.this); // 获取当前用户头像
android.util.Log.d("RoomDetail", "🎁 礼物发送成功,显示特效动画");
android.util.Log.d("RoomDetail", "礼物图标URL: " + selectedGift.getIconUrl());
android.util.Log.d("RoomDetail", "用户头像URL: " + currentUserAvatar);
// 使用带头像的方法
if (giftAnimationManager != null) {
giftAnimationManager.showGiftAnimation(
selectedGift.getName(),
selectedGift.getPrice(),
count,
currentUserNickname,
selectedGift.getIconUrl(),
currentUserAvatar
);
}
if (giftDialog != null) {
giftDialog.dismiss();
@ -2039,14 +2198,17 @@ public class RoomDetailActivity extends AppCompatActivity {
selectedGiftLayout.setVisibility(View.GONE);
giftCountText.setText("1");
} else {
String errorMsg = apiResponse.getMessage() != null ? apiResponse.getMessage() : "未知错误";
Toast.makeText(RoomDetailActivity.this,
"赠送失败: " + apiResponse.getMessage(),
"赠送失败: " + errorMsg,
Toast.LENGTH_SHORT).show();
android.util.Log.e("RoomDetail", "赠送礼物失败: " + errorMsg);
}
} else {
Toast.makeText(RoomDetailActivity.this,
"赠送失败",
"赠送失败: HTTP " + response.code(),
Toast.LENGTH_SHORT).show();
android.util.Log.e("RoomDetail", "赠送礼物HTTP错误: " + response.code());
}
}
@ -2055,6 +2217,7 @@ public class RoomDetailActivity extends AppCompatActivity {
Toast.makeText(RoomDetailActivity.this,
"网络错误: " + t.getMessage(),
Toast.LENGTH_SHORT).show();
android.util.Log.e("RoomDetail", "赠送礼物网络错误", t);
}
});
}
@ -2330,21 +2493,93 @@ public class RoomDetailActivity extends AppCompatActivity {
* @param senderNickname 赠送者昵称
*/
private void showGiftAnimation(String giftName, int count, String senderNickname) {
// TODO: 实现礼物动画效果
// 这里可以使用Lottie动画库或自定义动画
// 示例显示一个Toast提示
runOnUiThread(() -> {
// 详细日志诊断问题
android.util.Log.d("GiftAnimation", "=== 开始显示礼物动画 ===");
android.util.Log.d("GiftAnimation", "礼物名称: " + giftName);
android.util.Log.d("GiftAnimation", "数量: " + count);
android.util.Log.d("GiftAnimation", "赠送者: " + senderNickname);
android.util.Log.d("GiftAnimation", "动画管理器状态: " + (giftAnimationManager != null ? "已初始化" : "未初始化"));
android.util.Log.d("GiftAnimation", "礼物列表状态: " + (availableGifts != null ? "已加载(" + availableGifts.size() + "个)" : "未加载"));
// 检查动画管理器是否初始化
if (giftAnimationManager == null) {
android.util.Log.e("GiftAnimation", "❌ 礼物动画管理器未初始化!");
String message = senderNickname + " 送出了 " + count + "" + giftName;
Toast.makeText(this, message, Toast.LENGTH_SHORT).show();
return;
}
// 可以在这里添加更复杂的动画效果例如
// 1. 使用Lottie播放礼物动画
// 2. 显示礼物图标飞行动画
// 3. 播放音效
// 4. 显示特效粒子
// 检查礼物列表是否加载
if (availableGifts == null || availableGifts.isEmpty()) {
android.util.Log.w("GiftAnimation", "⚠️ 礼物列表未加载或为空,使用默认价格显示特效");
// 使用默认价格显示特效而不是降级到Toast
giftAnimationManager.showGiftAnimation(
giftName,
100, // 默认价格普通特效
count,
senderNickname,
null
);
return;
}
android.util.Log.d("GiftAnimation", "显示礼物动画: " + message);
// 查找礼物信息
Gift gift = null;
for (Gift g : availableGifts) {
if (g.getName().equals(giftName)) {
gift = g;
break;
}
}
if (gift != null) {
// 使用礼物动画管理器显示特效
android.util.Log.d("GiftAnimation", "✅ 找到礼物信息: " + gift.getName() + ", 价格: " + gift.getPrice());
giftAnimationManager.showGiftAnimation(
giftName,
gift.getPrice(),
count,
senderNickname,
gift.getIconUrl()
);
android.util.Log.d("GiftAnimation",
String.format("✅ 显示礼物动画: %s x%d (价格:%d) - %s",
giftName, count, gift.getPrice(), senderNickname));
} else {
// 如果找不到礼物信息使用默认价格显示特效
android.util.Log.w("GiftAnimation", "⚠️ 未找到礼物信息: " + giftName);
android.util.Log.w("GiftAnimation", "可用礼物列表: " + getGiftNames());
// 使用默认价格显示特效而不是降级到Toast
giftAnimationManager.showGiftAnimation(
giftName,
100, // 默认价格普通特效
count,
senderNickname,
null
);
android.util.Log.d("GiftAnimation", "✅ 使用默认价格显示特效");
}
});
}
/**
* 获取礼物名称列表用于调试
*/
private String getGiftNames() {
if (availableGifts == null || availableGifts.isEmpty()) {
return "[]";
}
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < availableGifts.size(); i++) {
if (i > 0) sb.append(", ");
sb.append(availableGifts.get(i).getName());
}
sb.append("]");
return sb.toString();
}
}

View File

@ -0,0 +1,31 @@
package com.example.livestreaming.glide;
import android.content.Context;
import android.graphics.drawable.PictureDrawable;
import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.Registry;
import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.module.AppGlideModule;
import com.caverock.androidsvg.SVG;
import java.io.InputStream;
/**
* Glide SVG模块 - 注册SVG支持
*/
@GlideModule
public class GlideSvgModule extends AppGlideModule {
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide,
@NonNull Registry registry) {
registry.register(SVG.class, PictureDrawable.class, new SvgDrawableTranscoder())
.append(InputStream.class, SVG.class, new SvgDecoder());
}
@Override
public boolean isManifestParsingEnabled() {
return false;
}
}

View File

@ -0,0 +1,40 @@
package com.example.livestreaming.glide;
import androidx.annotation.NonNull;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.ResourceDecoder;
import com.bumptech.glide.load.engine.Resource;
import com.bumptech.glide.load.resource.SimpleResource;
import com.caverock.androidsvg.SVG;
import com.caverock.androidsvg.SVGParseException;
import java.io.IOException;
import java.io.InputStream;
/**
* SVG解码器 - 将InputStream解码为SVG对象
*/
public class SvgDecoder implements ResourceDecoder<InputStream, SVG> {
@Override
public boolean handles(@NonNull InputStream source, @NonNull Options options) {
return true;
}
@Override
public Resource<SVG> decode(@NonNull InputStream source, int width, int height,
@NonNull Options options) throws IOException {
try {
SVG svg = SVG.getFromInputStream(source);
if (width != Integer.MIN_VALUE) {
svg.setDocumentWidth(width);
}
if (height != Integer.MIN_VALUE) {
svg.setDocumentHeight(height);
}
return new SimpleResource<>(svg);
} catch (SVGParseException ex) {
throw new IOException("Cannot load SVG from stream", ex);
}
}
}

View File

@ -0,0 +1,27 @@
package com.example.livestreaming.glide;
import android.graphics.Picture;
import android.graphics.drawable.PictureDrawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.engine.Resource;
import com.bumptech.glide.load.resource.SimpleResource;
import com.bumptech.glide.load.resource.transcode.ResourceTranscoder;
import com.caverock.androidsvg.SVG;
/**
* SVG转换器 - 将SVG对象转换为PictureDrawable
*/
public class SvgDrawableTranscoder implements ResourceTranscoder<SVG, PictureDrawable> {
@Nullable
@Override
public Resource<PictureDrawable> transcode(@NonNull Resource<SVG> toTranscode,
@NonNull Options options) {
SVG svg = toTranscode.get();
Picture picture = svg.renderToPicture();
PictureDrawable drawable = new PictureDrawable(picture);
return new SimpleResource<>(drawable);
}
}

View File

@ -0,0 +1,21 @@
package com.example.livestreaming.glide;
import android.graphics.drawable.PictureDrawable;
import android.widget.ImageView;
import com.bumptech.glide.request.target.ImageViewTarget;
/**
* SVG图层设置器 - 确保SVG正确渲染
*/
public class SvgSoftwareLayerSetter extends ImageViewTarget<PictureDrawable> {
public SvgSoftwareLayerSetter(ImageView view) {
super(view);
}
@Override
protected void setResource(PictureDrawable resource) {
view.setLayerType(ImageView.LAYER_TYPE_SOFTWARE, null);
view.setImageDrawable(resource);
}
}

View File

@ -685,4 +685,46 @@ public interface ApiService {
Call<ApiResponse<List<CommunityResponse.MatchUser>>> getMatchList(
@Query("page") int page,
@Query("limit") int limit);
// ==================== 虚拟货币和充值接口 ====================
/**
* 获取虚拟货币余额
*/
@GET("api/front/virtual/balance")
Call<ApiResponse<Map<String, Object>>> getVirtualBalance();
/**
* 获取充值套餐列表
*/
@GET("api/front/virtual/recharge/packages")
Call<ApiResponse<List<Map<String, Object>>>> getRechargePackages();
/**
* 创建充值订单
*/
@POST("api/front/virtual/recharge/order")
Call<ApiResponse<Map<String, Object>>> createRechargeOrder(@Body Map<String, Object> body);
/**
* 模拟支付成功测试用
*/
@POST("api/front/virtual/recharge/mock-pay")
Call<ApiResponse<String>> mockPaySuccess(@Body Map<String, Object> body);
/**
* 获取充值记录
*/
@GET("api/front/virtual/recharge/records")
Call<ApiResponse<List<Map<String, Object>>>> getRechargeRecords(
@Query("page") int page,
@Query("pageSize") int pageSize);
/**
* 获取消费记录
*/
@GET("api/front/virtual/consume/records")
Call<ApiResponse<List<Map<String, Object>>>> getConsumeRecords(
@Query("page") int page,
@Query("pageSize") int pageSize);
}

View File

@ -1,2 +0,0 @@
package com.example.livestreaming.net

View File

@ -48,10 +48,15 @@ public final class AuthStore {
private static final String KEY_USER_ID = "user_id";
private static final String KEY_NICKNAME = "nickname";
private static final String KEY_AVATAR = "avatar";
private static final String KEY_IS_STREAMER = "is_streamer";
private static final String KEY_STREAMER_MODE = "streamer_mode";
public static void setUserInfo(Context context, @Nullable String userId, @Nullable String nickname) {
setUserInfo(context, userId, nickname, null);
}
public static void setUserInfo(Context context, @Nullable String userId, @Nullable String nickname, @Nullable String avatar) {
if (context == null) return;
// 清理和验证 userId
@ -65,7 +70,7 @@ public final class AuthStore {
}
}
Log.d(TAG, "setUserInfo: userId=" + cleanUserId + ", nickname=" + nickname);
Log.d(TAG, "setUserInfo: userId=" + cleanUserId + ", nickname=" + nickname + ", avatar=" + avatar);
android.content.SharedPreferences.Editor editor = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit();
@ -81,6 +86,12 @@ public final class AuthStore {
editor.remove(KEY_NICKNAME);
}
if (avatar != null && !avatar.trim().isEmpty()) {
editor.putString(KEY_AVATAR, avatar.trim());
} else {
editor.remove(KEY_AVATAR);
}
editor.apply();
}
@ -114,6 +125,13 @@ public final class AuthStore {
return nickname != null ? nickname : "用户";
}
@Nullable
public static String getAvatar(Context context) {
if (context == null) return null;
return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_AVATAR, null);
}
/**
* 设置用户是否是认证主播
*/

View File

@ -7,19 +7,19 @@ public class SendGiftRequest {
@SerializedName("giftId")
private Integer giftId;
@SerializedName("streamerId")
private Integer streamerId;
@SerializedName("giftCount") // 后端期望的字段名是giftCount不是count
private Integer giftCount;
@SerializedName("count")
private Integer count;
@SerializedName("receiverId") // 后端期望的字段名是receiverId不是streamerId
private Integer receiverId;
public SendGiftRequest(Integer giftId, Integer streamerId, Integer count) {
public SendGiftRequest(Integer giftId, Integer receiverId, Integer giftCount) {
this.giftId = giftId;
this.streamerId = streamerId;
this.count = count;
this.receiverId = receiverId;
this.giftCount = giftCount;
}
public Integer getGiftId() { return giftId; }
public Integer getStreamerId() { return streamerId; }
public Integer getCount() { return count; }
public Integer getReceiverId() { return receiverId; }
public Integer getGiftCount() { return giftCount; }
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFFFFF" />
<corners android:radius="24dp" />
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#CCCCCC" />
</shape>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 礼物卡片渐变背景 - 紫粉渐变,参考主流直播平台风格 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 渐变背景:从深紫到粉紫 -->
<gradient
android:angle="135"
android:startColor="#E6663399"
android:centerColor="#E68B4789"
android:endColor="#E6B565A7"
android:type="linear" />
<!-- 圆角 -->
<corners android:radius="12dp" />
<!-- 边框:金色光晕效果 -->
<stroke
android:width="1dp"
android:color="#80FFD700" />
</shape>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 礼物卡片渐变背景 - 蓝色渐变,清新风格 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 渐变背景:从深蓝到浅蓝 -->
<gradient
android:angle="135"
android:startColor="#E61E3A8A"
android:centerColor="#E63B5998"
android:endColor="#E65B7BB4"
android:type="linear" />
<!-- 圆角 -->
<corners android:radius="12dp" />
<!-- 边框:蓝色光晕效果 -->
<stroke
android:width="1dp"
android:color="#8087CEEB" />
</shape>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 礼物卡片渐变背景 - 金色渐变,奢华风格 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 渐变背景:从深金到亮金 -->
<gradient
android:angle="135"
android:startColor="#E6B8860B"
android:centerColor="#E6DAA520"
android:endColor="#E6FFD700"
android:type="linear" />
<!-- 圆角 -->
<corners android:radius="12dp" />
<!-- 边框:白色光晕效果 -->
<stroke
android:width="1dp"
android:color="#80FFFFFF" />
</shape>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 礼物卡片渐变背景 - 橙红渐变,热情风格 -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<!-- 渐变背景:从橙色到红色 -->
<gradient
android:angle="135"
android:startColor="#E6FF6B35"
android:centerColor="#E6FF5252"
android:endColor="#E6FF4081"
android:type="linear" />
<!-- 圆角 -->
<corners android:radius="12dp" />
<!-- 边框:金色光晕效果 -->
<stroke
android:width="1dp"
android:color="#80FFD700" />
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="135"
android:startColor="#FF6B9D"
android:endColor="#C06C84"
android:type="linear" />
<corners android:radius="12dp" />
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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="#333333"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
@ -5,17 +6,11 @@
android:viewportHeight="48">
<path
android:fillColor="#2196F3"
android:pathData="M8,20l4-8h24l4,8v12H8z"/>
<path
android:fillColor="#1976D2"
android:pathData="M12,12h8v8h-8z"/>
<path
android:fillColor="#1976D2"
android:pathData="M28,12h8v8h-8z"/>
android:pathData="M8,24L12,16L36,16L40,24L40,32L8,32L8,24Z" />
<path
android:fillColor="#424242"
android:pathData="M14,28a4,4 0,1,1 0,8 4,4 0,1,1 0,-8z"/>
android:pathData="M12,28C13.1,28 14,28.9 14,30C14,31.1 13.1,32 12,32C10.9,32 10,31.1 10,30C10,28.9 10.9,28 12,28Z" />
<path
android:fillColor="#424242"
android:pathData="M34,28a4,4 0,1,1 0,8 4,4 0,1,1 0,-8z"/>
android:pathData="M36,28C37.1,28 38,28.9 38,30C38,31.1 37.1,32 36,32C34.9,32 34,31.1 34,30C34,28.9 34.9,28 36,28Z" />
</vector>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#9C27B0"
android:pathData="M8,16L8,36L40,36L40,16L36,16L36,12L32,12L32,16L28,16L28,12L24,12L24,16L20,16L20,12L16,12L16,16L12,16L12,12L8,12L8,16Z" />
<path
android:fillColor="#7B1FA2"
android:pathData="M20,24L20,36L28,36L28,24L20,24Z" />
</vector>

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
@ -5,17 +6,8 @@
android:viewportHeight="48">
<path
android:fillColor="#FFD700"
android:pathData="M8,36h32v4H8z"/>
android:pathData="M8,32L8,36L40,36L40,32L35,20L30,26L24,16L18,26L13,20L8,32Z" />
<path
android:fillColor="#FFC107"
android:pathData="M6,16l6,12h24l6-12-8,4-4-8-4,8-4-8-4,8-4-8z"/>
<path
android:fillColor="#FF9800"
android:pathData="M24,10a2,2 0,1,1 0,4 2,2 0,1,1 0,-4z"/>
<path
android:fillColor="#FF9800"
android:pathData="M12,14a2,2 0,1,1 0,4 2,2 0,1,1 0,-4z"/>
<path
android:fillColor="#FF9800"
android:pathData="M36,14a2,2 0,1,1 0,4 2,2 0,1,1 0,-4z"/>
android:fillColor="#FFA000"
android:pathData="M24,12C25.1,12 26,12.9 26,14C26,15.1 25.1,16 24,16C22.9,16 22,15.1 22,14C22,12.9 22.9,12 24,12Z" />
</vector>

View File

@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#F44336"
android:pathData="M24,40l-2.55-2.32C12.4,29.8 6,24.22 6,17.5 6,12.42 10.02,8.4 15.1,8.4c2.83,0 5.55,1.31 7.4,3.38 1.85-2.07 4.57-3.38 7.4-3.38C35.98,8.4 40,12.42 40,17.5c0,6.72-6.4,12.3-15.45,20.18L24,40z"/>
android:fillColor="#FF4444"
android:pathData="M24,42L20,38C10,29 4,23.5 4,17C4,12 7.5,8.5 12,8.5C15,8.5 18,10 20,12.5C22,10 25,8.5 28,8.5C32.5,8.5 36,12 36,17C36,23.5 30,29 20,38L24,42Z" />
</vector>

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
@ -5,11 +6,11 @@
android:viewportHeight="48">
<path
android:fillColor="#FF5722"
android:pathData="M24,4c-4,0-8,4-10,10l-4,8 4,2 2-4c0,4 2,8 4,10v8l4,4 4-4v-8c2-2 4-6 4-10l2,4 4-2-4-8c-2-6-6-10-10-10z"/>
android:pathData="M24,4L20,12L16,20L20,20L20,28L28,28L28,20L32,20L28,12L24,4Z" />
<path
android:fillColor="#FFF"
android:pathData="M24,15a3,3 0,1,1 0,6 3,3 0,1,1 0,-6z"/>
android:fillColor="#FFC107"
android:pathData="M18,28L18,36L22,40L22,28L18,28Z" />
<path
android:fillColor="#FF9800"
android:pathData="M20,38l-2,6 2-2 4,2 4-2 2,2-2-6z"/>
android:fillColor="#FFC107"
android:pathData="M30,28L30,36L26,40L26,28L30,28Z" />
</vector>

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
@ -5,11 +6,11 @@
android:viewportHeight="48">
<path
android:fillColor="#E91E63"
android:pathData="M24,8c-3.31,0-6,2.69-6,6 0,1.66 0.68,3.16 1.77,4.24L24,22.48l4.23-4.24C29.32,17.16 30,15.66 30,14c0-3.31-2.69-6-6-6z"/>
android:pathData="M24,8C20,8 17,11 17,15C17,19 20,22 24,22C28,22 31,19 31,15C31,11 28,8 24,8Z" />
<path
android:fillColor="#4CAF50"
android:pathData="M24,22.48l-4.23,4.24c-0.39,0.39-0.39,1.02 0,1.41l0,0c0.39,0.39 1.02,0.39 1.41,0L24,25.31l2.82,2.82c0.39,0.39 1.02,0.39 1.41,0l0,0c0.39-0.39 0.39-1.02 0-1.41L24,22.48z"/>
android:pathData="M22,22L22,40L26,40L26,22Z" />
<path
android:fillColor="#4CAF50"
android:pathData="M22,25v15h4V25z"/>
android:fillColor="#8BC34A"
android:pathData="M22,28C18,28 15,30 15,32C15,34 18,36 22,36L22,28Z" />
</vector>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#03A9F4"
android:pathData="M8,28L12,20L36,20L40,28L8,28Z" />
<path
android:fillColor="#0288D1"
android:pathData="M6,28L6,32L42,32L42,28L6,28Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M24,8L24,20L28,20L24,8Z" />
</vector>

View File

@ -0,0 +1,119 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@android:color/white">
<!-- 顶部标题栏 -->
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="56dp"
android:background="@color/colorPrimary"
android:elevation="4dp">
<ImageButton
android:id="@+id/btn_back"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_back_24"
android:contentDescription="返回" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:text="充值"
android:textColor="@android:color/white"
android:textSize="18sp"
android:textStyle="bold" />
</RelativeLayout>
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 充值套餐标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="选择充值套餐"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginBottom="12dp" />
<!-- 充值套餐列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv_packages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false" />
<!-- 支付方式标题 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="选择支付方式"
android:textColor="@android:color/black"
android:textSize="16sp"
android:textStyle="bold"
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp" />
<!-- 支付方式选择 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center">
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_alipay"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="支付宝"
android:textSize="16sp"
app:cornerRadius="8dp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_wechat"
android:layout_width="0dp"
android:layout_height="56dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="微信支付"
android:textSize="16sp"
app:cornerRadius="8dp"
style="@style/Widget.MaterialComponents.Button.OutlinedButton" />
</LinearLayout>
</LinearLayout>
</ScrollView>
<!-- 底部确认按钮 -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_confirm"
android:layout_width="match_parent"
android:layout_height="56dp"
android:layout_margin="16dp"
android:text="确认充值"
android:textSize="16sp"
android:textStyle="bold"
android:enabled="false"
app:cornerRadius="8dp" />
</LinearLayout>

View File

@ -363,4 +363,14 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 礼物特效覆盖层 - 显示在发送按钮上方 -->
<include
android:id="@+id/giftAnimationOverlay"
layout="@layout/gift_animation_overlay"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#F5F5F5">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp"
android:clipToPadding="false" />
<TextView
android:id="@+id/tv_empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="暂无记录"
android:textSize="16sp"
android:textColor="#999999"
android:visibility="gone" />
</FrameLayout>

View File

@ -0,0 +1,146 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- 礼物特效覆盖层 - 显示在发送按钮上方 -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/giftAnimationContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:layout_marginBottom="60dp"
android:clipChildren="false"
android:clipToPadding="false">
<!-- 礼物特效主容器 -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/giftEffectLayout"
android:layout_width="match_parent"
android:layout_height="120dp"
android:visibility="gone"
android:clipChildren="false"
android:clipToPadding="false">
<!-- 左侧礼物图标和信息 - 无背景版本 -->
<LinearLayout
android:id="@+id/giftInfoCard"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent">
<!-- 用户头像 -->
<ImageView
android:id="@+id/senderAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_user_24"
android:scaleType="centerCrop"
android:background="@drawable/circle_background" />
<!-- 礼物图标 -->
<ImageView
android:id="@+id/giftIcon"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_marginStart="8dp"
android:scaleType="fitCenter" />
<!-- 礼物信息 -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/senderName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="用户名"
android:textColor="#FFFFFF"
android:textSize="16sp"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end"
android:shadowColor="#000000"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="8" />
<TextView
android:id="@+id/giftAction"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="送出了"
android:textColor="#FFFFFF"
android:textSize="14sp"
android:shadowColor="#000000"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="8" />
<TextView
android:id="@+id/giftName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="礼物名称"
android:textColor="#FFD700"
android:textSize="18sp"
android:textStyle="bold"
android:shadowColor="#000000"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="8" />
</LinearLayout>
<!-- 连击数 -->
<TextView
android:id="@+id/comboCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="x1"
android:textColor="#FF4444"
android:textSize="36sp"
android:textStyle="bold"
android:visibility="gone"
android:shadowColor="#000000"
android:shadowDx="0"
android:shadowDy="0"
android:shadowRadius="10" />
</LinearLayout>
<!-- 粒子特效容器 - 用于显示飘散的粒子 -->
<FrameLayout
android:id="@+id/particleContainer"
android:layout_width="0dp"
android:layout_height="0dp"
android:clipChildren="false"
android:clipToPadding="false"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
<!-- 全屏特效容器 - 用于高级礼物的全屏动画 -->
<FrameLayout
android:id="@+id/fullscreenEffectContainer"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone"
android:clipChildren="false"
android:clipToPadding="false"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout>

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="16dp">
<!-- 标签 -->
<TextView
android:id="@+id/tv_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_alignParentTop="true"
android:background="@drawable/bg_hot_badge"
android:paddingStart="8dp"
android:paddingEnd="8dp"
android:paddingTop="2dp"
android:paddingBottom="2dp"
android:text="热门"
android:textSize="10sp"
android:textColor="#FFFFFF"
android:visibility="gone" />
<!-- 金额 -->
<TextView
android:id="@+id/tv_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerHorizontal="true"
android:layout_marginTop="24dp"
android:text="100"
android:textSize="32sp"
android:textStyle="bold"
android:textColor="@color/colorPrimary" />
<!-- 虚拟币单位 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/tv_amount"
android:layout_centerHorizontal="true"
android:text="虚拟币"
android:textSize="12sp"
android:textColor="#999999"
android:layout_marginTop="4dp" />
<!-- 价格 -->
<TextView
android:id="@+id/tv_price"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginTop="16dp"
android:text="¥10.00"
android:textSize="16sp"
android:textColor="@color/text_secondary" />
</RelativeLayout>
</androidx.cardview.widget.CardView>

View File

@ -1,4 +1,8 @@
<resources>
<color name="colorPrimary">#FF6B9D</color>
<color name="colorPrimaryDark">#C06C84</color>
<color name="colorAccent">#FF6B9D</color>
<color name="purple_500">#6200EE</color>
<color name="purple_700">#3700B3</color>
<color name="teal_200">#03DAC5</color>

View File

@ -1,3 +1,3 @@
plugins {
id("com.android.application") version "8.3.0" apply false
id("com.android.application") version "8.1.2" apply false
}

View File

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://mirrors.aliyun.com/macports/distfiles/gradle/gradle-8.7-bin.zip
distributionUrl=file:///D:/soft/gradle-8.1-bin.zip
networkTimeout=600000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists