fix 选择器
This commit is contained in:
@@ -32,11 +32,11 @@ public interface LearningTaskService {
|
||||
* @description 获取学习任务列表分页
|
||||
* @param filter 过滤条件
|
||||
* @param pageParam 分页参数
|
||||
* @return ResultDomain<TbLearningTask> 任务列表
|
||||
* @return ResultDomain<TaskVO> 任务列表(包含统计信息)
|
||||
* @author yslg
|
||||
* @since 2025-10-15
|
||||
*/
|
||||
ResultDomain<TbLearningTask> getTaskPage(TbLearningTask filter, PageParam pageParam);
|
||||
ResultDomain<TaskVO> getTaskPage(TbLearningTask filter, PageParam pageParam);
|
||||
|
||||
/**
|
||||
* @description 根据ID获取任务详情
|
||||
|
||||
@@ -98,6 +98,12 @@ public class PermissionVO extends BaseDTO{
|
||||
*/
|
||||
private String menuID;
|
||||
|
||||
/**
|
||||
* @description 父菜单ID(用于权限绑定菜单查询)
|
||||
* @author yslg
|
||||
* @since 2025-10-30
|
||||
*/
|
||||
private String parentID;
|
||||
/**
|
||||
* @description 菜单名称(用于权限绑定菜单查询)
|
||||
* @author yslg
|
||||
@@ -304,6 +310,14 @@ public class PermissionVO extends BaseDTO{
|
||||
this.menuID = menuID;
|
||||
}
|
||||
|
||||
public String getParentID() {
|
||||
return parentID;
|
||||
}
|
||||
|
||||
public void setParentID(String parentID) {
|
||||
this.parentID = parentID;
|
||||
}
|
||||
|
||||
public String getMenuName() {
|
||||
return menuName;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ public class LearningTaskController {
|
||||
* 获取任务列表分页
|
||||
*/
|
||||
@PostMapping("/page")
|
||||
public ResultDomain<TbLearningTask> getTaskPage(@RequestBody PageRequest<TbLearningTask> pageRequest) {
|
||||
public ResultDomain<TaskVO> getTaskPage(@RequestBody PageRequest<TbLearningTask> pageRequest) {
|
||||
TbLearningTask filter = pageRequest.getFilter();
|
||||
PageParam pageParam = pageRequest.getPageParam();
|
||||
return learningTaskService.getTaskPage(filter, pageParam);
|
||||
|
||||
@@ -109,16 +109,52 @@ public class SCLearningTaskServiceImpl implements LearningTaskService {
|
||||
}
|
||||
|
||||
@Override
|
||||
public ResultDomain<TbLearningTask> getTaskPage(TbLearningTask filter, PageParam pageParam) {
|
||||
ResultDomain<TbLearningTask> resultDomain = new ResultDomain<>();
|
||||
public ResultDomain<TaskVO> getTaskPage(TbLearningTask filter, PageParam pageParam) {
|
||||
ResultDomain<TaskVO> resultDomain = new ResultDomain<>();
|
||||
// 获取当前用户的部门角色
|
||||
List<UserDeptRoleVO> userDeptRoles = LoginUtil.getCurrentDeptRole();
|
||||
List<TbLearningTask> taskList = learningTaskMapper.selectLearningTasksPage(filter, pageParam, userDeptRoles);
|
||||
long total = learningTaskMapper.countLearningTasks(filter, userDeptRoles);
|
||||
pageParam.setTotalElements(total);
|
||||
pageParam.setTotalPages((int) Math.ceil((double) total / pageParam.getPageSize()));
|
||||
PageDomain<TbLearningTask> pageDomain = new PageDomain<>();
|
||||
pageDomain.setDataList(taskList);
|
||||
|
||||
// 将TbLearningTask转换为TaskVO,并添加统计信息
|
||||
List<TaskVO> taskVOList = new ArrayList<>();
|
||||
for (TbLearningTask task : taskList) {
|
||||
TaskVO taskVO = new TaskVO();
|
||||
taskVO.setLearningTask(task);
|
||||
|
||||
String taskID = task.getTaskID();
|
||||
// 获取任务的用户列表并统计各状态人数
|
||||
List<TbTaskUser> taskUsers = taskUserMapper.selectByTaskId(taskID);
|
||||
int totalUserNum = taskUsers.size();
|
||||
int completedUserNum = 0;
|
||||
int learningUserNum = 0;
|
||||
int notStartUserNum = 0;
|
||||
|
||||
for (TbTaskUser taskUser : taskUsers) {
|
||||
Integer status = taskUser.getStatus();
|
||||
if (status != null) {
|
||||
if (status == 2) {
|
||||
completedUserNum++;
|
||||
} else if (status == 1) {
|
||||
learningUserNum++;
|
||||
} else if (status == 0) {
|
||||
notStartUserNum++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
taskVO.setTotalTaskNum(totalUserNum);
|
||||
taskVO.setCompletedTaskNum(completedUserNum);
|
||||
taskVO.setLearningTaskNum(learningUserNum);
|
||||
taskVO.setNotStartTaskNum(notStartUserNum);
|
||||
|
||||
taskVOList.add(taskVO);
|
||||
}
|
||||
|
||||
PageDomain<TaskVO> pageDomain = new PageDomain<>();
|
||||
pageDomain.setDataList(taskVOList);
|
||||
pageDomain.setPageParam(pageParam);
|
||||
resultDomain.success("获取任务列表分页成功", pageDomain);
|
||||
return resultDomain;
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
<id column="id" property="id" jdbcType="VARCHAR"/>
|
||||
<result column="menu_id" property="menuID" jdbcType="VARCHAR"/>
|
||||
<result column="menu_name" property="menuName" jdbcType="VARCHAR"/>
|
||||
<result column="parent_id" property="parentID" jdbcType="VARCHAR"/>
|
||||
<result column="menu_url" property="menuUrl" jdbcType="VARCHAR"/>
|
||||
</resultMap>
|
||||
|
||||
@@ -301,6 +302,7 @@
|
||||
tsm.id,
|
||||
tsm.menu_id,
|
||||
tsm.name AS menu_name,
|
||||
tsm.parent_id AS parent_id,
|
||||
tsm.url AS menu_url
|
||||
FROM tb_sys_menu tsm
|
||||
INNER JOIN tb_sys_menu_permission tsmp ON tsmp.menu_id = tsm.menu_id
|
||||
|
||||
@@ -53,8 +53,8 @@ export const learningTaskApi = {
|
||||
* @param filter 过滤条件
|
||||
* @returns Promise<ResultDomain<LearningTask>>
|
||||
*/
|
||||
async getTaskPage(pageParam: PageParam, filter: LearningTask): Promise<ResultDomain<LearningTask>> {
|
||||
const response = await api.post<LearningTask>(`${this.learningTaskPrefix}/page`, {pageParam, filter});
|
||||
async getTaskPage(pageParam: PageParam, filter: LearningTask): Promise<ResultDomain<TaskVO>> {
|
||||
const response = await api.post<TaskVO>(`${this.learningTaskPrefix}/page`, {pageParam, filter});
|
||||
return response.data;
|
||||
},
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { api } from '@/apis/index';
|
||||
import type { SysPermission, SysRole, SysRolePermission, ResultDomain } from '@/types';
|
||||
import type { SysPermission, SysRole, SysRolePermission, ResultDomain, UserDeptRoleVO } from '@/types';
|
||||
|
||||
/**
|
||||
* 角色API服务
|
||||
@@ -17,8 +17,8 @@ export const roleApi = {
|
||||
* @author yslg
|
||||
* @ since 2025-10-09
|
||||
*/
|
||||
async getAllRoles(): Promise<ResultDomain<SysRole>> {
|
||||
const response = await api.post<SysRole>('/roles/all');
|
||||
async getAllRoles(): Promise<ResultDomain<UserDeptRoleVO>> {
|
||||
const response = await api.post<UserDeptRoleVO>('/roles/all');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
6
schoolNewsWeb/src/assets/imgs/calendar-icon.svg
Normal file
6
schoolNewsWeb/src/assets/imgs/calendar-icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.333 1.333L5.333 4" stroke="#4A5565" stroke-width="1.333" stroke-linecap="round"/>
|
||||
<path d="M10.667 1.333L10.667 4" stroke="#4A5565" stroke-width="1.333" stroke-linecap="round"/>
|
||||
<path d="M2 2.667L2 14.667L14 14.667L14 2.667L2 2.667Z" stroke="#4A5565" stroke-width="1.333"/>
|
||||
<path d="M2 6.667L14 6.667" stroke="#4A5565" stroke-width="1.333" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 465 B |
6
schoolNewsWeb/src/assets/imgs/dept-icon.svg
Normal file
6
schoolNewsWeb/src/assets/imgs/dept-icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.333 10L1.333 10L9.333 14C9.333 14 10 14 10 14C10.667 14 10.667 14 10.667 14L12.667 13.09L14.667 12.08C14 12 13.333 12 12.667 12L3.333 12C3.333 12 2 12 2 12L1.333 10Z" stroke="#4A5565" stroke-width="1.333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 2.083L10 5.166" stroke="#4A5565" stroke-width="1.333" stroke-linecap="round"/>
|
||||
<path d="M12 10.09L12 14" stroke="#4A5565" stroke-width="1.333" stroke-linecap="round"/>
|
||||
<path d="M3.333 2C4.066 2 4.666 2.597 4.666 3.333L4.666 7.333C4.666 8.069 4.066 8.666 3.333 8.666C2.6 8.666 2 8.069 2 7.333L2 3.333C2 2.597 2.6 2 3.333 2Z" stroke="#4A5565" stroke-width="1.333"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 731 B |
6
schoolNewsWeb/src/assets/imgs/usermange.svg
Normal file
6
schoolNewsWeb/src/assets/imgs/usermange.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.6667 14V12.6667C10.6667 11.9594 10.3857 11.2811 9.88561 10.781C9.38552 10.281 8.70724 10 7.99999 10H3.99999C3.29275 10 2.61447 10.281 2.11438 10.781C1.61428 11.2811 1.33333 11.9594 1.33333 12.6667V14" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.6667 2.08545C11.2385 2.2337 11.7449 2.56763 12.1065 3.03482C12.468 3.50202 12.6641 4.07604 12.6641 4.66678C12.6641 5.25752 12.468 5.83154 12.1065 6.29874C11.7449 6.76594 11.2385 7.09987 10.6667 7.24812" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M14.6667 14.0002V12.6669C14.6662 12.0761 14.4696 11.5021 14.1076 11.0351C13.7456 10.5682 13.2388 10.2346 12.6667 10.0869" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.99999 7.33333C7.47275 7.33333 8.66666 6.13943 8.66666 4.66667C8.66666 3.19391 7.47275 2 5.99999 2C4.52724 2 3.33333 3.19391 3.33333 4.66667C3.33333 6.13943 4.52724 7.33333 5.99999 7.33333Z" stroke="#0A0A0A" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
462
schoolNewsWeb/src/assets/styles/common.scss
Normal file
462
schoolNewsWeb/src/assets/styles/common.scss
Normal file
@@ -0,0 +1,462 @@
|
||||
/**
|
||||
* @description 全局通用样式
|
||||
* @author yslg
|
||||
* @since 2025-10-30
|
||||
* @figma https://www.figma.com/design/4aM0yqyoAjtW2jTZcqPAtN
|
||||
*/
|
||||
|
||||
// ============ 设计令牌 (Design Tokens) ============
|
||||
|
||||
// 颜色
|
||||
$color-primary: #C62828; // 主色(红色)
|
||||
$color-primary-hover: #B71C1C; // 主色悬停
|
||||
$color-text-primary: #1D2129; // 主要文字
|
||||
$color-text-secondary: #4E5969; // 次要文字
|
||||
$color-text-disabled: #C9CDD4; // 禁用文字/图标
|
||||
$color-bg-secondary: #F2F3F5; // 次要背景
|
||||
$color-bg-white: #FFFFFF; // 白色背景
|
||||
$color-border: #dcdfe6; // 边框
|
||||
|
||||
// 字体
|
||||
$font-family-cn: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
$font-family-en: 'Nunito Sans', sans-serif;
|
||||
$font-size-base: 14px;
|
||||
$line-height-base: 1.571428571em;
|
||||
|
||||
// 圆角
|
||||
$border-radius-small: 2px;
|
||||
$border-radius-medium: 4px;
|
||||
$border-radius-large: 8px;
|
||||
|
||||
// 间距
|
||||
$spacing-xs: 4px;
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 12px;
|
||||
$spacing-lg: 16px;
|
||||
$spacing-xl: 20px;
|
||||
$spacing-xxl: 24px;
|
||||
|
||||
// ============ 按钮样式 ============
|
||||
|
||||
// 主要操作按钮
|
||||
.btn-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
background: #409eff;
|
||||
border: none;
|
||||
border-radius: $border-radius-medium;
|
||||
color: $color-bg-white;
|
||||
font-size: $font-size-base;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 创建/新增按钮(红色主题)
|
||||
.btn-create {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
padding: $spacing-sm $spacing-lg;
|
||||
background: #E7000B;
|
||||
border: none;
|
||||
border-radius: $border-radius-large;
|
||||
color: $color-bg-white;
|
||||
font-size: $font-size-base;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
|
||||
.icon {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: #C70009;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索按钮
|
||||
.btn-search {
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
border-radius: $border-radius-medium;
|
||||
font-size: $font-size-base;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: none;
|
||||
background: #409eff;
|
||||
color: $color-bg-white;
|
||||
|
||||
&:hover {
|
||||
background: #66b1ff;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置按钮
|
||||
.btn-reset {
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
border-radius: $border-radius-medium;
|
||||
font-size: $font-size-base;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: 1px solid $color-border;
|
||||
background: $color-bg-white;
|
||||
color: #606266;
|
||||
|
||||
&:hover {
|
||||
color: #409eff;
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
// 危险操作按钮
|
||||
.btn-danger {
|
||||
padding: $spacing-sm $spacing-xl;
|
||||
border-radius: $border-radius-medium;
|
||||
font-size: $font-size-base;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border: none;
|
||||
background: #f56c6c;
|
||||
color: $color-bg-white;
|
||||
|
||||
&:hover {
|
||||
background: #f78989;
|
||||
}
|
||||
}
|
||||
|
||||
// 链接样式按钮(用于表格操作列)
|
||||
.btn-link {
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border-radius: $border-radius-medium;
|
||||
min-width: 64px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
color: $color-bg-white;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.btn-primary {
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
&.btn-warning {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
&.btn-success {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
&.btn-danger {
|
||||
background: #f56c6c;
|
||||
}
|
||||
|
||||
&.btn-info {
|
||||
background: #909399;
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 分页样式 (基于Figma设计) ============
|
||||
|
||||
// 分页容器
|
||||
.pagination-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: $spacing-md 0;
|
||||
margin-top: 32px;
|
||||
background: #F9FAFB;
|
||||
border-radius: $border-radius-large;
|
||||
|
||||
// Element Plus 分页组件自定义样式
|
||||
:deep(.el-pagination) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: 0 $spacing-xxl;
|
||||
|
||||
// 总条数文本
|
||||
.el-pagination__total {
|
||||
font-family: $font-family-cn;
|
||||
font-size: $font-size-base;
|
||||
font-weight: 400;
|
||||
line-height: $line-height-base;
|
||||
color: $color-text-primary;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
// 每页条数选择器
|
||||
.el-pagination__sizes {
|
||||
margin: 0;
|
||||
|
||||
.el-select {
|
||||
.el-select__wrapper {
|
||||
background: $color-bg-secondary;
|
||||
border: none;
|
||||
border-radius: $border-radius-small;
|
||||
padding: $spacing-xs $spacing-md;
|
||||
min-height: 32px;
|
||||
box-shadow: none;
|
||||
|
||||
.el-select__selected-item {
|
||||
font-family: $font-family-cn;
|
||||
font-size: $font-size-base;
|
||||
font-weight: 400;
|
||||
line-height: $line-height-base;
|
||||
color: $color-text-primary;
|
||||
}
|
||||
|
||||
.el-select__suffix {
|
||||
.el-icon {
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: darken($color-bg-secondary, 3%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 上一页/下一页按钮
|
||||
.btn-prev,
|
||||
.btn-next {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
min-width: auto;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: $border-radius-small;
|
||||
|
||||
.el-icon {
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba($color-primary, 0.08);
|
||||
|
||||
.el-icon {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: transparent;
|
||||
cursor: not-allowed;
|
||||
|
||||
.el-icon {
|
||||
color: $color-text-disabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 页码列表
|
||||
.el-pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
|
||||
li {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: $border-radius-small;
|
||||
padding: $spacing-xs;
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
line-height: 24px;
|
||||
font-family: $font-family-en;
|
||||
font-size: $font-size-base;
|
||||
font-weight: 600;
|
||||
color: $color-text-secondary;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
|
||||
&:hover:not(.is-active):not(.is-disabled) {
|
||||
background: rgba($color-primary, 0.08);
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: $color-primary;
|
||||
color: $color-bg-white;
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
color: $color-text-disabled;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
// 更多页省略号
|
||||
&.more {
|
||||
background: transparent;
|
||||
color: $color-text-secondary;
|
||||
|
||||
&:hover {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转输入框
|
||||
.el-pagination__jump {
|
||||
margin-left: 0;
|
||||
font-family: $font-family-cn;
|
||||
font-size: $font-size-base;
|
||||
color: $color-text-primary;
|
||||
|
||||
.el-input {
|
||||
.el-input__wrapper {
|
||||
background: $color-bg-white;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $border-radius-small;
|
||||
padding: $spacing-xs $spacing-sm;
|
||||
box-shadow: none;
|
||||
width: 60px;
|
||||
|
||||
.el-input__inner {
|
||||
font-family: $font-family-en;
|
||||
font-size: $font-size-base;
|
||||
font-weight: 600;
|
||||
color: $color-text-primary;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: $color-primary;
|
||||
}
|
||||
|
||||
&.is-focus {
|
||||
border-color: $color-primary;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 加载状态 ============
|
||||
|
||||
.loading-container,
|
||||
.empty-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 100px 20px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid $color-primary;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
// ============ 表格加载和空状态 ============
|
||||
|
||||
.loading-cell,
|
||||
.empty-cell {
|
||||
text-align: center;
|
||||
padding: 60px 20px !important;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
// ============ 搜索栏 ============
|
||||
|
||||
.search-bar {
|
||||
background: $color-bg-white;
|
||||
padding: $spacing-xl;
|
||||
border-radius: $border-radius-large;
|
||||
margin-bottom: $spacing-xl;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
gap: $spacing-lg;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.search-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.search-label {
|
||||
font-size: $font-size-base;
|
||||
font-weight: 500;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.search-input,
|
||||
.search-select {
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 1px solid $color-border;
|
||||
border-radius: $border-radius-medium;
|
||||
font-size: $font-size-base;
|
||||
color: #606266;
|
||||
transition: border-color 0.3s;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #409eff;
|
||||
}
|
||||
}
|
||||
|
||||
.search-select {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
// ============ 工具栏 ============
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
@@ -818,14 +818,21 @@ function moveBackSelected() {
|
||||
selectedTarget.value.includes(getItemId(item))
|
||||
);
|
||||
|
||||
// 添加回可选列表
|
||||
availableList.value.push(...itemsToMoveBack);
|
||||
|
||||
// 从已选列表中移除
|
||||
targetList.value = targetList.value.filter(item =>
|
||||
!selectedTarget.value.includes(getItemId(item))
|
||||
);
|
||||
|
||||
// 如果是树形模式,重新构建树
|
||||
if (props.useTree && props.treeTransform) {
|
||||
// 清空展开状态
|
||||
expandedKeys.value.clear();
|
||||
// 重新构建树结构
|
||||
treeData.value = props.treeTransform(availableList.value);
|
||||
// 重新展开所有节点
|
||||
expandAllNodes(treeData.value);
|
||||
}
|
||||
|
||||
@@ -836,12 +843,19 @@ function moveBackSelected() {
|
||||
function moveBackToAvailable(itemId: string) {
|
||||
const item = targetList.value.find(i => getItemId(i) === itemId);
|
||||
if (item) {
|
||||
// 添加回可选列表
|
||||
availableList.value.push(item);
|
||||
|
||||
// 从已选列表中移除
|
||||
targetList.value = targetList.value.filter(i => getItemId(i) !== itemId);
|
||||
|
||||
// 如果是树形模式,重新构建树
|
||||
if (props.useTree && props.treeTransform) {
|
||||
// 清空展开状态
|
||||
expandedKeys.value.clear();
|
||||
// 重新构建树结构
|
||||
treeData.value = props.treeTransform(availableList.value);
|
||||
// 重新展开所有节点
|
||||
expandAllNodes(treeData.value);
|
||||
}
|
||||
}
|
||||
@@ -849,12 +863,19 @@ function moveBackToAvailable(itemId: string) {
|
||||
|
||||
// 移回所有项到可选区域
|
||||
function moveBackAll() {
|
||||
// 添加回可选列表
|
||||
availableList.value.push(...targetList.value);
|
||||
|
||||
// 清空已选列表
|
||||
targetList.value = [];
|
||||
|
||||
// 如果是树形模式,重新构建树
|
||||
if (props.useTree && props.treeTransform) {
|
||||
// 清空展开状态
|
||||
expandedKeys.value.clear();
|
||||
// 重新构建树结构
|
||||
treeData.value = props.treeTransform(availableList.value);
|
||||
// 重新展开所有节点
|
||||
expandAllNodes(treeData.value);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import { setupPermissionDirectives } from "@/directives/permission";
|
||||
// 引入 Quill 富文本编辑器样式(全局)
|
||||
import "quill/dist/quill.snow.css";
|
||||
|
||||
// 引入全局通用样式
|
||||
import "@/assets/styles/common.scss";
|
||||
|
||||
// 初始化应用
|
||||
async function initApp() {
|
||||
const app = createApp(App);
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface SysPermission extends BaseDTO {
|
||||
moduleCode?: string;
|
||||
moduleDescription?: string;
|
||||
menuID?: string;
|
||||
parentID?: string;
|
||||
menuName?: string;
|
||||
menuUrl?: string;
|
||||
roleID?: string;
|
||||
|
||||
@@ -276,10 +276,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" v-if="currentLog.responseResult">
|
||||
<div class="detail-section" v-if="currentLog.responseData">
|
||||
<h4 class="detail-title">响应结果</h4>
|
||||
<div class="detail-content">
|
||||
<pre class="code-block">{{ currentLog.responseResult }}</pre>
|
||||
<pre class="code-block">{{ currentLog.responseData }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
354
schoolNewsWeb/src/views/admin/manage/study/TaskCard.vue
Normal file
354
schoolNewsWeb/src/views/admin/manage/study/TaskCard.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="task-card">
|
||||
<!-- 卡片头部 -->
|
||||
<div class="card-header">
|
||||
<div class="header-content">
|
||||
<h3 class="task-title">{{ task.learningTask.name }}</h3>
|
||||
<div class="task-meta">
|
||||
<!-- <div class="meta-item">
|
||||
<img class="icon" src="@/assets/imgs/usermange.svg" alt="部门" />
|
||||
<span class="meta-text">{{ getDeptName }}</span>
|
||||
</div> -->
|
||||
<div class="meta-item">
|
||||
<img class="icon" src="@/assets/imgs/calendar-icon.svg" alt="日期" />
|
||||
<span class="meta-text">截止: {{ formatDate(task.learningTask.endTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-badge" :class="getStatusClass(task.learningTask.status)">
|
||||
{{ getStatusText(task.learningTask.status, task.learningTask.startTime, task.learningTask.endTime) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 统计信息 -->
|
||||
<div class="card-stats">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">分配人数</div>
|
||||
<div class="stat-value">{{ task.totalTaskNum || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-item stat-completed">
|
||||
<div class="stat-label">已完成</div>
|
||||
<div class="stat-value stat-value-completed">{{ task.completedTaskNum || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-item stat-rate">
|
||||
<div class="stat-label">完成率</div>
|
||||
<div class="stat-value stat-value-rate">{{ completionRate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 进度条 -->
|
||||
<div class="card-progress">
|
||||
<div class="progress-header">
|
||||
<span class="progress-label">完成进度</span>
|
||||
<span class="progress-value">{{ completionRate }}</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" :style="{ width: completionRate }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="card-footer">
|
||||
<div class="card-actions">
|
||||
<button class="btn-link btn-primary" @click="$emit('view', task)">查看</button>
|
||||
<button class="btn-link btn-warning" @click="$emit('edit', task)" v-if="task.learningTask.status !== 1">编辑</button>
|
||||
<button class="btn-link btn-success" @click="$emit('publish', task)" v-if="task.learningTask.status !== 1">发布</button>
|
||||
<button class="btn-link btn-warning" @click="$emit('unpublish', task)" v-if="task.learningTask.status === 1">下架</button>
|
||||
<button class="btn-link btn-primary" @click="$emit('statistics', task)" v-if="task.learningTask.status !== 0">统计</button>
|
||||
<button class="btn-link btn-warning" @click="$emit('update-user', task)">修改人员</button>
|
||||
<button class="btn-link btn-danger" @click="$emit('delete', task)" v-if="task.learningTask.status === 0">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { TaskVO } from '@/types/study';
|
||||
|
||||
interface Props {
|
||||
task: TaskVO;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
defineEmits<{
|
||||
view: [task: TaskVO];
|
||||
edit: [task: TaskVO];
|
||||
publish: [task: TaskVO];
|
||||
unpublish: [task: TaskVO];
|
||||
statistics: [task: TaskVO];
|
||||
'update-user': [task: TaskVO];
|
||||
delete: [task: TaskVO];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 获取部门名称
|
||||
*/
|
||||
// const getDeptName = computed(() => {
|
||||
// if (props.task.taskUsers && props.task.taskUsers.length > 0) {
|
||||
// const deptNames = new Set(props.task.taskUsers.map(u => u.deptName).filter(Boolean));
|
||||
// if (deptNames.size === 0) return '全校';
|
||||
// if (deptNames.size === 1) return Array.from(deptNames)[0] || '全校';
|
||||
// return '多个部门';
|
||||
// }
|
||||
// return '全校';
|
||||
// });
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
function formatDate(dateStr?: string): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).replace(/\//g, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态样式类
|
||||
*/
|
||||
function getStatusClass(status?: number): string {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return 'status-draft';
|
||||
case 1:
|
||||
return 'status-ongoing';
|
||||
case 2:
|
||||
return 'status-ended';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态文本
|
||||
*/
|
||||
function getStatusText(status?: number, startTime?: string, endTime?: string): string {
|
||||
if (status === 0) return '草稿';
|
||||
if (status === 1) {
|
||||
const now = new Date();
|
||||
const start = new Date(startTime!);
|
||||
const end = new Date(endTime!);
|
||||
if (now >= start && now <= end) {
|
||||
return '进行中';
|
||||
} else if (now < start) {
|
||||
return '未开始';
|
||||
} else {
|
||||
return '已结束';
|
||||
}
|
||||
}
|
||||
if (status === 2) return '下架';
|
||||
return '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算完成率
|
||||
*/
|
||||
const completionRate = computed(() => {
|
||||
const total = props.task.totalTaskNum || 0;
|
||||
const completed = props.task.completedTaskNum || 0;
|
||||
if (total === 0) return '0%';
|
||||
const rate = Math.round((completed / total) * 100);
|
||||
return `${rate}%`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.task-card {
|
||||
background: #F9FAFB;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
// 卡片头部
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1.5em;
|
||||
letter-spacing: -1.953125%;
|
||||
color: #101828;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta-text {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.428571em;
|
||||
letter-spacing: -1.07421875%;
|
||||
color: #4A5565;
|
||||
}
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px 8px;
|
||||
height: 22px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.333333em;
|
||||
white-space: nowrap;
|
||||
|
||||
&.status-draft {
|
||||
background: #F4F4F5;
|
||||
color: #909399;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
&.status-ongoing {
|
||||
background: #DBEAFE;
|
||||
color: #1447E6;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
&.status-ended {
|
||||
background: #F0F9FF;
|
||||
color: #409EFF;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
}
|
||||
|
||||
// 统计信息
|
||||
.card-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
background: #FFFFFF;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.428571em;
|
||||
letter-spacing: -1.07421875%;
|
||||
color: #4A5565;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
line-height: 1.4em;
|
||||
letter-spacing: -2.24609375%;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.stat-value-completed {
|
||||
color: #00A63E;
|
||||
}
|
||||
|
||||
.stat-value-rate {
|
||||
color: #155DFC;
|
||||
}
|
||||
|
||||
// 进度条
|
||||
.card-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.progress-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.428571em;
|
||||
letter-spacing: -1.07421875%;
|
||||
color: #4A5565;
|
||||
}
|
||||
|
||||
.progress-value {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 1.428571em;
|
||||
letter-spacing: -1.07421875%;
|
||||
color: #101828;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: rgba(3, 2, 19, 0.2);
|
||||
border-radius: 16777200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #030213;
|
||||
border-radius: 16777200px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
// 卡片底部
|
||||
.card-footer {
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
// 操作按钮
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1
schoolNewsWeb/src/views/admin/manage/study/index.ts
Normal file
1
schoolNewsWeb/src/views/admin/manage/study/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as TaskCard } from './TaskCard.vue';
|
||||
@@ -409,7 +409,16 @@ function resetForm() {
|
||||
|
||||
// 获取所有可选角色的接口
|
||||
async function fetchAllRoles() {
|
||||
return await roleApi.getAllRoles();
|
||||
const result = await roleApi.getAllRoles();
|
||||
if (result.success && result.dataList) {
|
||||
result.dataList = result.dataList.map(item => ({
|
||||
roleID: item.roleID,
|
||||
name: item.roleName, // roleName -> name
|
||||
description: item.roleDescription // roleDescription -> description
|
||||
}));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 获取部门已绑定角色的接口
|
||||
|
||||
@@ -638,7 +638,15 @@ async function fetchPermissionMenus() {
|
||||
|
||||
// 获取所有角色的接口
|
||||
async function fetchAllRoles() {
|
||||
return await roleApi.getAllRoles();
|
||||
const result = await roleApi.getAllRoles();
|
||||
if (result.success && result.dataList) {
|
||||
result.dataList = result.dataList.map(item => ({
|
||||
roleID: item.roleID,
|
||||
name: item.roleName,
|
||||
description: item.roleDescription
|
||||
}));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// 获取权限已绑定角色的接口
|
||||
|
||||
@@ -19,9 +19,9 @@
|
||||
border
|
||||
stripe
|
||||
>
|
||||
<el-table-column prop="name" label="角色名称" min-width="200" />
|
||||
<el-table-column prop="roleName" label="角色名称" min-width="200" />
|
||||
<el-table-column prop="roleID" label="角色ID" min-width="150" />
|
||||
<el-table-column prop="description" label="角色描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="roleDescription" label="角色描述" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="creatorName" label="创建人" width="120" />
|
||||
<el-table-column prop="createTime" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
@@ -99,7 +99,7 @@
|
||||
<!-- 绑定权限对话框 -->
|
||||
<GenericSelector
|
||||
v-model:visible="bindPermissionDialogVisible"
|
||||
:title="`绑定权限 - ${currentRole?.name || ''}`"
|
||||
:title="`绑定权限 - ${currentRole?.roleName || ''}`"
|
||||
left-title="可选权限"
|
||||
right-title="已选权限"
|
||||
:fetch-available-api="fetchAllPermissions"
|
||||
@@ -122,7 +122,7 @@
|
||||
<script setup lang="ts">
|
||||
import { roleApi } from '@/apis/system/role';
|
||||
import { permissionApi } from '@/apis/system/permission';
|
||||
import { SysRole, SysPermission } from '@/types';
|
||||
import { SysRole, SysPermission, UserDeptRoleVO } from '@/types';
|
||||
import { AdminLayout } from '@/views/admin';
|
||||
import { GenericSelector } from '@/components/base';
|
||||
|
||||
@@ -134,7 +134,7 @@ import { ElMessage, ElMessageBox, FormInstance, FormRules } from 'element-plus';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
|
||||
// 数据状态
|
||||
const roleList = ref<SysRole[]>([]);
|
||||
const roleList = ref<UserDeptRoleVO[]>([]);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
@@ -142,7 +142,7 @@ const submitting = ref(false);
|
||||
// 移除:改为由 GenericSelector 通过 API 加载
|
||||
// const permissionList = ref<SysPermission[]>([]);
|
||||
// const initialBoundPermissions = ref<SysPermission[]>([]);
|
||||
const currentRole = ref<SysRole | null>(null);
|
||||
const currentRole = ref<UserDeptRoleVO | null>(null);
|
||||
|
||||
// 对话框状态
|
||||
const dialogVisible = ref(false);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<button class="btn-back" @click="handleCancel">
|
||||
<span class="arrow-left">←</span> 返回
|
||||
</button>
|
||||
<h2 class="page-title">{{ taskID ? '编辑学习任务' : '创建学习任务' }}</h2>
|
||||
<h2 class="page-title">{{ taskId ? '编辑学习任务' : '创建学习任务' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="task-form">
|
||||
@@ -152,7 +152,7 @@
|
||||
<!-- 操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<button class="btn-primary" @click="handleSubmit" :disabled="submitting">
|
||||
{{ submitting ? '提交中...' : (taskID ? '更新任务' : '创建任务') }}
|
||||
{{ submitting ? '提交中...' : (taskId ? '更新任务' : '创建任务') }}
|
||||
</button>
|
||||
<button class="btn-default" @click="handleCancel">取消</button>
|
||||
</div>
|
||||
@@ -252,53 +252,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 用户选择器弹窗 -->
|
||||
<div v-if="showUserSelector" class="modal-overlay" @click.self="showUserSelector = false">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">选择学习用户</h3>
|
||||
<button class="modal-close" @click="showUserSelector = false">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="userSearchKeyword"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="搜索用户名或手机号"
|
||||
@input="searchUsers"
|
||||
<!-- 用户选择器组件 -->
|
||||
<GenericSelector
|
||||
v-model:visible="showUserSelector"
|
||||
mode="add"
|
||||
title="选择学习用户"
|
||||
left-title="可选用户"
|
||||
right-title="已选用户"
|
||||
:fetch-available-api="fetchAllUsers"
|
||||
:fetch-selected-api="fetchSelectedUsers"
|
||||
:filter-selected="filterUsers"
|
||||
:loading="userLoading"
|
||||
:item-config="{ id: 'id', label: 'username', sublabel: 'deptName' }"
|
||||
:use-tree="true"
|
||||
:tree-transform="transformUsersToTree"
|
||||
:tree-props="{ children: 'children', label: 'displayName', id: 'id' }"
|
||||
:only-leaf-selectable="true"
|
||||
unit-name="人"
|
||||
search-placeholder="搜索用户..."
|
||||
@confirm="handleUserSelectConfirm"
|
||||
@cancel="showUserSelector = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="userLoading" class="loading-tip">加载中...</div>
|
||||
<div v-else-if="availableUsers.length === 0" class="empty-tip">暂无可选用户</div>
|
||||
<div v-else class="selector-list">
|
||||
<label
|
||||
v-for="user in availableUsers"
|
||||
:key="user.id"
|
||||
class="selector-item"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="user.id"
|
||||
:checked="isUserSelected(user.id)"
|
||||
@change="toggleUser(user)"
|
||||
/>
|
||||
<div class="selector-item-content">
|
||||
<h4 class="selector-item-title">{{ user.username }}</h4>
|
||||
<p class="selector-item-meta">邮箱: {{ user.email || '未知' }} | {{ user.phone || '-' }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn-primary" @click="showUserSelector = false">确定</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -308,6 +283,7 @@ import { courseApi } from '@/apis/study';
|
||||
import { resourceApi } from '@/apis/resource';
|
||||
import { userApi } from '@/apis/system';
|
||||
import { learningTaskApi } from '@/apis/study';
|
||||
import { GenericSelector } from '@/components/base';
|
||||
import type { TaskVO, Course, TaskItemVO } from '@/types/study';
|
||||
import type { Resource } from '@/types/resource';
|
||||
import type { SysUser } from '@/types/user';
|
||||
@@ -321,7 +297,7 @@ defineExpose({
|
||||
});
|
||||
|
||||
interface Props {
|
||||
taskID?: string;
|
||||
taskId?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -369,7 +345,6 @@ const showUserSelector = ref(false);
|
||||
// 搜索关键词
|
||||
const courseSearchKeyword = ref('');
|
||||
const resourceSearchKeyword = ref('');
|
||||
const userSearchKeyword = ref('');
|
||||
|
||||
// 加载状态
|
||||
const courseLoading = ref(false);
|
||||
@@ -377,22 +352,52 @@ const resourceLoading = ref(false);
|
||||
const userLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.taskID) {
|
||||
loadTask();
|
||||
onMounted(async () => {
|
||||
// 先加载所有可选项
|
||||
await Promise.all([
|
||||
loadCourses(),
|
||||
loadResources(),
|
||||
loadUsers()
|
||||
]);
|
||||
|
||||
// 如果是编辑模式,加载任务数据并恢复选择
|
||||
if (props.taskId) {
|
||||
await loadTask();
|
||||
}
|
||||
loadCourses();
|
||||
loadResources();
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
// 加载任务数据
|
||||
async function loadTask() {
|
||||
try {
|
||||
const res = await learningTaskApi.getTaskById(props.taskID!);
|
||||
const res = await learningTaskApi.getTaskById(props.taskId!);
|
||||
if (res.success && res.data) {
|
||||
taskData.value = res.data as TaskVO;
|
||||
// TODO: 根据 taskCourses、taskResources、taskUsers 恢复选中状态
|
||||
|
||||
// 转换时间格式为 datetime-local 可接受的格式
|
||||
if (taskData.value.learningTask.startTime) {
|
||||
taskData.value.learningTask.startTime = formatDateTimeLocal(taskData.value.learningTask.startTime);
|
||||
}
|
||||
if (taskData.value.learningTask.endTime) {
|
||||
taskData.value.learningTask.endTime = formatDateTimeLocal(taskData.value.learningTask.endTime);
|
||||
}
|
||||
|
||||
// 恢复课程选择
|
||||
if (taskData.value.taskCourses && taskData.value.taskCourses.length > 0) {
|
||||
const courseIds = taskData.value.taskCourses.map(tc => tc.courseID);
|
||||
selectedCourses.value = availableCourses.value.filter(c => courseIds.includes(c.courseID));
|
||||
}
|
||||
|
||||
// 恢复资源选择
|
||||
if (taskData.value.taskResources && taskData.value.taskResources.length > 0) {
|
||||
const resourceIds = taskData.value.taskResources.map(tr => tr.resourceID);
|
||||
selectedResources.value = availableResources.value.filter(r => resourceIds.includes(r.resourceID));
|
||||
}
|
||||
|
||||
// 恢复用户选择
|
||||
if (taskData.value.taskUsers && taskData.value.taskUsers.length > 0) {
|
||||
const userIds = taskData.value.taskUsers.map(tu => tu.userID);
|
||||
selectedUsers.value = availableUsers.value.filter(u => userIds.includes(u.id));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载任务失败:', error);
|
||||
@@ -400,6 +405,29 @@ async function loadTask() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ISO 8601 时间格式转换为 datetime-local 输入框需要的格式
|
||||
* @param dateTimeString ISO 8601 格式的时间字符串
|
||||
* @returns YYYY-MM-DDTHH:mm 格式的字符串
|
||||
*/
|
||||
function formatDateTimeLocal(dateTimeString: string): string {
|
||||
if (!dateTimeString) return '';
|
||||
|
||||
const date = new Date(dateTimeString);
|
||||
|
||||
// 检查日期是否有效
|
||||
if (isNaN(date.getTime())) return '';
|
||||
|
||||
// 获取本地时间的各个部分
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
// 加载课程列表
|
||||
async function loadCourses() {
|
||||
courseLoading.value = true;
|
||||
@@ -474,19 +502,132 @@ async function loadUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索用户
|
||||
function searchUsers() {
|
||||
if (!userSearchKeyword.value) {
|
||||
loadUsers();
|
||||
return;
|
||||
/**
|
||||
* 获取所有用户的API函数
|
||||
*/
|
||||
async function fetchAllUsers() {
|
||||
const filter: any = { username: undefined };
|
||||
return await userApi.getUserList(filter);
|
||||
}
|
||||
|
||||
const keyword = userSearchKeyword.value.toLowerCase();
|
||||
availableUsers.value = availableUsers.value.filter(user =>
|
||||
user.username?.toLowerCase().includes(keyword) ||
|
||||
user.phone?.includes(keyword) ||
|
||||
user.email?.toLowerCase().includes(keyword)
|
||||
);
|
||||
/**
|
||||
* 获取已选用户的API函数
|
||||
*/
|
||||
async function fetchSelectedUsers() {
|
||||
return {
|
||||
success: true,
|
||||
dataList: selectedUsers.value,
|
||||
code: 200,
|
||||
message: '',
|
||||
login: true,
|
||||
auth: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 过滤已选用户
|
||||
*/
|
||||
function filterUsers(available: any[], selected: any[]) {
|
||||
const selectedIds = new Set(selected.map(item => item.id));
|
||||
return available.filter(item => !selectedIds.has(item.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 将用户扁平数据转换为树形结构(按部门分组)
|
||||
*/
|
||||
function transformUsersToTree(flatData: any[]): any[] {
|
||||
if (!flatData || flatData.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// 按部门分组
|
||||
const deptMap = new Map<string, any>();
|
||||
const tree: any[] = [];
|
||||
|
||||
flatData.forEach(item => {
|
||||
const deptID = item.deptID || 'unknown';
|
||||
const deptName = item.deptName || '未分配部门';
|
||||
// 优先使用 parentID,如果不存在则使用 parentDeptID
|
||||
const parentID = item.parentID || item.parentDeptID;
|
||||
|
||||
if (!deptMap.has(deptID)) {
|
||||
// 创建部门节点
|
||||
deptMap.set(deptID, {
|
||||
id: `dept_${deptID}`,
|
||||
displayName: deptName,
|
||||
deptID: deptID,
|
||||
deptName: deptName,
|
||||
parentDeptID: parentID,
|
||||
children: [],
|
||||
isDept: true
|
||||
});
|
||||
}
|
||||
|
||||
// 添加用户到部门的children中
|
||||
const deptNode = deptMap.get(deptID);
|
||||
if (deptNode) {
|
||||
deptNode.children.push({
|
||||
...item,
|
||||
displayName: item.username || item.id,
|
||||
isDept: false
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 构建部门层级关系
|
||||
const deptNodes = Array.from(deptMap.values());
|
||||
const deptTreeMap = new Map<string, any>();
|
||||
|
||||
// 初始化所有部门节点
|
||||
deptNodes.forEach(dept => {
|
||||
deptTreeMap.set(dept.deptID, { ...dept });
|
||||
});
|
||||
|
||||
// 构建部门树
|
||||
deptNodes.forEach(dept => {
|
||||
const node = deptTreeMap.get(dept.deptID);
|
||||
if (!node) return;
|
||||
|
||||
if (!dept.parentDeptID || dept.parentDeptID === '0' || dept.parentDeptID === '') {
|
||||
// 根部门
|
||||
tree.push(node);
|
||||
} else {
|
||||
// 子部门
|
||||
const parent = deptTreeMap.get(dept.parentDeptID);
|
||||
if (parent) {
|
||||
// 将用户节点暂存
|
||||
const users = node.children || [];
|
||||
|
||||
// 添加部门到父部门(先添加部门)
|
||||
if (!parent.children) {
|
||||
parent.children = [];
|
||||
}
|
||||
|
||||
// 找到部门子节点的插入位置(部门应该在用户之前)
|
||||
const firstUserIndex = parent.children.findIndex((child: any) => !child.isDept);
|
||||
const insertIndex = firstUserIndex === -1 ? parent.children.length : firstUserIndex;
|
||||
|
||||
// 插入部门节点
|
||||
parent.children.splice(insertIndex, 0, {
|
||||
...node,
|
||||
children: users
|
||||
});
|
||||
} else {
|
||||
// 找不到父节点,作为根节点
|
||||
tree.push(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理用户选择器确认事件
|
||||
*/
|
||||
function handleUserSelectConfirm(users: any[]) {
|
||||
selectedUsers.value = users as SysUser[];
|
||||
showUserSelector.value = false;
|
||||
}
|
||||
|
||||
// 课程选择相关
|
||||
@@ -526,19 +667,6 @@ function removeResource(index: number) {
|
||||
}
|
||||
|
||||
// 用户选择相关
|
||||
function isUserSelected(userID?: string) {
|
||||
return selectedUsers.value.some(u => u.id === userID);
|
||||
}
|
||||
|
||||
function toggleUser(user: SysUser) {
|
||||
const index = selectedUsers.value.findIndex(u => u.id === user.id);
|
||||
if (index > -1) {
|
||||
selectedUsers.value.splice(index, 1);
|
||||
} else {
|
||||
selectedUsers.value.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
function removeUser(index: number) {
|
||||
selectedUsers.value.splice(index, 1);
|
||||
}
|
||||
@@ -623,14 +751,14 @@ async function handleSubmit() {
|
||||
} as TaskItemVO));
|
||||
|
||||
let res;
|
||||
if (props.taskID) {
|
||||
if (props.taskId) {
|
||||
res = await learningTaskApi.updateTask(taskData.value);
|
||||
} else {
|
||||
res = await learningTaskApi.createTask(taskData.value);
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
ElMessage.success(props.taskID ? '任务更新成功' : '任务创建成功');
|
||||
ElMessage.success(props.taskId ? '任务更新成功' : '任务创建成功');
|
||||
emit('success');
|
||||
} else {
|
||||
ElMessage.error(res.message || '操作失败');
|
||||
|
||||
Reference in New Issue
Block a user