321
This commit is contained in:
parent
8f19923b16
commit
81fb507fab
|
|
@ -6,97 +6,97 @@
|
|||
|
||||
## Tasks
|
||||
|
||||
- [ ] 1. 创建数据库实体和表结构
|
||||
- [ ] 1.1 创建 PrizeAnnouncement 实体类
|
||||
- [x] 1. 创建数据库实体和表结构
|
||||
- [x] 1.1 创建 PrizeAnnouncement 实体类
|
||||
- 在 `HoneyBox.Model/Entities/` 创建 `PrizeAnnouncement.cs`
|
||||
- 包含 Id, UserAvatar, UserName, PrizeLevel, PrizeName, Sort, IsEnabled, CreatedAt, UpdatedAt 字段
|
||||
- _Requirements: 1.1, 1.6, 1.7_
|
||||
- [ ] 1.2 更新 HoneyBoxDbContext 添加 DbSet
|
||||
- [x] 1.2 更新 HoneyBoxDbContext 添加 DbSet
|
||||
- 在 `HoneyBoxDbContext.cs` 添加 `DbSet<PrizeAnnouncement>` 和实体配置
|
||||
- 配置表名、索引、字段约束
|
||||
- _Requirements: 1.1_
|
||||
- [ ] 1.3 创建数据库建表 SQL 脚本
|
||||
- [x] 1.3 创建数据库建表 SQL 脚本
|
||||
- 在 `server/HoneyBox/scripts/` 创建 `create_prize_announcements.sql`
|
||||
- _Requirements: 1.1_
|
||||
|
||||
- [ ] 2. 实现后台管理 API
|
||||
- [ ] 2.1 创建 DTO 模型
|
||||
- [x] 2. 实现后台管理 API
|
||||
- [x] 2.1 创建 DTO 模型
|
||||
- 在 `HoneyBox.Admin.Business/Models/` 创建 `Announcement/AnnouncementModels.cs`
|
||||
- 包含 CreateAnnouncementRequest, UpdateAnnouncementRequest, AnnouncementAdminDto
|
||||
- _Requirements: 1.1, 1.2, 1.5_
|
||||
- [ ] 2.2 创建 Service 接口和实现
|
||||
- [x] 2.2 创建 Service 接口和实现
|
||||
- 在 `HoneyBox.Admin.Business/Services/Interfaces/` 创建 `IAnnouncementService.cs`
|
||||
- 在 `HoneyBox.Admin.Business/Services/` 创建 `AnnouncementService.cs`
|
||||
- 实现 GetListAsync, CreateAsync, UpdateAsync, DeleteAsync, ToggleStatusAsync
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.5, 1.6_
|
||||
- [ ] 2.3 编写 Service 属性测试
|
||||
- [x] 2.3 编写 Service 属性测试
|
||||
- **Property 1: CRUD Round-Trip Consistency**
|
||||
- **Property 4: Required Field Validation**
|
||||
- **Validates: Requirements 1.1, 1.2, 1.3, 1.5, 2.4**
|
||||
- [ ] 2.4 创建 Admin Controller
|
||||
- [x] 2.4 创建 Admin Controller
|
||||
- 在 `HoneyBox.Admin.Business/Controllers/` 创建 `AnnouncementController.cs`
|
||||
- 实现 GET/POST/PUT/DELETE/PATCH 端点
|
||||
- _Requirements: 1.1, 1.2, 1.3, 1.4, 1.6_
|
||||
|
||||
- [ ] 3. Checkpoint - 后台管理 API 验证
|
||||
- [x] 3. Checkpoint - 后台管理 API 验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 4. 实现用户端 API
|
||||
- [ ] 4.1 创建用户端 DTO
|
||||
- [x] 4. 实现用户端 API
|
||||
- [x] 4.1 创建用户端 DTO
|
||||
- 在 `HoneyBox.Model/Models/Announcement/` 创建 `PrizeAnnouncementDto.cs`
|
||||
- _Requirements: 2.4_
|
||||
- [ ] 4.2 创建用户端 Service 接口和实现
|
||||
- [x] 4.2 创建用户端 Service 接口和实现
|
||||
- 在 `HoneyBox.Core/Interfaces/` 创建 `IPrizeAnnouncementService.cs`
|
||||
- 在 `HoneyBox.Core/Services/` 创建 `PrizeAnnouncementService.cs`
|
||||
- 实现 GetEnabledAnnouncementsAsync 方法
|
||||
- _Requirements: 2.1, 2.2, 2.3, 2.4_
|
||||
- [ ] 4.3 编写用户端 Service 属性测试
|
||||
- [x] 4.3 编写用户端 Service 属性测试
|
||||
- **Property 2: Enabled Filter Correctness**
|
||||
- **Property 3: Sort Ordering Preservation**
|
||||
- **Validates: Requirements 2.2, 2.3, 1.6, 1.7**
|
||||
- [ ] 4.4 在 ConfigController 添加 API 端点
|
||||
- [x] 4.4 在 ConfigController 添加 API 端点
|
||||
- 添加 `GET /api/getPrizeAnnouncements` 端点
|
||||
- _Requirements: 2.1_
|
||||
- [ ] 4.5 注册依赖注入
|
||||
- [x] 4.5 注册依赖注入
|
||||
- 在 Autofac 模块中注册 IPrizeAnnouncementService
|
||||
- _Requirements: 2.1_
|
||||
|
||||
- [ ] 5. Checkpoint - 用户端 API 验证
|
||||
- [x] 5. Checkpoint - 用户端 API 验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
- [ ] 6. 实现前端公告组件
|
||||
- [ ] 6.1 创建 API 接口文件
|
||||
- [x] 6. 实现前端公告组件
|
||||
- [x] 6.1 创建 API 接口文件
|
||||
- 在 `honey_box/common/server/` 创建 `announcement.js`
|
||||
- 导出 getPrizeAnnouncements 函数
|
||||
- _Requirements: 2.1_
|
||||
- [ ] 6.2 创建公告轮播组件
|
||||
- [x] 6.2 创建公告轮播组件
|
||||
- 在 `honey_box/components/` 创建 `prize-announcement/prize-announcement.vue`
|
||||
- 实现4秒自动轮播、进出动画、循环展示
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 3.6_
|
||||
- [ ] 6.3 创建中奖详情弹窗组件
|
||||
- [x] 6.3 创建中奖详情弹窗组件
|
||||
- 在 `honey_box/components/` 创建 `prize-detail-popup/prize-detail-popup.vue`
|
||||
- 实现弹窗展示、奖品名称显示、【我也要玩】按钮
|
||||
- _Requirements: 4.1, 4.2, 4.3, 4.4_
|
||||
- [ ] 6.4 集成到首页
|
||||
- [x] 6.4 集成到首页
|
||||
- 修改 `honey_box/pages/shouye/index.vue`
|
||||
- 替换静态公告为动态轮播组件
|
||||
- 添加点击弹窗交互
|
||||
- _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 4.1_
|
||||
|
||||
- [ ] 7. 实现后台管理前端页面
|
||||
- [ ] 7.1 创建 API 接口文件
|
||||
- [x] 7. 实现后台管理前端页面
|
||||
- [x] 7.1 创建 API 接口文件
|
||||
- 在 `admin-web/src/api/business/` 创建 `announcement.ts`
|
||||
- 导出 CRUD API 函数
|
||||
- _Requirements: 5.1_
|
||||
- [ ] 7.2 创建公告管理页面
|
||||
- [x] 7.2 创建公告管理页面
|
||||
- 在 `admin-web/src/views/business/` 创建 `announcement/index.vue`
|
||||
- 实现列表展示、新增/编辑弹窗、删除确认、状态切换
|
||||
- _Requirements: 5.1, 5.2, 5.3, 5.4, 5.5, 5.6_
|
||||
- [ ] 7.3 添加路由和菜单配置
|
||||
- [x] 7.3 添加路由和菜单配置
|
||||
- 在路由配置中添加公告管理页面路由
|
||||
- _Requirements: 5.1_
|
||||
|
||||
- [ ] 8. Final Checkpoint - 全功能验证
|
||||
- [x] 8. Final Checkpoint - 全功能验证
|
||||
- Ensure all tests pass, ask the user if questions arise.
|
||||
|
||||
## Notes
|
||||
|
|
|
|||
12
honey_box/common/server/announcement.js
Normal file
12
honey_box/common/server/announcement.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import RequestManager from '../request';
|
||||
|
||||
|
||||
/**
|
||||
* 获取中奖公告列表
|
||||
* 返回启用状态的公告列表,按排序字段升序
|
||||
* @returns {Promise<Array>} 公告列表
|
||||
*/
|
||||
export const getPrizeAnnouncements = async () => {
|
||||
const res = await RequestManager.get("getPrizeAnnouncements", {});
|
||||
return res.data;
|
||||
}
|
||||
349
honey_box/components/prize-announcement/prize-announcement.vue
Normal file
349
honey_box/components/prize-announcement/prize-announcement.vue
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
<template>
|
||||
<view class="prize-announcement" v-if="announcements && announcements.length > 0" @click="handleClick">
|
||||
<!-- 左侧头像区域 -->
|
||||
<view class="avatar-container">
|
||||
<image
|
||||
class="avatar"
|
||||
:src="currentAnnouncement.userAvatar || defaultAvatar"
|
||||
mode="aspectFill"
|
||||
@error="onAvatarError"
|
||||
></image>
|
||||
</view>
|
||||
|
||||
<!-- 右侧装饰图标 -->
|
||||
<image
|
||||
class="reward-icon"
|
||||
:src="$img1('common/reward_notice.png')"
|
||||
></image>
|
||||
|
||||
<!-- 中间公告内容区域 -->
|
||||
<view class="content-container">
|
||||
<view class="content-bg"></view>
|
||||
<!-- 公告文字 - 带动画 -->
|
||||
<view class="announcement-text-wrapper">
|
||||
<text
|
||||
class="announcement-text"
|
||||
:class="animationClass"
|
||||
>{{ formattedText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态:隐藏公告区域或显示默认内容 -->
|
||||
<view class="prize-announcement prize-announcement--empty" v-else-if="showEmpty">
|
||||
<view class="avatar-container">
|
||||
<image class="avatar" :src="defaultAvatar" mode="aspectFill"></image>
|
||||
</view>
|
||||
<image class="reward-icon" :src="$img1('common/reward_notice.png')"></image>
|
||||
<view class="content-container">
|
||||
<view class="content-bg"></view>
|
||||
<view class="announcement-text-wrapper">
|
||||
<text class="announcement-text">暂无中奖公告</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 中奖公告轮播组件
|
||||
*
|
||||
* @description 在首页展示中奖公告,支持自动轮播、进出动画、循环展示
|
||||
* @property {Array} announcements - 公告列表数据
|
||||
* @property {Number} interval - 轮播间隔时间(毫秒),默认4000
|
||||
* @property {Boolean} showEmpty - 列表为空时是否显示默认内容,默认false(隐藏)
|
||||
* @event {Function} click - 点击公告时触发,返回当前公告数据
|
||||
*/
|
||||
export default {
|
||||
name: 'PrizeAnnouncement',
|
||||
|
||||
props: {
|
||||
// 公告列表
|
||||
announcements: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
// 轮播间隔时间(毫秒)
|
||||
interval: {
|
||||
type: Number,
|
||||
default: 4000
|
||||
},
|
||||
// 列表为空时是否显示默认内容
|
||||
showEmpty: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
currentIndex: 0, // 当前展示的公告索引
|
||||
timer: null, // 轮播定时器
|
||||
animationClass: '', // 动画类名
|
||||
isAnimating: false // 是否正在执行动画
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* 默认头像URL
|
||||
*/
|
||||
defaultAvatar() {
|
||||
return this.$img1('common/logo.png');
|
||||
},
|
||||
|
||||
/**
|
||||
* 当前展示的公告
|
||||
*/
|
||||
currentAnnouncement() {
|
||||
if (!this.announcements || this.announcements.length === 0) {
|
||||
return {};
|
||||
}
|
||||
return this.announcements[this.currentIndex] || {};
|
||||
},
|
||||
|
||||
/**
|
||||
* 格式化的公告文字
|
||||
* 格式:用户{用户名}抽中了{奖品等级}的{奖品名称}!
|
||||
* Validates: Requirements 3.5
|
||||
*/
|
||||
formattedText() {
|
||||
const { userName, prizeLevel, prizeName } = this.currentAnnouncement;
|
||||
if (!userName || !prizeLevel || !prizeName) {
|
||||
return '';
|
||||
}
|
||||
return `用户${userName}抽中了${prizeLevel}的${prizeName}!`;
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
/**
|
||||
* 监听公告列表变化,重新开始轮播
|
||||
*/
|
||||
announcements: {
|
||||
handler(newVal) {
|
||||
this.resetCarousel();
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.startCarousel();
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.stopCarousel();
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* 开始轮播
|
||||
* Validates: Requirements 3.1
|
||||
*/
|
||||
startCarousel() {
|
||||
// 清除已有定时器
|
||||
this.stopCarousel();
|
||||
|
||||
// 如果只有一条或没有公告,不需要轮播
|
||||
if (!this.announcements || this.announcements.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 设置定时器,每隔 interval 毫秒切换到下一条
|
||||
this.timer = setInterval(() => {
|
||||
this.nextAnnouncement();
|
||||
}, this.interval);
|
||||
},
|
||||
|
||||
/**
|
||||
* 停止轮播
|
||||
*/
|
||||
stopCarousel() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 重置轮播
|
||||
*/
|
||||
resetCarousel() {
|
||||
this.currentIndex = 0;
|
||||
this.animationClass = '';
|
||||
this.isAnimating = false;
|
||||
this.startCarousel();
|
||||
},
|
||||
|
||||
/**
|
||||
* 切换到下一条公告
|
||||
* Validates: Requirements 3.2, 3.3
|
||||
*/
|
||||
nextAnnouncement() {
|
||||
if (this.isAnimating) return;
|
||||
if (!this.announcements || this.announcements.length <= 1) return;
|
||||
|
||||
this.isAnimating = true;
|
||||
|
||||
// 播放退出动画
|
||||
this.animationClass = 'slide-out';
|
||||
|
||||
// 动画结束后切换内容并播放进入动画
|
||||
setTimeout(() => {
|
||||
// 计算下一个索引,循环回到第一条
|
||||
// Property 5: Carousel Index Cycling - (currentIndex + 1) % length
|
||||
this.currentIndex = (this.currentIndex + 1) % this.announcements.length;
|
||||
|
||||
// 播放进入动画
|
||||
this.animationClass = 'slide-in';
|
||||
|
||||
// 动画完成后重置状态
|
||||
setTimeout(() => {
|
||||
this.animationClass = '';
|
||||
this.isAnimating = false;
|
||||
}, 300);
|
||||
}, 300);
|
||||
},
|
||||
|
||||
/**
|
||||
* 点击公告
|
||||
*/
|
||||
handleClick() {
|
||||
if (this.currentAnnouncement && this.currentAnnouncement.id) {
|
||||
this.$emit('click', this.currentAnnouncement);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 头像加载失败时使用默认头像
|
||||
*/
|
||||
onAvatarError() {
|
||||
// 图片加载失败时,Vue会自动使用默认头像(通过 || defaultAvatar)
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.prize-announcement {
|
||||
width: 686rpx;
|
||||
height: 100rpx;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 10rpx auto 0;
|
||||
|
||||
// 左侧头像容器
|
||||
.avatar-container {
|
||||
width: 88rpx;
|
||||
height: 88rpx;
|
||||
background-color: #1e88e5;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
// 右侧装饰图标
|
||||
.reward-icon {
|
||||
width: 84rpx;
|
||||
height: 94rpx;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
// 中间内容区域
|
||||
.content-container {
|
||||
width: 566rpx;
|
||||
height: 72rpx;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
.content-bg {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle at center, #FFE95B 0%, #FFC402 100%);
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
||||
.announcement-text-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-left: 50rpx;
|
||||
padding-right: 20rpx;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.announcement-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
color: #5a4a2a;
|
||||
text-shadow: 1px 1px 0 rgba(255, 255, 255, 0.3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 空状态样式
|
||||
&--empty {
|
||||
opacity: 0.6;
|
||||
|
||||
.announcement-text {
|
||||
color: #8a7a5a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 进出动画
|
||||
.slide-out {
|
||||
animation: slideOut 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
.slide-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes slideOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-20rpx);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20rpx);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
263
honey_box/components/prize-detail-popup/prize-detail-popup.vue
Normal file
263
honey_box/components/prize-detail-popup/prize-detail-popup.vue
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<template>
|
||||
<uni-popup ref="prizePopup" type="center" maskBackgroundColor="rgba(0,0,0,0.8)">
|
||||
<view class="prize-detail-popup" v-if="visible">
|
||||
<!-- 顶部装饰 -->
|
||||
<view class="popup-header">
|
||||
<image class="header-decoration" :src="$img1('common/reward_notice.png')" mode="aspectFit"></image>
|
||||
</view>
|
||||
|
||||
<!-- 中奖信息区域 -->
|
||||
<view class="prize-info">
|
||||
<!-- 用户头像 -->
|
||||
<view class="avatar-wrapper">
|
||||
<image
|
||||
class="avatar"
|
||||
:src="announcement.userAvatar || defaultAvatar"
|
||||
mode="aspectFill"
|
||||
></image>
|
||||
</view>
|
||||
|
||||
<!-- 用户名 -->
|
||||
<view class="user-name">{{ announcement.userName || '幸运用户' }}</view>
|
||||
|
||||
<!-- 中奖提示文字 -->
|
||||
<view class="prize-tip">恭喜抽中</view>
|
||||
|
||||
<!-- 奖品等级 -->
|
||||
<view class="prize-level" v-if="announcement.prizeLevel">
|
||||
<text class="level-text">{{ announcement.prizeLevel }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 奖品名称 - Requirements 4.2 -->
|
||||
<view class="prize-name">{{ announcement.prizeName || '神秘奖品' }}</view>
|
||||
</view>
|
||||
|
||||
<!-- 我也要玩按钮 - Requirements 4.3, 4.4 -->
|
||||
<view class="action-area">
|
||||
<view class="play-btn" @click="handlePlayClick">
|
||||
<text class="btn-text">我也要玩</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<view class="close-btn" @click="close">
|
||||
<image :src="$img1('common/close.png')" mode="aspectFit"></image>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
/**
|
||||
* 中奖详情弹窗组件
|
||||
*
|
||||
* @description 展示中奖公告的详细信息,包含奖品名称和"我也要玩"按钮
|
||||
* @property {Object} announcement - 公告数据对象
|
||||
* @property {String} announcement.userAvatar - 用户头像URL
|
||||
* @property {String} announcement.userName - 用户名称
|
||||
* @property {String} announcement.prizeLevel - 奖品等级
|
||||
* @property {String} announcement.prizeName - 奖品名称
|
||||
* @event {Function} play - 点击"我也要玩"按钮时触发
|
||||
*
|
||||
* Validates: Requirements 4.1, 4.2, 4.3, 4.4
|
||||
*/
|
||||
export default {
|
||||
name: 'PrizeDetailPopup',
|
||||
|
||||
props: {
|
||||
// 公告数据
|
||||
announcement: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
visible: false
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
/**
|
||||
* 默认头像URL
|
||||
*/
|
||||
defaultAvatar() {
|
||||
return this.$img1('common/logo.png');
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* 打开弹窗
|
||||
* Validates: Requirements 4.1
|
||||
*/
|
||||
open() {
|
||||
this.visible = true;
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.prizePopup) {
|
||||
// 小程序环境下需要额外延迟
|
||||
setTimeout(() => {
|
||||
this.$refs.prizePopup.open();
|
||||
}, 50);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 关闭弹窗
|
||||
*/
|
||||
close() {
|
||||
this.visible = false;
|
||||
if (this.$refs.prizePopup) {
|
||||
this.$refs.prizePopup.close();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 点击"我也要玩"按钮
|
||||
* Validates: Requirements 4.3, 4.4
|
||||
*/
|
||||
handlePlayClick() {
|
||||
// 关闭弹窗 - Requirements 4.4
|
||||
this.close();
|
||||
|
||||
// 触发play事件
|
||||
this.$emit('play', this.announcement);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.prize-detail-popup {
|
||||
width: 600rpx;
|
||||
background: linear-gradient(180deg, #FFF8E1 0%, #FFFFFF 100%);
|
||||
border-radius: 30rpx;
|
||||
padding: 40rpx 30rpx;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
|
||||
// 顶部装饰
|
||||
.popup-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.header-decoration {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
}
|
||||
}
|
||||
|
||||
// 中奖信息区域
|
||||
.prize-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
// 用户头像
|
||||
.avatar-wrapper {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 4rpx solid #FFD700;
|
||||
box-shadow: 0 4rpx 12rpx rgba(255, 215, 0, 0.3);
|
||||
margin-bottom: 20rpx;
|
||||
|
||||
.avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// 用户名
|
||||
.user-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
// 中奖提示
|
||||
.prize-tip {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
// 奖品等级
|
||||
.prize-level {
|
||||
background: linear-gradient(90deg, #FFD700 0%, #FFA500 100%);
|
||||
padding: 8rpx 30rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 16rpx;
|
||||
|
||||
.level-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: bold;
|
||||
color: #FFFFFF;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
// 奖品名称 - Requirements 4.2
|
||||
.prize-name {
|
||||
font-size: 36rpx;
|
||||
font-weight: bold;
|
||||
color: #E65100;
|
||||
text-align: center;
|
||||
padding: 0 20rpx;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
}
|
||||
|
||||
// 操作区域
|
||||
.action-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20rpx;
|
||||
|
||||
// 我也要玩按钮 - Requirements 4.3
|
||||
.play-btn {
|
||||
width: 400rpx;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(90deg, #FF6B00 0%, #FF9500 100%);
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 6rpx 16rpx rgba(255, 107, 0, 0.4);
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #FFFFFF;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭按钮
|
||||
.close-btn {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: -80rpx;
|
||||
transform: translateX(-50%);
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -17,26 +17,12 @@
|
|||
|
||||
<text style="font-size: 36rpx; margin-top: 30rpx;">哈尼盲盒</text>
|
||||
|
||||
<!-- 抽奖广播 -->
|
||||
<view class=""
|
||||
style="width: 686rpx; height: 100rpx; position: relative; display: flex; align-items: center; justify-content: center; margin-top: 10rpx;">
|
||||
<view class=""
|
||||
style="width: 88rpx; height: 88rpx; background-color: #1e88e5; position: absolute; border-radius: 50%; left: 0rpx;">
|
||||
|
||||
</view>
|
||||
<image class="" :src="$img1('common/reward_notice.png')"
|
||||
style="width: 84rpx; height: 94rpx; position: absolute; right: 0rpx;">
|
||||
</image>
|
||||
<view class="row"
|
||||
style="width: 566rpx; height: 72rpx; background: radial-gradient(circle at center, #FFE95B 0%, #FFC402 100%);">
|
||||
|
||||
</view>
|
||||
|
||||
<text
|
||||
style="position: absolute; left: 100rpx; font-size: 26rpx; font-weight: bold; color: #FFCC00; text-shadow: 1px 1px 0 #5a4a2a, -1px -1px 0 #5a4a2a, 1px -1px 0 #5a4a2a, -1px 1px 0 #5a4a2a; text-align: left;">用户123456抽中了一等级的限定盲盒!</text>
|
||||
|
||||
</view>
|
||||
|
||||
<!-- 中奖公告轮播 - Requirements 3.1, 3.2, 3.3, 3.4, 3.5 -->
|
||||
<prize-announcement
|
||||
:announcements="prizeAnnouncements"
|
||||
:interval="4000"
|
||||
@click="onAnnouncementClick"
|
||||
></prize-announcement>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
|
|
@ -146,12 +132,21 @@
|
|||
<!-- 隐私政策弹窗 -->
|
||||
<priv-pop></priv-pop>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- 中奖详情弹窗 - Requirements 4.1, 4.2, 4.3, 4.4 -->
|
||||
<prize-detail-popup
|
||||
ref="prizeDetailPopup"
|
||||
:announcement="currentAnnouncement"
|
||||
@play="onPlayClick"
|
||||
></prize-detail-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import lffBarrage from "@/components/lff-barrage/lff-barrage.vue";
|
||||
import FloatBall from "@/components/float-ball/FloatBall.vue";
|
||||
import PrizeAnnouncement from "@/components/prize-announcement/prize-announcement.vue";
|
||||
import PrizeDetailPopup from "@/components/prize-detail-popup/prize-detail-popup.vue";
|
||||
import {
|
||||
getAdvert,
|
||||
getDanYe
|
||||
|
|
@ -163,10 +158,15 @@
|
|||
receiveCoupons,
|
||||
getAvailableCoupons
|
||||
} from "@/common/server/coupon";
|
||||
import {
|
||||
getPrizeAnnouncements
|
||||
} from "@/common/server/announcement";
|
||||
export default {
|
||||
components: {
|
||||
lffBarrage,
|
||||
FloatBall
|
||||
FloatBall,
|
||||
PrizeAnnouncement,
|
||||
PrizeDetailPopup
|
||||
},
|
||||
data() {
|
||||
let tabList = []; // this.$config.getGoodType();
|
||||
|
|
@ -178,6 +178,9 @@
|
|||
keyword: "",
|
||||
listdata: [],
|
||||
isLoading: false,
|
||||
// 中奖公告数据
|
||||
prizeAnnouncements: [],
|
||||
currentAnnouncement: {},
|
||||
// 下拉刷新的配置(可选, 绝大部分情况无需配置)
|
||||
downOption: {
|
||||
auto: false,
|
||||
|
|
@ -235,6 +238,8 @@
|
|||
this.tabCur = 0;
|
||||
}
|
||||
this.canGetCoupon = true;
|
||||
// 获取中奖公告列表
|
||||
this.fetchPrizeAnnouncements();
|
||||
},
|
||||
|
||||
onShareAppMessage() {
|
||||
|
|
@ -250,6 +255,46 @@
|
|||
// 删除getBallStyle和getPopupStyle方法
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* @description: 获取中奖公告列表
|
||||
* @return {*}
|
||||
*/
|
||||
async fetchPrizeAnnouncements() {
|
||||
try {
|
||||
const res = await getPrizeAnnouncements();
|
||||
if (res && Array.isArray(res)) {
|
||||
this.prizeAnnouncements = res;
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默失败,隐藏公告区域
|
||||
console.log('获取中奖公告失败', error);
|
||||
this.prizeAnnouncements = [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @description: 点击公告显示详情弹窗
|
||||
* @param {Object} announcement - 公告数据
|
||||
* Validates: Requirements 4.1
|
||||
*/
|
||||
onAnnouncementClick(announcement) {
|
||||
this.currentAnnouncement = announcement;
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.prizeDetailPopup) {
|
||||
this.$refs.prizeDetailPopup.open();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @description: 点击"我也要玩"按钮
|
||||
* @param {Object} announcement - 公告数据
|
||||
*/
|
||||
onPlayClick(announcement) {
|
||||
// 关闭弹窗后可以跳转到相关抽奖页面或其他操作
|
||||
// 目前仅关闭弹窗,后续可根据需求扩展
|
||||
},
|
||||
|
||||
// 删除BallClick和getFloatBall方法
|
||||
/**
|
||||
* @description: 是否弹公告
|
||||
|
|
|
|||
189
server/HoneyBox/scripts/create_prize_announcements.sql
Normal file
189
server/HoneyBox/scripts/create_prize_announcements.sql
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
-- 创建中奖公告配置表
|
||||
-- 用于首页展示假中奖公告,营造热闹氛围
|
||||
|
||||
-- 创建中奖公告配置表
|
||||
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'prize_announcements')
|
||||
BEGIN
|
||||
CREATE TABLE prize_announcements (
|
||||
id INT IDENTITY(1,1) NOT NULL,
|
||||
user_avatar NVARCHAR(500) NULL, -- 用户头像URL
|
||||
user_name NVARCHAR(50) NOT NULL, -- 用户名称
|
||||
prize_level NVARCHAR(20) NOT NULL, -- 奖品等级(如:无上、传说、史诗、稀有)
|
||||
prize_name NVARCHAR(100) NOT NULL, -- 奖品名称
|
||||
sort INT NOT NULL DEFAULT 0, -- 排序值,越小越靠前
|
||||
is_enabled BIT NOT NULL DEFAULT 1, -- 是否启用
|
||||
created_at DATETIME2 NOT NULL DEFAULT GETDATE(),
|
||||
updated_at DATETIME2 NOT NULL DEFAULT GETDATE(),
|
||||
CONSTRAINT pk_prize_announcements PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX ix_prize_announcements_is_enabled ON prize_announcements(is_enabled);
|
||||
CREATE INDEX ix_prize_announcements_sort ON prize_announcements(sort);
|
||||
|
||||
PRINT N'创建中奖公告配置表 prize_announcements 成功';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT N'中奖公告配置表 prize_announcements 已存在';
|
||||
END
|
||||
GO
|
||||
|
||||
-- 添加表注释
|
||||
IF EXISTS (SELECT * FROM sys.tables WHERE name = 'prize_announcements')
|
||||
BEGIN
|
||||
-- 检查是否已存在表注释
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('prize_announcements')
|
||||
AND minor_id = 0
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'中奖公告配置表,用于首页展示假中奖公告,营造热闹氛围',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'prize_announcements';
|
||||
END
|
||||
|
||||
-- 添加列注释
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('prize_announcements')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'id')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'主键ID',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'prize_announcements',
|
||||
@level2type = N'COLUMN', @level2name = N'id';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('prize_announcements')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'user_avatar')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'用户头像URL',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'prize_announcements',
|
||||
@level2type = N'COLUMN', @level2name = N'user_avatar';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('prize_announcements')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'user_name')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'用户名称',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'prize_announcements',
|
||||
@level2type = N'COLUMN', @level2name = N'user_name';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('prize_announcements')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'prize_level')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'奖品等级(如:无上、传说、史诗、稀有)',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'prize_announcements',
|
||||
@level2type = N'COLUMN', @level2name = N'prize_level';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('prize_announcements')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'prize_name')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'奖品名称',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'prize_announcements',
|
||||
@level2type = N'COLUMN', @level2name = N'prize_name';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('prize_announcements')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'sort')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'排序值,越小越靠前',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'prize_announcements',
|
||||
@level2type = N'COLUMN', @level2name = N'sort';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('prize_announcements')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'is_enabled')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'是否启用 1-启用 0-禁用',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'prize_announcements',
|
||||
@level2type = N'COLUMN', @level2name = N'is_enabled';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('prize_announcements')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'created_at')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'创建时间',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'prize_announcements',
|
||||
@level2type = N'COLUMN', @level2name = N'created_at';
|
||||
END
|
||||
|
||||
IF NOT EXISTS (
|
||||
SELECT * FROM sys.extended_properties
|
||||
WHERE major_id = OBJECT_ID('prize_announcements')
|
||||
AND minor_id = (SELECT column_id FROM sys.columns WHERE object_id = OBJECT_ID('prize_announcements') AND name = 'updated_at')
|
||||
AND name = 'MS_Description'
|
||||
)
|
||||
BEGIN
|
||||
EXEC sp_addextendedproperty
|
||||
@name = N'MS_Description',
|
||||
@value = N'更新时间',
|
||||
@level0type = N'SCHEMA', @level0name = N'dbo',
|
||||
@level1type = N'TABLE', @level1name = N'prize_announcements',
|
||||
@level2type = N'COLUMN', @level2name = N'updated_at';
|
||||
END
|
||||
END
|
||||
GO
|
||||
|
||||
PRINT N'中奖公告配置表迁移脚本执行完成';
|
||||
GO
|
||||
168
server/HoneyBox/scripts/seed_announcement_menu.sql
Normal file
168
server/HoneyBox/scripts/seed_announcement_menu.sql
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
-- =============================================
|
||||
-- 中奖公告管理菜单初始化脚本
|
||||
-- 用于在后台管理系统中添加中奖公告管理菜单
|
||||
--
|
||||
-- 注意:此脚本需要在 Admin 数据库 (honey_box_admin) 中执行
|
||||
-- 表名使用小写(menus, roles, permissions, role_menus, role_permissions)
|
||||
-- =============================================
|
||||
|
||||
-- 注意:执行此脚本前请确保:
|
||||
-- 1. 已存在超级管理员角色 (Code = 'super_admin')
|
||||
-- 2. 数据库中已有基础菜单数据
|
||||
-- 3. 已存在内容管理目录菜单
|
||||
|
||||
-- 声明变量
|
||||
DECLARE @ContentMenuId BIGINT;
|
||||
DECLARE @SuperAdminRoleId BIGINT;
|
||||
DECLARE @AnnouncementMenuId BIGINT;
|
||||
|
||||
-- 获取超级管理员角色ID
|
||||
SELECT @SuperAdminRoleId = Id FROM roles WHERE Code = 'super_admin';
|
||||
|
||||
-- 获取内容管理目录ID
|
||||
SELECT @ContentMenuId = Id FROM menus WHERE Path = '/business/content';
|
||||
|
||||
-- 如果内容管理目录不存在,则创建它
|
||||
IF @ContentMenuId IS NULL
|
||||
BEGIN
|
||||
INSERT INTO menus (ParentId, Name, Path, Component, Icon, MenuType, Permission, SortOrder, Status, IsExternal, IsCache, CreatedAt)
|
||||
VALUES (0, N'内容管理', '/business/content', 'Layout', 'Document', 1, NULL, 70, 1, 0, 0, GETDATE());
|
||||
|
||||
SET @ContentMenuId = SCOPE_IDENTITY();
|
||||
PRINT N'创建内容管理目录,ID: ' + CAST(@ContentMenuId AS VARCHAR);
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT N'内容管理目录已存在,ID: ' + CAST(@ContentMenuId AS VARCHAR);
|
||||
END
|
||||
|
||||
-- =============================================
|
||||
-- 1. 创建中奖公告管理菜单
|
||||
-- =============================================
|
||||
IF NOT EXISTS (SELECT 1 FROM menus WHERE Path = '/business/announcement/list')
|
||||
BEGIN
|
||||
INSERT INTO menus (ParentId, Name, Path, Component, Icon, MenuType, Permission, SortOrder, Status, IsExternal, IsCache, CreatedAt)
|
||||
VALUES (@ContentMenuId, N'中奖公告管理', '/business/announcement/list', 'business/announcement/index', 'Bell', 2, 'announcement:list', 4, 1, 0, 1, GETDATE());
|
||||
|
||||
SET @AnnouncementMenuId = SCOPE_IDENTITY();
|
||||
PRINT N'创建中奖公告管理菜单,ID: ' + CAST(@AnnouncementMenuId AS VARCHAR);
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
SELECT @AnnouncementMenuId = Id FROM menus WHERE Path = '/business/announcement/list';
|
||||
PRINT N'中奖公告管理菜单已存在,ID: ' + CAST(@AnnouncementMenuId AS VARCHAR);
|
||||
END
|
||||
|
||||
-- =============================================
|
||||
-- 2. 添加中奖公告管理相关权限
|
||||
-- =============================================
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM permissions WHERE Code = 'announcement:list')
|
||||
INSERT INTO permissions (Name, Code, Module, CreatedAt) VALUES (N'公告列表', 'announcement:list', N'内容管理', GETDATE());
|
||||
IF NOT EXISTS (SELECT 1 FROM permissions WHERE Code = 'announcement:add')
|
||||
INSERT INTO permissions (Name, Code, Module, CreatedAt) VALUES (N'新增公告', 'announcement:add', N'内容管理', GETDATE());
|
||||
IF NOT EXISTS (SELECT 1 FROM permissions WHERE Code = 'announcement:edit')
|
||||
INSERT INTO permissions (Name, Code, Module, CreatedAt) VALUES (N'编辑公告', 'announcement:edit', N'内容管理', GETDATE());
|
||||
IF NOT EXISTS (SELECT 1 FROM permissions WHERE Code = 'announcement:delete')
|
||||
INSERT INTO permissions (Name, Code, Module, CreatedAt) VALUES (N'删除公告', 'announcement:delete', N'内容管理', GETDATE());
|
||||
IF NOT EXISTS (SELECT 1 FROM permissions WHERE Code = 'announcement:status')
|
||||
INSERT INTO permissions (Name, Code, Module, CreatedAt) VALUES (N'切换公告状态', 'announcement:status', N'内容管理', GETDATE());
|
||||
|
||||
PRINT N'中奖公告管理权限创建完成';
|
||||
|
||||
-- =============================================
|
||||
-- 3. 为超级管理员角色分配新菜单和权限
|
||||
-- =============================================
|
||||
IF @SuperAdminRoleId IS NOT NULL
|
||||
BEGIN
|
||||
-- 分配内容管理目录(如果尚未分配)
|
||||
IF NOT EXISTS (SELECT 1 FROM role_menus WHERE RoleId = @SuperAdminRoleId AND MenuId = @ContentMenuId)
|
||||
BEGIN
|
||||
INSERT INTO role_menus (RoleId, MenuId) VALUES (@SuperAdminRoleId, @ContentMenuId);
|
||||
PRINT N'为超级管理员分配内容管理目录';
|
||||
END
|
||||
|
||||
-- 分配中奖公告管理菜单
|
||||
IF @AnnouncementMenuId IS NOT NULL AND NOT EXISTS (SELECT 1 FROM role_menus WHERE RoleId = @SuperAdminRoleId AND MenuId = @AnnouncementMenuId)
|
||||
BEGIN
|
||||
INSERT INTO role_menus (RoleId, MenuId) VALUES (@SuperAdminRoleId, @AnnouncementMenuId);
|
||||
PRINT N'为超级管理员分配中奖公告管理菜单';
|
||||
END
|
||||
|
||||
-- 分配新增的权限
|
||||
INSERT INTO role_permissions (RoleId, PermissionId)
|
||||
SELECT @SuperAdminRoleId, p.Id
|
||||
FROM permissions p
|
||||
WHERE p.Code IN (
|
||||
'announcement:list', 'announcement:add', 'announcement:edit',
|
||||
'announcement:delete', 'announcement:status'
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_permissions rp
|
||||
WHERE rp.RoleId = @SuperAdminRoleId AND rp.PermissionId = p.Id
|
||||
);
|
||||
|
||||
PRINT N'为超级管理员分配中奖公告管理权限';
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT N'警告:未找到超级管理员角色,请手动分配菜单和权限';
|
||||
END
|
||||
|
||||
-- =============================================
|
||||
-- 4. 验证结果
|
||||
-- =============================================
|
||||
PRINT N'';
|
||||
PRINT N'========== 菜单创建结果 ==========';
|
||||
SELECT
|
||||
m.Id,
|
||||
m.ParentId,
|
||||
m.Name,
|
||||
m.Path,
|
||||
m.Component,
|
||||
m.MenuType,
|
||||
m.Permission,
|
||||
m.SortOrder
|
||||
FROM menus m
|
||||
WHERE m.Path LIKE '/business/announcement%'
|
||||
OR m.Path = '/business/content'
|
||||
ORDER BY m.ParentId, m.SortOrder;
|
||||
|
||||
PRINT N'';
|
||||
PRINT N'========== 权限创建结果 ==========';
|
||||
SELECT
|
||||
p.Id,
|
||||
p.Name,
|
||||
p.Code,
|
||||
p.Module
|
||||
FROM permissions p
|
||||
WHERE p.Code LIKE 'announcement:%'
|
||||
ORDER BY p.Code;
|
||||
|
||||
PRINT N'';
|
||||
PRINT N'========== 角色菜单分配结果 ==========';
|
||||
SELECT
|
||||
r.Name AS RoleName,
|
||||
m.Name AS MenuName,
|
||||
m.Path
|
||||
FROM role_menus rm
|
||||
INNER JOIN roles r ON rm.RoleId = r.Id
|
||||
INNER JOIN menus m ON rm.MenuId = m.Id
|
||||
WHERE m.Path LIKE '/business/announcement%'
|
||||
OR m.Path = '/business/content'
|
||||
ORDER BY r.Name, m.Path;
|
||||
|
||||
PRINT N'';
|
||||
PRINT N'========== 角色权限分配结果 ==========';
|
||||
SELECT
|
||||
r.Name AS RoleName,
|
||||
p.Name AS PermissionName,
|
||||
p.Code
|
||||
FROM role_permissions rp
|
||||
INNER JOIN roles r ON rp.RoleId = r.Id
|
||||
INNER JOIN permissions p ON rp.PermissionId = p.Id
|
||||
WHERE p.Code LIKE 'announcement:%'
|
||||
ORDER BY r.Name, p.Code;
|
||||
|
||||
PRINT N'';
|
||||
PRINT N'中奖公告管理菜单初始化完成';
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
using HoneyBox.Admin.Business.Attributes;
|
||||
using HoneyBox.Admin.Business.Models;
|
||||
using HoneyBox.Admin.Business.Models.Announcement;
|
||||
using HoneyBox.Admin.Business.Services.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace HoneyBox.Admin.Business.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 中奖公告管理控制器
|
||||
/// </summary>
|
||||
[Route("api/admin/announcements")]
|
||||
public class AnnouncementController : BusinessControllerBase
|
||||
{
|
||||
private readonly IAnnouncementService _announcementService;
|
||||
|
||||
public AnnouncementController(IAnnouncementService announcementService)
|
||||
{
|
||||
_announcementService = announcementService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取公告列表(分页)
|
||||
/// </summary>
|
||||
/// <param name="page">页码</param>
|
||||
/// <param name="pageSize">每页数量</param>
|
||||
/// <param name="keyword">搜索关键词(可选,搜索用户名或奖品名称)</param>
|
||||
/// <returns>分页公告列表</returns>
|
||||
[HttpGet]
|
||||
[BusinessPermission("announcement:list")]
|
||||
public async Task<IActionResult> GetList([FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _announcementService.GetListAsync(page, pageSize, keyword);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return Error(ex.Code, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 根据ID获取公告详情
|
||||
/// </summary>
|
||||
/// <param name="id">公告ID</param>
|
||||
/// <returns>公告详情</returns>
|
||||
[HttpGet("{id}")]
|
||||
[BusinessPermission("announcement:list")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _announcementService.GetByIdAsync(id);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return Error(ex.Code, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建公告
|
||||
/// </summary>
|
||||
/// <param name="request">创建请求</param>
|
||||
/// <returns>创建的公告</returns>
|
||||
[HttpPost]
|
||||
[BusinessPermission("announcement:add")]
|
||||
public async Task<IActionResult> Create([FromBody] CreateAnnouncementRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _announcementService.CreateAsync(request);
|
||||
return Ok(result, "创建成功");
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return Error(ex.Code, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新公告
|
||||
/// </summary>
|
||||
/// <param name="id">公告ID</param>
|
||||
/// <param name="request">更新请求</param>
|
||||
/// <returns>更新后的公告</returns>
|
||||
[HttpPut("{id}")]
|
||||
[BusinessPermission("announcement:edit")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateAnnouncementRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _announcementService.UpdateAsync(id, request);
|
||||
return Ok(result, "更新成功");
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return Error(ex.Code, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 删除公告
|
||||
/// </summary>
|
||||
/// <param name="id">公告ID</param>
|
||||
/// <returns>操作结果</returns>
|
||||
[HttpDelete("{id}")]
|
||||
[BusinessPermission("announcement:delete")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _announcementService.DeleteAsync(id);
|
||||
return result ? Ok("删除成功") : Error(BusinessErrorCodes.InternalError, "删除失败");
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return Error(ex.Code, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 切换公告启用状态
|
||||
/// </summary>
|
||||
/// <param name="id">公告ID</param>
|
||||
/// <returns>更新后的公告</returns>
|
||||
[HttpPatch("{id}/status")]
|
||||
[BusinessPermission("announcement:edit")]
|
||||
public async Task<IActionResult> ToggleStatus(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _announcementService.ToggleStatusAsync(id);
|
||||
return Ok(result, "状态切换成功");
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return Error(ex.Code, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
namespace HoneyBox.Admin.Business.Models.Announcement;
|
||||
|
||||
#region Response Models
|
||||
|
||||
/// <summary>
|
||||
/// 公告管理响应DTO
|
||||
/// </summary>
|
||||
public class AnnouncementAdminDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 公告ID
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户头像URL
|
||||
/// </summary>
|
||||
public string UserAvatar { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 用户名称
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 奖品等级(如:无上、传说、史诗、稀有)
|
||||
/// </summary>
|
||||
public string PrizeLevel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 奖品名称
|
||||
/// </summary>
|
||||
public string PrizeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值,越小越靠前
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Request Models
|
||||
|
||||
/// <summary>
|
||||
/// 创建公告请求
|
||||
/// </summary>
|
||||
public class CreateAnnouncementRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户头像URL
|
||||
/// </summary>
|
||||
public string UserAvatar { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 用户名称(必填)
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 奖品等级(必填,如:无上、传说、史诗、稀有)
|
||||
/// </summary>
|
||||
public string PrizeLevel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 奖品名称(必填)
|
||||
/// </summary>
|
||||
public string PrizeName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值,越小越靠前
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 更新公告请求
|
||||
/// </summary>
|
||||
public class UpdateAnnouncementRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// 用户头像URL(可选,不传则不更新)
|
||||
/// </summary>
|
||||
public string? UserAvatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户名称(可选,不传则不更新)
|
||||
/// </summary>
|
||||
public string? UserName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 奖品等级(可选,不传则不更新)
|
||||
/// </summary>
|
||||
public string? PrizeLevel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 奖品名称(可选,不传则不更新)
|
||||
/// </summary>
|
||||
public string? PrizeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 排序值(可选,不传则不更新)
|
||||
/// </summary>
|
||||
public int? Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用(可选,不传则不更新)
|
||||
/// </summary>
|
||||
public bool? IsEnabled { get; set; }
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
using HoneyBox.Admin.Business.Models;
|
||||
using HoneyBox.Admin.Business.Models.Announcement;
|
||||
using HoneyBox.Admin.Business.Services.Interfaces;
|
||||
using HoneyBox.Model.Data;
|
||||
using HoneyBox.Model.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace HoneyBox.Admin.Business.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 中奖公告管理服务实现
|
||||
/// </summary>
|
||||
public class AnnouncementService : IAnnouncementService
|
||||
{
|
||||
private readonly HoneyBoxDbContext _dbContext;
|
||||
private readonly ILogger<AnnouncementService> _logger;
|
||||
|
||||
public AnnouncementService(
|
||||
HoneyBoxDbContext dbContext,
|
||||
ILogger<AnnouncementService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<PagedResult<AnnouncementAdminDto>> GetListAsync(int page = 1, int pageSize = 20, string? keyword = null)
|
||||
{
|
||||
var query = _dbContext.PrizeAnnouncements.AsNoTracking();
|
||||
|
||||
// 关键词搜索
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
{
|
||||
query = query.Where(a => a.UserName.Contains(keyword) || a.PrizeName.Contains(keyword));
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
var total = await query.CountAsync();
|
||||
|
||||
// 分页查询,按排序值升序、创建时间降序
|
||||
var list = await query
|
||||
.OrderBy(a => a.Sort)
|
||||
.ThenByDescending(a => a.CreatedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
var dtoList = list.Select(MapToDto).ToList();
|
||||
|
||||
return PagedResult<AnnouncementAdminDto>.Create(dtoList, total, page, pageSize);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AnnouncementAdminDto> GetByIdAsync(int id)
|
||||
{
|
||||
var announcement = await _dbContext.PrizeAnnouncements
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (announcement == null)
|
||||
{
|
||||
throw new BusinessException(BusinessErrorCodes.NotFound, "公告不存在");
|
||||
}
|
||||
|
||||
return MapToDto(announcement);
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AnnouncementAdminDto> CreateAsync(CreateAnnouncementRequest request)
|
||||
{
|
||||
// 验证必填字段
|
||||
ValidateRequiredFields(request.UserName, request.PrizeLevel, request.PrizeName);
|
||||
|
||||
var now = DateTime.Now;
|
||||
var announcement = new PrizeAnnouncement
|
||||
{
|
||||
UserAvatar = request.UserAvatar,
|
||||
UserName = request.UserName,
|
||||
PrizeLevel = request.PrizeLevel,
|
||||
PrizeName = request.PrizeName,
|
||||
Sort = request.Sort,
|
||||
IsEnabled = request.IsEnabled,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
_dbContext.PrizeAnnouncements.Add(announcement);
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"创建中奖公告成功: Id={Id}, UserName={UserName}, PrizeName={PrizeName}",
|
||||
announcement.Id, announcement.UserName, announcement.PrizeName);
|
||||
|
||||
return MapToDto(announcement);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AnnouncementAdminDto> UpdateAsync(int id, UpdateAnnouncementRequest request)
|
||||
{
|
||||
var announcement = await _dbContext.PrizeAnnouncements
|
||||
.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (announcement == null)
|
||||
{
|
||||
throw new BusinessException(BusinessErrorCodes.NotFound, "公告不存在");
|
||||
}
|
||||
|
||||
// 更新字段(只更新非null的字段)
|
||||
if (request.UserAvatar != null)
|
||||
{
|
||||
announcement.UserAvatar = request.UserAvatar;
|
||||
}
|
||||
|
||||
if (request.UserName != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.UserName))
|
||||
{
|
||||
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "用户名称不能为空");
|
||||
}
|
||||
announcement.UserName = request.UserName;
|
||||
}
|
||||
|
||||
if (request.PrizeLevel != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.PrizeLevel))
|
||||
{
|
||||
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "奖品等级不能为空");
|
||||
}
|
||||
announcement.PrizeLevel = request.PrizeLevel;
|
||||
}
|
||||
|
||||
if (request.PrizeName != null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.PrizeName))
|
||||
{
|
||||
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "奖品名称不能为空");
|
||||
}
|
||||
announcement.PrizeName = request.PrizeName;
|
||||
}
|
||||
|
||||
if (request.Sort.HasValue)
|
||||
{
|
||||
announcement.Sort = request.Sort.Value;
|
||||
}
|
||||
|
||||
if (request.IsEnabled.HasValue)
|
||||
{
|
||||
announcement.IsEnabled = request.IsEnabled.Value;
|
||||
}
|
||||
|
||||
announcement.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"更新中奖公告成功: Id={Id}, UserName={UserName}",
|
||||
id, announcement.UserName);
|
||||
|
||||
return MapToDto(announcement);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<bool> DeleteAsync(int id)
|
||||
{
|
||||
var announcement = await _dbContext.PrizeAnnouncements
|
||||
.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (announcement == null)
|
||||
{
|
||||
throw new BusinessException(BusinessErrorCodes.NotFound, "公告不存在");
|
||||
}
|
||||
|
||||
_dbContext.PrizeAnnouncements.Remove(announcement);
|
||||
var result = await _dbContext.SaveChangesAsync() > 0;
|
||||
|
||||
_logger.LogInformation(
|
||||
"删除中奖公告成功: Id={Id}, UserName={UserName}, PrizeName={PrizeName}",
|
||||
id, announcement.UserName, announcement.PrizeName);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AnnouncementAdminDto> ToggleStatusAsync(int id)
|
||||
{
|
||||
var announcement = await _dbContext.PrizeAnnouncements
|
||||
.FirstOrDefaultAsync(a => a.Id == id);
|
||||
|
||||
if (announcement == null)
|
||||
{
|
||||
throw new BusinessException(BusinessErrorCodes.NotFound, "公告不存在");
|
||||
}
|
||||
|
||||
announcement.IsEnabled = !announcement.IsEnabled;
|
||||
announcement.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _dbContext.SaveChangesAsync();
|
||||
|
||||
_logger.LogInformation(
|
||||
"切换中奖公告状态成功: Id={Id}, IsEnabled={IsEnabled}",
|
||||
id, announcement.IsEnabled);
|
||||
|
||||
return MapToDto(announcement);
|
||||
}
|
||||
|
||||
#region Private Helper Methods
|
||||
|
||||
/// <summary>
|
||||
/// 验证必填字段
|
||||
/// </summary>
|
||||
private static void ValidateRequiredFields(string userName, string prizeLevel, string prizeName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "用户名称不能为空");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(prizeLevel))
|
||||
{
|
||||
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "奖品等级不能为空");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(prizeName))
|
||||
{
|
||||
throw new BusinessException(BusinessErrorCodes.ValidationFailed, "奖品名称不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 映射实体到DTO
|
||||
/// </summary>
|
||||
private static AnnouncementAdminDto MapToDto(PrizeAnnouncement entity)
|
||||
{
|
||||
return new AnnouncementAdminDto
|
||||
{
|
||||
Id = entity.Id,
|
||||
UserAvatar = entity.UserAvatar ?? string.Empty,
|
||||
UserName = entity.UserName,
|
||||
PrizeLevel = entity.PrizeLevel,
|
||||
PrizeName = entity.PrizeName,
|
||||
Sort = entity.Sort,
|
||||
IsEnabled = entity.IsEnabled,
|
||||
CreatedAt = entity.CreatedAt,
|
||||
UpdatedAt = entity.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
using HoneyBox.Admin.Business.Models;
|
||||
using HoneyBox.Admin.Business.Models.Announcement;
|
||||
|
||||
namespace HoneyBox.Admin.Business.Services.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 中奖公告管理服务接口
|
||||
/// </summary>
|
||||
public interface IAnnouncementService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取公告列表(分页)
|
||||
/// </summary>
|
||||
/// <param name="page">页码</param>
|
||||
/// <param name="pageSize">每页数量</param>
|
||||
/// <param name="keyword">搜索关键词(可选,搜索用户名或奖品名称)</param>
|
||||
/// <returns>分页结果</returns>
|
||||
Task<PagedResult<AnnouncementAdminDto>> GetListAsync(int page = 1, int pageSize = 20, string? keyword = null);
|
||||
|
||||
/// <summary>
|
||||
/// 根据ID获取公告详情
|
||||
/// </summary>
|
||||
/// <param name="id">公告ID</param>
|
||||
/// <returns>公告详情</returns>
|
||||
Task<AnnouncementAdminDto> GetByIdAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// 创建公告
|
||||
/// </summary>
|
||||
/// <param name="request">创建请求</param>
|
||||
/// <returns>创建的公告</returns>
|
||||
Task<AnnouncementAdminDto> CreateAsync(CreateAnnouncementRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// 更新公告
|
||||
/// </summary>
|
||||
/// <param name="id">公告ID</param>
|
||||
/// <param name="request">更新请求</param>
|
||||
/// <returns>更新后的公告</returns>
|
||||
Task<AnnouncementAdminDto> UpdateAsync(int id, UpdateAnnouncementRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// 删除公告
|
||||
/// </summary>
|
||||
/// <param name="id">公告ID</param>
|
||||
/// <returns>是否成功</returns>
|
||||
Task<bool> DeleteAsync(int id);
|
||||
|
||||
/// <summary>
|
||||
/// 切换公告启用状态
|
||||
/// </summary>
|
||||
/// <param name="id">公告ID</param>
|
||||
/// <returns>更新后的公告</returns>
|
||||
Task<AnnouncementAdminDto> ToggleStatusAsync(int id);
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { request, type ApiResponse, type PagedResult } from '@/utils/request'
|
||||
|
||||
// ==================== 公告相关类型定义 ====================
|
||||
|
||||
/** 公告列表项 */
|
||||
export interface AnnouncementItem {
|
||||
id: number
|
||||
userAvatar: string
|
||||
userName: string
|
||||
prizeLevel: string
|
||||
prizeName: string
|
||||
sort: number
|
||||
isEnabled: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** 公告列表查询参数 */
|
||||
export interface AnnouncementListQuery {
|
||||
page: number
|
||||
pageSize: number
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
/** 创建公告请求 */
|
||||
export interface CreateAnnouncementRequest {
|
||||
userAvatar?: string
|
||||
userName: string
|
||||
prizeLevel: string
|
||||
prizeName: string
|
||||
sort?: number
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
/** 更新公告请求 */
|
||||
export interface UpdateAnnouncementRequest {
|
||||
userAvatar?: string
|
||||
userName?: string
|
||||
prizeLevel?: string
|
||||
prizeName?: string
|
||||
sort?: number
|
||||
isEnabled?: boolean
|
||||
}
|
||||
|
||||
// ==================== 公告管理 API ====================
|
||||
|
||||
/** 获取公告列表(分页) */
|
||||
export function getAnnouncementList(params: AnnouncementListQuery): Promise<ApiResponse<PagedResult<AnnouncementItem>>> {
|
||||
return request({
|
||||
url: '/admin/announcements',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
/** 获取公告详情 */
|
||||
export function getAnnouncementById(id: number): Promise<ApiResponse<AnnouncementItem>> {
|
||||
return request({
|
||||
url: `/admin/announcements/${id}`,
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
/** 创建公告 */
|
||||
export function createAnnouncement(data: CreateAnnouncementRequest): Promise<ApiResponse<AnnouncementItem>> {
|
||||
return request({
|
||||
url: '/admin/announcements',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** 更新公告 */
|
||||
export function updateAnnouncement(id: number, data: UpdateAnnouncementRequest): Promise<ApiResponse<AnnouncementItem>> {
|
||||
return request({
|
||||
url: `/admin/announcements/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
/** 删除公告 */
|
||||
export function deleteAnnouncement(id: number): Promise<ApiResponse<string>> {
|
||||
return request({
|
||||
url: `/admin/announcements/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
/** 切换公告启用状态 */
|
||||
export function toggleAnnouncementStatus(id: number): Promise<ApiResponse<AnnouncementItem>> {
|
||||
return request({
|
||||
url: `/admin/announcements/${id}/status`,
|
||||
method: 'patch'
|
||||
})
|
||||
}
|
||||
|
|
@ -507,6 +507,16 @@ export const businessRoutes: RouteRecordRaw[] = [
|
|||
permission: 'welfarehouse:list',
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/business/announcement/list',
|
||||
name: 'AnnouncementList',
|
||||
component: () => import('@/views/business/announcement/index.vue'),
|
||||
meta: {
|
||||
title: '中奖公告管理',
|
||||
permission: 'announcement:list',
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -1042,6 +1052,14 @@ export const rankPermissions = {
|
|||
* - menuType: 2 (菜单)
|
||||
* - permission: welfarehouse:list
|
||||
* - sortOrder: 3
|
||||
*
|
||||
* 5. 中奖公告管理(菜单)
|
||||
* - name: 中奖公告管理
|
||||
* - path: /business/announcement/list
|
||||
* - component: business/announcement/index
|
||||
* - menuType: 2 (菜单)
|
||||
* - permission: announcement:list
|
||||
* - sortOrder: 4
|
||||
*/
|
||||
|
||||
/**
|
||||
|
|
@ -1063,7 +1081,14 @@ export const contentPermissions = {
|
|||
welfareHouseList: 'welfarehouse:list',
|
||||
welfareHouseAdd: 'welfarehouse:add',
|
||||
welfareHouseEdit: 'welfarehouse:edit',
|
||||
welfareHouseDelete: 'welfarehouse:delete'
|
||||
welfareHouseDelete: 'welfarehouse:delete',
|
||||
|
||||
// 中奖公告管理
|
||||
announcementList: 'announcement:list',
|
||||
announcementAdd: 'announcement:add',
|
||||
announcementEdit: 'announcement:edit',
|
||||
announcementDelete: 'announcement:delete',
|
||||
announcementStatus: 'announcement:status'
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,486 @@
|
|||
<template>
|
||||
<div class="page-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>中奖公告管理</span>
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>添加公告
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 搜索表单 -->
|
||||
<div class="search-form">
|
||||
<el-form :inline="true" :model="queryParams">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="用户名/奖品名称"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">
|
||||
<el-icon><Search /></el-icon>搜索
|
||||
</el-button>
|
||||
<el-button @click="handleReset">
|
||||
<el-icon><Refresh /></el-icon>重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 公告表格 -->
|
||||
<el-table :data="announcementList" v-loading="loading" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
|
||||
<el-table-column label="用户头像" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.userAvatar"
|
||||
:src="row.userAvatar"
|
||||
:preview-src-list="[row.userAvatar]"
|
||||
fit="cover"
|
||||
class="avatar-image"
|
||||
preview-teleported
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-error">
|
||||
<el-icon><Picture /></el-icon>
|
||||
</div>
|
||||
</template>
|
||||
</el-image>
|
||||
<el-avatar v-else :size="40" :icon="UserFilled" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="userName" label="用户名称" min-width="120" />
|
||||
|
||||
<el-table-column label="奖品等级" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getPrizeLevelType(row.prizeLevel)" size="small">
|
||||
{{ row.prizeLevel }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="prizeName" label="奖品名称" min-width="150" />
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.isEnabled"
|
||||
:loading="row.statusLoading"
|
||||
@change="handleStatusChange(row)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||
|
||||
<el-table-column label="创建时间" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.createdAt) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="操作" width="150" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
class="pagination"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="formDialogVisible"
|
||||
:title="isEdit ? '编辑公告' : '新增公告'"
|
||||
width="550px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleDialogClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="用户头像" prop="userAvatar">
|
||||
<ImageUpload
|
||||
v-model="formData.userAvatar"
|
||||
placeholder="点击上传头像"
|
||||
url-placeholder="或输入头像URL"
|
||||
tip="支持 jpg、png、gif、webp 格式"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名称" prop="userName">
|
||||
<el-input
|
||||
v-model="formData.userName"
|
||||
placeholder="请输入用户名称"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="奖品等级" prop="prizeLevel">
|
||||
<el-select v-model="formData.prizeLevel" placeholder="请选择奖品等级" style="width: 100%">
|
||||
<el-option label="无上" value="无上" />
|
||||
<el-option label="传说" value="传说" />
|
||||
<el-option label="史诗" value="史诗" />
|
||||
<el-option label="稀有" value="稀有" />
|
||||
<el-option label="普通" value="普通" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="奖品名称" prop="prizeName">
|
||||
<el-input
|
||||
v-model="formData.prizeName"
|
||||
placeholder="请输入奖品名称"
|
||||
maxlength="100"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="排序值" prop="sort">
|
||||
<el-input-number
|
||||
v-model="formData.sort"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
placeholder="请输入排序值"
|
||||
style="width: 100%"
|
||||
controls-position="right"
|
||||
/>
|
||||
<div class="form-tip">数值越小排序越靠前</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="启用状态" prop="isEnabled">
|
||||
<el-switch v-model="formData.isEnabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleDialogClose">取消</el-button>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
确定
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { Plus, Search, Refresh, Picture, UserFilled } from '@element-plus/icons-vue'
|
||||
import ImageUpload from '@/components/ImageUpload/index.vue'
|
||||
import {
|
||||
getAnnouncementList,
|
||||
createAnnouncement,
|
||||
updateAnnouncement,
|
||||
deleteAnnouncement,
|
||||
toggleAnnouncementStatus,
|
||||
type AnnouncementItem,
|
||||
type AnnouncementListQuery,
|
||||
type CreateAnnouncementRequest
|
||||
} from '@/api/business/announcement'
|
||||
|
||||
// 列表数据
|
||||
const loading = ref(false)
|
||||
const announcementList = ref<(AnnouncementItem & { statusLoading?: boolean })[]>([])
|
||||
const total = ref(0)
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive<AnnouncementListQuery>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
keyword: ''
|
||||
})
|
||||
|
||||
// 弹窗控制
|
||||
const formDialogVisible = ref(false)
|
||||
const isEdit = ref(false)
|
||||
const currentId = ref<number | null>(null)
|
||||
const submitLoading = ref(false)
|
||||
|
||||
// 表单引用
|
||||
const formRef = ref<FormInstance>()
|
||||
|
||||
// 表单数据
|
||||
interface FormDataType {
|
||||
userAvatar: string
|
||||
userName: string
|
||||
prizeLevel: string
|
||||
prizeName: string
|
||||
sort: number
|
||||
isEnabled: boolean
|
||||
}
|
||||
|
||||
const formData = reactive<FormDataType>({
|
||||
userAvatar: '',
|
||||
userName: '',
|
||||
prizeLevel: '',
|
||||
prizeName: '',
|
||||
sort: 0,
|
||||
isEnabled: true
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const formRules: FormRules = {
|
||||
userName: [
|
||||
{ required: true, message: '请输入用户名称', trigger: 'blur' },
|
||||
{ max: 50, message: '用户名称不能超过50个字符', trigger: 'blur' }
|
||||
],
|
||||
prizeLevel: [
|
||||
{ required: true, message: '请选择奖品等级', trigger: 'change' }
|
||||
],
|
||||
prizeName: [
|
||||
{ required: true, message: '请输入奖品名称', trigger: 'blur' },
|
||||
{ max: 100, message: '奖品名称不能超过100个字符', trigger: 'blur' }
|
||||
],
|
||||
sort: [
|
||||
{ required: true, message: '请输入排序值', trigger: 'blur' }
|
||||
]
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDateTime = (dateStr: string): string => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取奖品等级标签类型
|
||||
const getPrizeLevelType = (level: string): '' | 'success' | 'warning' | 'info' | 'danger' => {
|
||||
const typeMap: Record<string, '' | 'success' | 'warning' | 'info' | 'danger'> = {
|
||||
'无上': 'danger',
|
||||
'传说': 'warning',
|
||||
'史诗': '',
|
||||
'稀有': 'success',
|
||||
'普通': 'info'
|
||||
}
|
||||
return typeMap[level] || 'info'
|
||||
}
|
||||
|
||||
// 获取公告列表
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getAnnouncementList(queryParams)
|
||||
announcementList.value = res.data.list.map(item => ({ ...item, statusLoading: false }))
|
||||
total.value = res.data.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handlePageChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.pageSize = size
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
Object.assign(formData, {
|
||||
userAvatar: '',
|
||||
userName: '',
|
||||
prizeLevel: '',
|
||||
prizeName: '',
|
||||
sort: 0,
|
||||
isEnabled: true
|
||||
})
|
||||
formRef.value?.resetFields()
|
||||
}
|
||||
|
||||
// 新增公告
|
||||
const handleAdd = () => {
|
||||
isEdit.value = false
|
||||
currentId.value = null
|
||||
resetForm()
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑公告
|
||||
const handleEdit = (row: AnnouncementItem) => {
|
||||
isEdit.value = true
|
||||
currentId.value = row.id
|
||||
Object.assign(formData, {
|
||||
userAvatar: row.userAvatar || '',
|
||||
userName: row.userName,
|
||||
prizeLevel: row.prizeLevel,
|
||||
prizeName: row.prizeName,
|
||||
sort: row.sort,
|
||||
isEnabled: row.isEnabled
|
||||
})
|
||||
formDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除公告
|
||||
const handleDelete = async (row: AnnouncementItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除公告 "${row.userName} - ${row.prizeName}" 吗?删除后不可恢复!`,
|
||||
'删除确认',
|
||||
{ type: 'warning' }
|
||||
)
|
||||
await deleteAnnouncement(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} catch {
|
||||
// 取消操作
|
||||
}
|
||||
}
|
||||
|
||||
// 切换状态
|
||||
const handleStatusChange = async (row: AnnouncementItem & { statusLoading?: boolean }) => {
|
||||
row.statusLoading = true
|
||||
try {
|
||||
await toggleAnnouncementStatus(row.id)
|
||||
ElMessage.success(row.isEnabled ? '已启用' : '已禁用')
|
||||
} catch {
|
||||
// 恢复原状态
|
||||
row.isEnabled = !row.isEnabled
|
||||
} finally {
|
||||
row.statusLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const handleDialogClose = () => {
|
||||
formDialogVisible.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) return
|
||||
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
submitLoading.value = true
|
||||
try {
|
||||
const submitData: CreateAnnouncementRequest = {
|
||||
userAvatar: formData.userAvatar || undefined,
|
||||
userName: formData.userName,
|
||||
prizeLevel: formData.prizeLevel,
|
||||
prizeName: formData.prizeName,
|
||||
sort: formData.sort,
|
||||
isEnabled: formData.isEnabled
|
||||
}
|
||||
|
||||
if (isEdit.value && currentId.value) {
|
||||
await updateAnnouncement(currentId.value, submitData)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createAnnouncement(submitData)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
handleDialogClose()
|
||||
fetchData()
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.image-error {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #f5f7fa;
|
||||
color: #909399;
|
||||
font-size: 16px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
using HoneyBox.Core.Interfaces;
|
||||
using HoneyBox.Model.Base;
|
||||
using HoneyBox.Model.Models.Announcement;
|
||||
using HoneyBox.Model.Models.Config;
|
||||
using HoneyBox.Model.Models.FloatBall;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
|
@ -19,15 +20,18 @@ public class ConfigController : ControllerBase
|
|||
{
|
||||
private readonly IConfigService _configService;
|
||||
private readonly IFloatBallService _floatBallService;
|
||||
private readonly IPrizeAnnouncementService _prizeAnnouncementService;
|
||||
private readonly ILogger<ConfigController> _logger;
|
||||
|
||||
public ConfigController(
|
||||
IConfigService configService,
|
||||
IFloatBallService floatBallService,
|
||||
IPrizeAnnouncementService prizeAnnouncementService,
|
||||
ILogger<ConfigController> logger)
|
||||
{
|
||||
_configService = configService;
|
||||
_floatBallService = floatBallService;
|
||||
_prizeAnnouncementService = prizeAnnouncementService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
|
|
@ -217,4 +221,32 @@ public class ConfigController : ControllerBase
|
|||
return ApiResponse<List<FloatBallResponse>>.Fail("获取悬浮球配置失败");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取中奖公告列表
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// GET /api/getPrizeAnnouncements
|
||||
///
|
||||
/// 返回启用状态的公告列表,按排序字段升序排列
|
||||
/// 支持未登录访问
|
||||
/// Requirements: 2.1
|
||||
/// </remarks>
|
||||
/// <returns>中奖公告列表</returns>
|
||||
[HttpGet("getPrizeAnnouncements")]
|
||||
[AllowAnonymous]
|
||||
[ProducesResponseType(typeof(ApiResponse<List<PrizeAnnouncementDto>>), StatusCodes.Status200OK)]
|
||||
public async Task<ApiResponse<List<PrizeAnnouncementDto>>> GetPrizeAnnouncements()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _prizeAnnouncementService.GetEnabledAnnouncementsAsync();
|
||||
return ApiResponse<List<PrizeAnnouncementDto>>.Success(result, "获取中奖公告成功");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get prize announcements");
|
||||
return ApiResponse<List<PrizeAnnouncementDto>>.Fail("获取中奖公告失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
using HoneyBox.Model.Models.Announcement;
|
||||
|
||||
namespace HoneyBox.Core.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// 中奖公告服务接口(用户端)
|
||||
/// </summary>
|
||||
public interface IPrizeAnnouncementService
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取启用状态的公告列表
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 只返回启用状态的公告,按排序字段升序排列
|
||||
/// </remarks>
|
||||
/// <returns>公告列表</returns>
|
||||
Task<List<PrizeAnnouncementDto>> GetEnabledAnnouncementsAsync();
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
using HoneyBox.Core.Interfaces;
|
||||
using HoneyBox.Model.Data;
|
||||
using HoneyBox.Model.Models.Announcement;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace HoneyBox.Core.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 中奖公告服务实现(用户端)
|
||||
/// </summary>
|
||||
public class PrizeAnnouncementService : IPrizeAnnouncementService
|
||||
{
|
||||
private readonly HoneyBoxDbContext _dbContext;
|
||||
private readonly ILogger<PrizeAnnouncementService> _logger;
|
||||
|
||||
public PrizeAnnouncementService(
|
||||
HoneyBoxDbContext dbContext,
|
||||
ILogger<PrizeAnnouncementService> logger)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<PrizeAnnouncementDto>> GetEnabledAnnouncementsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 只返回启用状态的公告,按排序字段升序排列
|
||||
var announcements = await _dbContext.PrizeAnnouncements
|
||||
.Where(a => a.IsEnabled)
|
||||
.OrderBy(a => a.Sort)
|
||||
.Select(a => new PrizeAnnouncementDto
|
||||
{
|
||||
Id = a.Id,
|
||||
UserAvatar = a.UserAvatar ?? string.Empty,
|
||||
UserName = a.UserName,
|
||||
PrizeLevel = a.PrizeLevel,
|
||||
PrizeName = a.PrizeName
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return announcements;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to get enabled prize announcements");
|
||||
return new List<PrizeAnnouncementDto>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -351,5 +351,15 @@ public class ServiceModule : Module
|
|||
var logger = c.Resolve<ILogger<MallService>>();
|
||||
return new MallService(dbContext, wechatPayService, logger);
|
||||
}).As<IMallService>().InstancePerLifetimeScope();
|
||||
|
||||
// ========== 公告系统服务注册 ==========
|
||||
|
||||
// 注册中奖公告服务(用户端)
|
||||
builder.Register(c =>
|
||||
{
|
||||
var dbContext = c.Resolve<HoneyBoxDbContext>();
|
||||
var logger = c.Resolve<ILogger<PrizeAnnouncementService>>();
|
||||
return new PrizeAnnouncementService(dbContext, logger);
|
||||
}).As<IPrizeAnnouncementService>().InstancePerLifetimeScope();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ public partial class HoneyBoxDbContext : DbContext
|
|||
|
||||
public virtual DbSet<GoodsDesignatedPrize> GoodsDesignatedPrizes { get; set; }
|
||||
|
||||
public virtual DbSet<PrizeAnnouncement> PrizeAnnouncements { get; set; }
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
// Connection string is configured in Program.cs via dependency injection
|
||||
|
|
@ -3531,6 +3533,55 @@ public partial class HoneyBoxDbContext : DbContext
|
|||
.HasConstraintName("fk_goods_designated_prizes_users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PrizeAnnouncement>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id).HasName("pk_prize_announcements");
|
||||
|
||||
entity.ToTable("prize_announcements", tb => tb.HasComment("中奖公告配置表,存储首页假中奖公告信息"));
|
||||
|
||||
entity.HasIndex(e => e.IsEnabled, "ix_prize_announcements_is_enabled");
|
||||
entity.HasIndex(e => e.Sort, "ix_prize_announcements_sort");
|
||||
|
||||
entity.Property(e => e.Id)
|
||||
.HasComment("主键ID")
|
||||
.HasColumnName("id");
|
||||
entity.Property(e => e.UserAvatar)
|
||||
.HasMaxLength(500)
|
||||
.HasComment("用户头像URL")
|
||||
.HasColumnName("user_avatar");
|
||||
entity.Property(e => e.UserName)
|
||||
.HasMaxLength(50)
|
||||
.IsRequired()
|
||||
.HasComment("用户名称")
|
||||
.HasColumnName("user_name");
|
||||
entity.Property(e => e.PrizeLevel)
|
||||
.HasMaxLength(20)
|
||||
.IsRequired()
|
||||
.HasComment("奖品等级(如:无上、传说、史诗、稀有)")
|
||||
.HasColumnName("prize_level");
|
||||
entity.Property(e => e.PrizeName)
|
||||
.HasMaxLength(100)
|
||||
.IsRequired()
|
||||
.HasComment("奖品名称")
|
||||
.HasColumnName("prize_name");
|
||||
entity.Property(e => e.Sort)
|
||||
.HasDefaultValue(0)
|
||||
.HasComment("排序值,越小越靠前")
|
||||
.HasColumnName("sort");
|
||||
entity.Property(e => e.IsEnabled)
|
||||
.HasDefaultValue(true)
|
||||
.HasComment("是否启用")
|
||||
.HasColumnName("is_enabled");
|
||||
entity.Property(e => e.CreatedAt)
|
||||
.HasDefaultValueSql("(getdate())")
|
||||
.HasComment("创建时间")
|
||||
.HasColumnName("created_at");
|
||||
entity.Property(e => e.UpdatedAt)
|
||||
.HasDefaultValueSql("(getdate())")
|
||||
.HasComment("更新时间")
|
||||
.HasColumnName("updated_at");
|
||||
});
|
||||
|
||||
OnModelCreatingPartial(modelBuilder);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
using System;
|
||||
|
||||
namespace HoneyBox.Model.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// 中奖公告配置表,存储首页假中奖公告信息
|
||||
/// </summary>
|
||||
public partial class PrizeAnnouncement
|
||||
{
|
||||
/// <summary>
|
||||
/// 主键ID
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户头像URL
|
||||
/// </summary>
|
||||
public string? UserAvatar { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户名称
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 奖品等级(如:无上、传说、史诗、稀有)
|
||||
/// </summary>
|
||||
public string PrizeLevel { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 奖品名称
|
||||
/// </summary>
|
||||
public string PrizeName { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// 排序值,越小越靠前
|
||||
/// </summary>
|
||||
public int Sort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 是否启用
|
||||
/// </summary>
|
||||
public bool IsEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间
|
||||
/// </summary>
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间
|
||||
/// </summary>
|
||||
public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
namespace HoneyBox.Model.Models.Announcement;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// 中奖公告响应DTO(用户端)
|
||||
/// </summary>
|
||||
public class PrizeAnnouncementDto
|
||||
{
|
||||
/// <summary>
|
||||
/// 公告ID
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 用户头像URL
|
||||
/// </summary>
|
||||
[JsonPropertyName("user_avatar")]
|
||||
public string UserAvatar { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 用户名称
|
||||
/// </summary>
|
||||
[JsonPropertyName("user_name")]
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 奖品等级(如:无上、传说、史诗、稀有)
|
||||
/// </summary>
|
||||
[JsonPropertyName("prize_level")]
|
||||
public string PrizeLevel { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 奖品名称
|
||||
/// </summary>
|
||||
[JsonPropertyName("prize_name")]
|
||||
public string PrizeName { get; set; } = string.Empty;
|
||||
}
|
||||
|
|
@ -0,0 +1,830 @@
|
|||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using HoneyBox.Admin.Business.Models;
|
||||
using HoneyBox.Admin.Business.Models.Announcement;
|
||||
using HoneyBox.Admin.Business.Services;
|
||||
using HoneyBox.Model.Data;
|
||||
using HoneyBox.Model.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace HoneyBox.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// AnnouncementService 属性测试
|
||||
/// 测试首页中大奖公告功能的核心属性
|
||||
/// </summary>
|
||||
public class AnnouncementServicePropertyTests
|
||||
{
|
||||
private readonly Mock<ILogger<AnnouncementService>> _mockLogger = new();
|
||||
|
||||
private (HoneyBoxDbContext dbContext, AnnouncementService service) CreateService()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<HoneyBoxDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var dbContext = new HoneyBoxDbContext(options);
|
||||
var service = new AnnouncementService(dbContext, _mockLogger.Object);
|
||||
return (dbContext, service);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成有效的非空字符串(用于必填字段)
|
||||
/// </summary>
|
||||
private static string GenerateValidString(string prefix, int seed)
|
||||
{
|
||||
return $"{prefix}_{Math.Abs(seed) % 1000}";
|
||||
}
|
||||
|
||||
#region Property 1: CRUD Round-Trip Consistency
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency**
|
||||
/// For any valid announcement data, creating an announcement and then reading it back
|
||||
/// should return the same data.
|
||||
/// **Validates: Requirements 1.1, 1.2, 1.3, 2.4**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool CrudRoundTrip_CreateAndRead_ShouldReturnSameData(
|
||||
NonEmptyString userName,
|
||||
NonEmptyString prizeLevel,
|
||||
NonEmptyString prizeName,
|
||||
PositiveInt sort,
|
||||
bool isEnabled)
|
||||
{
|
||||
var actualUserName = userName.Get.Trim();
|
||||
var actualPrizeLevel = prizeLevel.Get.Trim();
|
||||
var actualPrizeName = prizeName.Get.Trim();
|
||||
var actualSort = sort.Get % 1000;
|
||||
|
||||
// Skip if any required field becomes empty after trim
|
||||
if (string.IsNullOrWhiteSpace(actualUserName) ||
|
||||
string.IsNullOrWhiteSpace(actualPrizeLevel) ||
|
||||
string.IsNullOrWhiteSpace(actualPrizeName))
|
||||
{
|
||||
return true; // Skip this test case
|
||||
}
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create announcement
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserAvatar = "http://test.com/avatar.jpg",
|
||||
UserName = actualUserName,
|
||||
PrizeLevel = actualPrizeLevel,
|
||||
PrizeName = actualPrizeName,
|
||||
Sort = actualSort,
|
||||
IsEnabled = isEnabled
|
||||
};
|
||||
|
||||
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
|
||||
// Read it back
|
||||
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
|
||||
|
||||
// Verify data consistency
|
||||
return retrieved.UserName == actualUserName &&
|
||||
retrieved.PrizeLevel == actualPrizeLevel &&
|
||||
retrieved.PrizeName == actualPrizeName &&
|
||||
retrieved.Sort == actualSort &&
|
||||
retrieved.IsEnabled == isEnabled &&
|
||||
retrieved.UserAvatar == "http://test.com/avatar.jpg";
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency**
|
||||
/// For any valid announcement data, updating it and reading again should reflect the updates.
|
||||
/// **Validates: Requirements 1.1, 1.2, 1.3, 2.4**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool CrudRoundTrip_UpdateAndRead_ShouldReflectUpdates(
|
||||
PositiveInt seed1,
|
||||
PositiveInt seed2,
|
||||
PositiveInt sort1,
|
||||
PositiveInt sort2,
|
||||
bool isEnabled1,
|
||||
bool isEnabled2)
|
||||
{
|
||||
var userName1 = GenerateValidString("User", seed1.Get);
|
||||
var prizeLevel1 = GenerateValidString("Level", seed1.Get);
|
||||
var prizeName1 = GenerateValidString("Prize", seed1.Get);
|
||||
var userName2 = GenerateValidString("UpdatedUser", seed2.Get);
|
||||
var prizeLevel2 = GenerateValidString("UpdatedLevel", seed2.Get);
|
||||
var prizeName2 = GenerateValidString("UpdatedPrize", seed2.Get);
|
||||
var actualSort1 = sort1.Get % 1000;
|
||||
var actualSort2 = sort2.Get % 1000;
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create initial announcement
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserAvatar = "http://test.com/avatar1.jpg",
|
||||
UserName = userName1,
|
||||
PrizeLevel = prizeLevel1,
|
||||
PrizeName = prizeName1,
|
||||
Sort = actualSort1,
|
||||
IsEnabled = isEnabled1
|
||||
};
|
||||
|
||||
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
|
||||
// Update the announcement
|
||||
var updateRequest = new UpdateAnnouncementRequest
|
||||
{
|
||||
UserAvatar = "http://test.com/avatar2.jpg",
|
||||
UserName = userName2,
|
||||
PrizeLevel = prizeLevel2,
|
||||
PrizeName = prizeName2,
|
||||
Sort = actualSort2,
|
||||
IsEnabled = isEnabled2
|
||||
};
|
||||
|
||||
service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult();
|
||||
|
||||
// Read it back
|
||||
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
|
||||
|
||||
// Verify updates are reflected
|
||||
return retrieved.UserName == userName2 &&
|
||||
retrieved.PrizeLevel == prizeLevel2 &&
|
||||
retrieved.PrizeName == prizeName2 &&
|
||||
retrieved.Sort == actualSort2 &&
|
||||
retrieved.IsEnabled == isEnabled2 &&
|
||||
retrieved.UserAvatar == "http://test.com/avatar2.jpg";
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency**
|
||||
/// For any valid announcement, deleting it should make it no longer retrievable.
|
||||
/// **Validates: Requirements 1.1, 1.2, 1.3, 2.4**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool CrudRoundTrip_Delete_ShouldMakeUnretrievable(PositiveInt seed)
|
||||
{
|
||||
var userName = GenerateValidString("User", seed.Get);
|
||||
var prizeLevel = GenerateValidString("Level", seed.Get);
|
||||
var prizeName = GenerateValidString("Prize", seed.Get);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create announcement
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserName = userName,
|
||||
PrizeLevel = prizeLevel,
|
||||
PrizeName = prizeName,
|
||||
Sort = 0,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
var createdId = created.Id;
|
||||
|
||||
// Delete the announcement
|
||||
var deleteResult = service.DeleteAsync(createdId).GetAwaiter().GetResult();
|
||||
|
||||
if (!deleteResult)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to retrieve it - should throw NotFound exception
|
||||
try
|
||||
{
|
||||
service.GetByIdAsync(createdId).GetAwaiter().GetResult();
|
||||
return false; // Should not reach here
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
return ex.Code == BusinessErrorCodes.NotFound;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency**
|
||||
/// Partial updates should only modify specified fields, leaving others unchanged.
|
||||
/// **Validates: Requirements 1.1, 1.2, 1.3, 2.4**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool CrudRoundTrip_PartialUpdate_ShouldOnlyModifySpecifiedFields(
|
||||
PositiveInt seed,
|
||||
PositiveInt newSort)
|
||||
{
|
||||
var userName = GenerateValidString("User", seed.Get);
|
||||
var prizeLevel = GenerateValidString("Level", seed.Get);
|
||||
var prizeName = GenerateValidString("Prize", seed.Get);
|
||||
var originalSort = seed.Get % 500;
|
||||
var updatedSort = (newSort.Get % 500) + 500; // Ensure different value
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create announcement
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserAvatar = "http://test.com/original.jpg",
|
||||
UserName = userName,
|
||||
PrizeLevel = prizeLevel,
|
||||
PrizeName = prizeName,
|
||||
Sort = originalSort,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
|
||||
// Partial update - only update Sort
|
||||
var updateRequest = new UpdateAnnouncementRequest
|
||||
{
|
||||
Sort = updatedSort
|
||||
};
|
||||
|
||||
service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult();
|
||||
|
||||
// Read it back
|
||||
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
|
||||
|
||||
// Verify only Sort changed, other fields remain the same
|
||||
return retrieved.Sort == updatedSort &&
|
||||
retrieved.UserName == userName &&
|
||||
retrieved.PrizeLevel == prizeLevel &&
|
||||
retrieved.PrizeName == prizeName &&
|
||||
retrieved.UserAvatar == "http://test.com/original.jpg" &&
|
||||
retrieved.IsEnabled == true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 1: CRUD Round-Trip Consistency**
|
||||
/// Multiple CRUD operations should maintain data consistency.
|
||||
/// **Validates: Requirements 1.1, 1.2, 1.3, 2.4**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 50)]
|
||||
public bool CrudRoundTrip_MultipleCrudOperations_ShouldMaintainConsistency(PositiveInt operationCount)
|
||||
{
|
||||
var actualCount = Math.Max(2, operationCount.Get % 5 + 2);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
var createdIds = new List<int>();
|
||||
|
||||
// Create multiple announcements
|
||||
for (int i = 0; i < actualCount; i++)
|
||||
{
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserName = $"User_{i}",
|
||||
PrizeLevel = $"Level_{i}",
|
||||
PrizeName = $"Prize_{i}",
|
||||
Sort = i,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
createdIds.Add(created.Id);
|
||||
}
|
||||
|
||||
// Update each announcement
|
||||
for (int i = 0; i < actualCount; i++)
|
||||
{
|
||||
var updateRequest = new UpdateAnnouncementRequest
|
||||
{
|
||||
UserName = $"UpdatedUser_{i}",
|
||||
Sort = i + 100
|
||||
};
|
||||
|
||||
service.UpdateAsync(createdIds[i], updateRequest).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
// Verify all updates
|
||||
for (int i = 0; i < actualCount; i++)
|
||||
{
|
||||
var retrieved = service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult();
|
||||
if (retrieved.UserName != $"UpdatedUser_{i}" || retrieved.Sort != i + 100)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete half of them
|
||||
for (int i = 0; i < actualCount / 2; i++)
|
||||
{
|
||||
service.DeleteAsync(createdIds[i]).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
// Verify deleted ones are not retrievable
|
||||
for (int i = 0; i < actualCount / 2; i++)
|
||||
{
|
||||
try
|
||||
{
|
||||
service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult();
|
||||
return false; // Should have thrown
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
if (ex.Code != BusinessErrorCodes.NotFound)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify remaining ones are still retrievable
|
||||
for (int i = actualCount / 2; i < actualCount; i++)
|
||||
{
|
||||
var retrieved = service.GetByIdAsync(createdIds[i]).GetAwaiter().GetResult();
|
||||
if (retrieved.UserName != $"UpdatedUser_{i}")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property 4: Required Field Validation
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
|
||||
/// For any create request where UserName is empty or whitespace-only,
|
||||
/// the system should reject the request and return a validation error.
|
||||
/// **Validates: Requirements 1.5**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool RequiredFieldValidation_EmptyUserName_ShouldRejectCreate(PositiveInt seed)
|
||||
{
|
||||
var prizeLevel = GenerateValidString("Level", seed.Get);
|
||||
var prizeName = GenerateValidString("Prize", seed.Get);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
var emptyUserNames = new[] { "", " ", "\t", "\n", " \t\n " };
|
||||
|
||||
foreach (var emptyUserName in emptyUserNames)
|
||||
{
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserName = emptyUserName,
|
||||
PrizeLevel = prizeLevel,
|
||||
PrizeName = prizeName,
|
||||
Sort = 0,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
return false; // Should have thrown
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
if (ex.Code != BusinessErrorCodes.ValidationFailed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify database is not modified
|
||||
var count = dbContext.PrizeAnnouncements.Count();
|
||||
return count == 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
|
||||
/// For any create request where PrizeLevel is empty or whitespace-only,
|
||||
/// the system should reject the request and return a validation error.
|
||||
/// **Validates: Requirements 1.5**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool RequiredFieldValidation_EmptyPrizeLevel_ShouldRejectCreate(PositiveInt seed)
|
||||
{
|
||||
var userName = GenerateValidString("User", seed.Get);
|
||||
var prizeName = GenerateValidString("Prize", seed.Get);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
var emptyPrizeLevels = new[] { "", " ", "\t", "\n", " \t\n " };
|
||||
|
||||
foreach (var emptyPrizeLevel in emptyPrizeLevels)
|
||||
{
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserName = userName,
|
||||
PrizeLevel = emptyPrizeLevel,
|
||||
PrizeName = prizeName,
|
||||
Sort = 0,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
return false; // Should have thrown
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
if (ex.Code != BusinessErrorCodes.ValidationFailed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify database is not modified
|
||||
var count = dbContext.PrizeAnnouncements.Count();
|
||||
return count == 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
|
||||
/// For any create request where PrizeName is empty or whitespace-only,
|
||||
/// the system should reject the request and return a validation error.
|
||||
/// **Validates: Requirements 1.5**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool RequiredFieldValidation_EmptyPrizeName_ShouldRejectCreate(PositiveInt seed)
|
||||
{
|
||||
var userName = GenerateValidString("User", seed.Get);
|
||||
var prizeLevel = GenerateValidString("Level", seed.Get);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
var emptyPrizeNames = new[] { "", " ", "\t", "\n", " \t\n " };
|
||||
|
||||
foreach (var emptyPrizeName in emptyPrizeNames)
|
||||
{
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserName = userName,
|
||||
PrizeLevel = prizeLevel,
|
||||
PrizeName = emptyPrizeName,
|
||||
Sort = 0,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
return false; // Should have thrown
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
if (ex.Code != BusinessErrorCodes.ValidationFailed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify database is not modified
|
||||
var count = dbContext.PrizeAnnouncements.Count();
|
||||
return count == 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
|
||||
/// For any update request where UserName is set to empty or whitespace-only,
|
||||
/// the system should reject the request and return a validation error without modifying the database.
|
||||
/// **Validates: Requirements 1.5**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool RequiredFieldValidation_EmptyUserName_ShouldRejectUpdate(PositiveInt seed)
|
||||
{
|
||||
var userName = GenerateValidString("User", seed.Get);
|
||||
var prizeLevel = GenerateValidString("Level", seed.Get);
|
||||
var prizeName = GenerateValidString("Prize", seed.Get);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// First create a valid announcement
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserName = userName,
|
||||
PrizeLevel = prizeLevel,
|
||||
PrizeName = prizeName,
|
||||
Sort = 0,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
|
||||
var emptyUserNames = new[] { "", " ", "\t", "\n" };
|
||||
|
||||
foreach (var emptyUserName in emptyUserNames)
|
||||
{
|
||||
var updateRequest = new UpdateAnnouncementRequest
|
||||
{
|
||||
UserName = emptyUserName
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult();
|
||||
return false; // Should have thrown
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
if (ex.Code != BusinessErrorCodes.ValidationFailed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify original data is not modified
|
||||
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
|
||||
return retrieved.UserName == userName;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
|
||||
/// For any update request where PrizeLevel is set to empty or whitespace-only,
|
||||
/// the system should reject the request and return a validation error without modifying the database.
|
||||
/// **Validates: Requirements 1.5**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool RequiredFieldValidation_EmptyPrizeLevel_ShouldRejectUpdate(PositiveInt seed)
|
||||
{
|
||||
var userName = GenerateValidString("User", seed.Get);
|
||||
var prizeLevel = GenerateValidString("Level", seed.Get);
|
||||
var prizeName = GenerateValidString("Prize", seed.Get);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// First create a valid announcement
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserName = userName,
|
||||
PrizeLevel = prizeLevel,
|
||||
PrizeName = prizeName,
|
||||
Sort = 0,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
|
||||
var emptyPrizeLevels = new[] { "", " ", "\t", "\n" };
|
||||
|
||||
foreach (var emptyPrizeLevel in emptyPrizeLevels)
|
||||
{
|
||||
var updateRequest = new UpdateAnnouncementRequest
|
||||
{
|
||||
PrizeLevel = emptyPrizeLevel
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult();
|
||||
return false; // Should have thrown
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
if (ex.Code != BusinessErrorCodes.ValidationFailed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify original data is not modified
|
||||
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
|
||||
return retrieved.PrizeLevel == prizeLevel;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
|
||||
/// For any update request where PrizeName is set to empty or whitespace-only,
|
||||
/// the system should reject the request and return a validation error without modifying the database.
|
||||
/// **Validates: Requirements 1.5**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool RequiredFieldValidation_EmptyPrizeName_ShouldRejectUpdate(PositiveInt seed)
|
||||
{
|
||||
var userName = GenerateValidString("User", seed.Get);
|
||||
var prizeLevel = GenerateValidString("Level", seed.Get);
|
||||
var prizeName = GenerateValidString("Prize", seed.Get);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// First create a valid announcement
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserName = userName,
|
||||
PrizeLevel = prizeLevel,
|
||||
PrizeName = prizeName,
|
||||
Sort = 0,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
|
||||
var emptyPrizeNames = new[] { "", " ", "\t", "\n" };
|
||||
|
||||
foreach (var emptyPrizeName in emptyPrizeNames)
|
||||
{
|
||||
var updateRequest = new UpdateAnnouncementRequest
|
||||
{
|
||||
PrizeName = emptyPrizeName
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
service.UpdateAsync(created.Id, updateRequest).GetAwaiter().GetResult();
|
||||
return false; // Should have thrown
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
if (ex.Code != BusinessErrorCodes.ValidationFailed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Verify original data is not modified
|
||||
var retrieved = service.GetByIdAsync(created.Id).GetAwaiter().GetResult();
|
||||
return retrieved.PrizeName == prizeName;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
|
||||
/// For any create request with all required fields empty,
|
||||
/// the system should reject the request without modifying the database.
|
||||
/// **Validates: Requirements 1.5**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 50)]
|
||||
public bool RequiredFieldValidation_AllFieldsEmpty_ShouldRejectCreate(PositiveInt seed)
|
||||
{
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserName = "",
|
||||
PrizeLevel = "",
|
||||
PrizeName = "",
|
||||
Sort = 0,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
return false; // Should have thrown
|
||||
}
|
||||
catch (BusinessException ex)
|
||||
{
|
||||
if (ex.Code != BusinessErrorCodes.ValidationFailed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify database is not modified
|
||||
var count = dbContext.PrizeAnnouncements.Count();
|
||||
return count == 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 4: Required Field Validation**
|
||||
/// Valid requests with non-empty required fields should succeed.
|
||||
/// **Validates: Requirements 1.5**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool RequiredFieldValidation_ValidFields_ShouldSucceed(
|
||||
NonEmptyString userName,
|
||||
NonEmptyString prizeLevel,
|
||||
NonEmptyString prizeName)
|
||||
{
|
||||
var actualUserName = userName.Get.Trim();
|
||||
var actualPrizeLevel = prizeLevel.Get.Trim();
|
||||
var actualPrizeName = prizeName.Get.Trim();
|
||||
|
||||
// Skip if any required field becomes empty after trim
|
||||
if (string.IsNullOrWhiteSpace(actualUserName) ||
|
||||
string.IsNullOrWhiteSpace(actualPrizeLevel) ||
|
||||
string.IsNullOrWhiteSpace(actualPrizeName))
|
||||
{
|
||||
return true; // Skip this test case
|
||||
}
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
var createRequest = new CreateAnnouncementRequest
|
||||
{
|
||||
UserName = actualUserName,
|
||||
PrizeLevel = actualPrizeLevel,
|
||||
PrizeName = actualPrizeName,
|
||||
Sort = 0,
|
||||
IsEnabled = true
|
||||
};
|
||||
|
||||
var created = service.CreateAsync(createRequest).GetAwaiter().GetResult();
|
||||
|
||||
// Verify creation succeeded
|
||||
return created.Id > 0 &&
|
||||
created.UserName == actualUserName &&
|
||||
created.PrizeLevel == actualPrizeLevel &&
|
||||
created.PrizeName == actualPrizeName;
|
||||
}
|
||||
catch (BusinessException)
|
||||
{
|
||||
return false; // Should not throw for valid data
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -371,8 +371,6 @@ public class DesignatedPrizeServicePropertyTests
|
|||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
|
||||
#region Property 8: Prize Data Immutability
|
||||
|
||||
|
|
@ -799,3 +797,5 @@ public class DesignatedPrizeServicePropertyTests
|
|||
}
|
||||
|
||||
#endregion
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,491 @@
|
|||
using FsCheck;
|
||||
using FsCheck.Xunit;
|
||||
using HoneyBox.Core.Services;
|
||||
using HoneyBox.Model.Data;
|
||||
using HoneyBox.Model.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace HoneyBox.Tests.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PrizeAnnouncementService 属性测试(用户端)
|
||||
/// 测试首页中大奖公告功能的用户端核心属性
|
||||
/// </summary>
|
||||
public class PrizeAnnouncementServicePropertyTests
|
||||
{
|
||||
private readonly Mock<ILogger<PrizeAnnouncementService>> _mockLogger = new();
|
||||
|
||||
private (HoneyBoxDbContext dbContext, PrizeAnnouncementService service) CreateService()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<HoneyBoxDbContext>()
|
||||
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var dbContext = new HoneyBoxDbContext(options);
|
||||
var service = new PrizeAnnouncementService(dbContext, _mockLogger.Object);
|
||||
return (dbContext, service);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 生成有效的非空字符串(用于必填字段)
|
||||
/// </summary>
|
||||
private static string GenerateValidString(string prefix, int seed)
|
||||
{
|
||||
return $"{prefix}_{Math.Abs(seed) % 1000}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 创建测试用的公告实体
|
||||
/// </summary>
|
||||
private static PrizeAnnouncement CreateAnnouncement(int seed, bool isEnabled, int sort)
|
||||
{
|
||||
return new PrizeAnnouncement
|
||||
{
|
||||
UserAvatar = $"http://test.com/avatar_{seed}.jpg",
|
||||
UserName = GenerateValidString("User", seed),
|
||||
PrizeLevel = GenerateValidString("Level", seed),
|
||||
PrizeName = GenerateValidString("Prize", seed),
|
||||
Sort = sort,
|
||||
IsEnabled = isEnabled,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
|
||||
#region Property 2: Enabled Filter Correctness
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness**
|
||||
/// For any set of announcements with mixed enabled/disabled states, the user-facing API
|
||||
/// should only return announcements where IsEnabled is true.
|
||||
/// **Validates: Requirements 2.2, 1.6**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool EnabledFilterCorrectness_OnlyReturnsEnabledAnnouncements(
|
||||
PositiveInt enabledCount,
|
||||
PositiveInt disabledCount)
|
||||
{
|
||||
var actualEnabledCount = Math.Max(1, enabledCount.Get % 10);
|
||||
var actualDisabledCount = disabledCount.Get % 10;
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create enabled announcements
|
||||
for (int i = 0; i < actualEnabledCount; i++)
|
||||
{
|
||||
var announcement = CreateAnnouncement(i, isEnabled: true, sort: i);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
}
|
||||
|
||||
// Create disabled announcements
|
||||
for (int i = 0; i < actualDisabledCount; i++)
|
||||
{
|
||||
var announcement = CreateAnnouncement(i + 1000, isEnabled: false, sort: i + 1000);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
// Get enabled announcements via service
|
||||
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Verify: all returned items should be enabled
|
||||
// Since we're testing the service, we verify by checking the count matches enabled count
|
||||
return result.Count == actualEnabledCount;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness**
|
||||
/// For any set of announcements, the count of returned items should equal
|
||||
/// the count of enabled announcements in the database.
|
||||
/// **Validates: Requirements 2.2, 1.6**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool EnabledFilterCorrectness_CountMatchesEnabledInDatabase(PositiveInt totalCount)
|
||||
{
|
||||
var actualTotalCount = Math.Max(1, totalCount.Get % 20);
|
||||
var random = new Random(totalCount.Get);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
var expectedEnabledCount = 0;
|
||||
|
||||
// Create announcements with random enabled states
|
||||
for (int i = 0; i < actualTotalCount; i++)
|
||||
{
|
||||
var isEnabled = random.Next(2) == 1;
|
||||
if (isEnabled) expectedEnabledCount++;
|
||||
|
||||
var announcement = CreateAnnouncement(i, isEnabled, sort: i);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
// Get enabled announcements via service
|
||||
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Verify: count should match expected enabled count
|
||||
return result.Count == expectedEnabledCount;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness**
|
||||
/// When all announcements are disabled, the API should return an empty list.
|
||||
/// **Validates: Requirements 2.2, 1.6**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool EnabledFilterCorrectness_AllDisabled_ReturnsEmptyList(PositiveInt count)
|
||||
{
|
||||
var actualCount = Math.Max(1, count.Get % 10);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create only disabled announcements
|
||||
for (int i = 0; i < actualCount; i++)
|
||||
{
|
||||
var announcement = CreateAnnouncement(i, isEnabled: false, sort: i);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
// Get enabled announcements via service
|
||||
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Verify: should return empty list
|
||||
return result.Count == 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness**
|
||||
/// When all announcements are enabled, the API should return all of them.
|
||||
/// **Validates: Requirements 2.2, 1.6**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool EnabledFilterCorrectness_AllEnabled_ReturnsAll(PositiveInt count)
|
||||
{
|
||||
var actualCount = Math.Max(1, count.Get % 10);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create only enabled announcements
|
||||
for (int i = 0; i < actualCount; i++)
|
||||
{
|
||||
var announcement = CreateAnnouncement(i, isEnabled: true, sort: i);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
// Get enabled announcements via service
|
||||
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Verify: should return all announcements
|
||||
return result.Count == actualCount;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 2: Enabled Filter Correctness**
|
||||
/// When database is empty, the API should return an empty list.
|
||||
/// **Validates: Requirements 2.2, 1.6**
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void EnabledFilterCorrectness_EmptyDatabase_ReturnsEmptyList()
|
||||
{
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Get enabled announcements from empty database
|
||||
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Verify: should return empty list
|
||||
Assert.Empty(result);
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Property 3: Sort Ordering Preservation
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation**
|
||||
/// For any set of enabled announcements with different sort values, the user-facing API
|
||||
/// should return them in ascending order by the sort field.
|
||||
/// **Validates: Requirements 2.3, 1.7**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool SortOrderingPreservation_ReturnsInAscendingOrder(PositiveInt count, PositiveInt seed)
|
||||
{
|
||||
var actualCount = Math.Max(2, count.Get % 10 + 2);
|
||||
var random = new Random(seed.Get);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create announcements with random sort values
|
||||
var sortValues = Enumerable.Range(0, actualCount)
|
||||
.Select(_ => random.Next(1000))
|
||||
.ToList();
|
||||
|
||||
for (int i = 0; i < actualCount; i++)
|
||||
{
|
||||
var announcement = CreateAnnouncement(i, isEnabled: true, sort: sortValues[i]);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
// Get enabled announcements via service
|
||||
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Verify: for any two consecutive items, first item's sort should be <= second item's sort
|
||||
// Since DTO doesn't have Sort field, we verify by checking the order matches expected
|
||||
var expectedOrder = sortValues.OrderBy(s => s).ToList();
|
||||
|
||||
// We need to verify the order by checking the UserName pattern which contains the index
|
||||
// The announcements should be returned in the order of their sort values
|
||||
for (int i = 0; i < result.Count - 1; i++)
|
||||
{
|
||||
// Extract the original index from UserName (format: "User_{index}")
|
||||
var currentIndex = int.Parse(result[i].UserName.Split('_')[1]);
|
||||
var nextIndex = int.Parse(result[i + 1].UserName.Split('_')[1]);
|
||||
|
||||
var currentSort = sortValues[currentIndex];
|
||||
var nextSort = sortValues[nextIndex];
|
||||
|
||||
if (currentSort > nextSort)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation**
|
||||
/// For any two consecutive items in the result, the first item's sort value
|
||||
/// should be less than or equal to the second item's sort value.
|
||||
/// **Validates: Requirements 2.3, 1.7**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool SortOrderingPreservation_ConsecutiveItemsOrdered(PositiveInt count)
|
||||
{
|
||||
var actualCount = Math.Max(2, count.Get % 15 + 2);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create announcements with sequential sort values in reverse order
|
||||
// This tests that the service correctly orders them
|
||||
for (int i = 0; i < actualCount; i++)
|
||||
{
|
||||
var announcement = CreateAnnouncement(i, isEnabled: true, sort: actualCount - i);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
// Get enabled announcements via service
|
||||
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Verify: items should be in ascending order by sort
|
||||
// The first item should have sort = 1, last should have sort = actualCount
|
||||
for (int i = 0; i < result.Count - 1; i++)
|
||||
{
|
||||
var currentIndex = int.Parse(result[i].UserName.Split('_')[1]);
|
||||
var nextIndex = int.Parse(result[i + 1].UserName.Split('_')[1]);
|
||||
|
||||
var currentSort = actualCount - currentIndex;
|
||||
var nextSort = actualCount - nextIndex;
|
||||
|
||||
if (currentSort > nextSort)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation**
|
||||
/// Announcements with the same sort value should be returned (order among them is stable).
|
||||
/// **Validates: Requirements 2.3, 1.7**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool SortOrderingPreservation_SameSortValues_AllReturned(PositiveInt count)
|
||||
{
|
||||
var actualCount = Math.Max(2, count.Get % 10 + 2);
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create announcements with the same sort value
|
||||
for (int i = 0; i < actualCount; i++)
|
||||
{
|
||||
var announcement = CreateAnnouncement(i, isEnabled: true, sort: 100);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
// Get enabled announcements via service
|
||||
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Verify: all announcements should be returned
|
||||
return result.Count == actualCount;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation**
|
||||
/// Only enabled announcements should be sorted and returned.
|
||||
/// **Validates: Requirements 2.3, 1.7**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool SortOrderingPreservation_OnlyEnabledAreSorted(
|
||||
PositiveInt enabledCount,
|
||||
PositiveInt disabledCount)
|
||||
{
|
||||
var actualEnabledCount = Math.Max(2, enabledCount.Get % 10 + 2);
|
||||
var actualDisabledCount = disabledCount.Get % 5;
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create enabled announcements with specific sort values
|
||||
for (int i = 0; i < actualEnabledCount; i++)
|
||||
{
|
||||
var announcement = CreateAnnouncement(i, isEnabled: true, sort: actualEnabledCount - i);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
}
|
||||
|
||||
// Create disabled announcements with sort values that would appear first if included
|
||||
for (int i = 0; i < actualDisabledCount; i++)
|
||||
{
|
||||
var announcement = CreateAnnouncement(i + 1000, isEnabled: false, sort: 0);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
}
|
||||
|
||||
dbContext.SaveChanges();
|
||||
|
||||
// Get enabled announcements via service
|
||||
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Verify: only enabled announcements are returned and sorted
|
||||
if (result.Count != actualEnabledCount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify ordering
|
||||
for (int i = 0; i < result.Count - 1; i++)
|
||||
{
|
||||
var currentIndex = int.Parse(result[i].UserName.Split('_')[1]);
|
||||
var nextIndex = int.Parse(result[i + 1].UserName.Split('_')[1]);
|
||||
|
||||
var currentSort = actualEnabledCount - currentIndex;
|
||||
var nextSort = actualEnabledCount - nextIndex;
|
||||
|
||||
if (currentSort > nextSort)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// **Feature: homepage-prize-announcement, Property 3: Sort Ordering Preservation**
|
||||
/// Single announcement should be returned correctly.
|
||||
/// **Validates: Requirements 2.3, 1.7**
|
||||
/// </summary>
|
||||
[Property(MaxTest = 100)]
|
||||
public bool SortOrderingPreservation_SingleAnnouncement_ReturnsCorrectly(PositiveInt sort)
|
||||
{
|
||||
var actualSort = sort.Get % 1000;
|
||||
|
||||
var (dbContext, service) = CreateService();
|
||||
|
||||
try
|
||||
{
|
||||
// Create single enabled announcement
|
||||
var announcement = CreateAnnouncement(0, isEnabled: true, sort: actualSort);
|
||||
dbContext.PrizeAnnouncements.Add(announcement);
|
||||
dbContext.SaveChanges();
|
||||
|
||||
// Get enabled announcements via service
|
||||
var result = service.GetEnabledAnnouncementsAsync().GetAwaiter().GetResult();
|
||||
|
||||
// Verify: single announcement is returned
|
||||
return result.Count == 1 && result[0].UserName == "User_0";
|
||||
}
|
||||
finally
|
||||
{
|
||||
dbContext.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user