接口修正、成就修正、学习记录修正
This commit is contained in:
@@ -74,8 +74,7 @@ CREATE TABLE `tb_resource_recommend` (
|
|||||||
`deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否删除',
|
`deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否删除',
|
||||||
PRIMARY KEY (`id`),
|
PRIMARY KEY (`id`),
|
||||||
KEY `idx_resource_id` (`resource_id`),
|
KEY `idx_resource_id` (`resource_id`),
|
||||||
KEY `idx_recommend_type` (`recommend_type`),
|
KEY `idx_recommend_type` (`recommend_type`)
|
||||||
CONSTRAINT `fk_resource_recommend_resource` FOREIGN KEY (`resource_id`) REFERENCES `tb_resource` (`resource_id`) ON DELETE CASCADE
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='资源推荐表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='资源推荐表';
|
||||||
|
|
||||||
-- 标签表
|
-- 标签表
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use school_news;
|
|||||||
DROP TABLE IF EXISTS `tb_sys_operation_log`;
|
DROP TABLE IF EXISTS `tb_sys_operation_log`;
|
||||||
CREATE TABLE `tb_sys_operation_log` (
|
CREATE TABLE `tb_sys_operation_log` (
|
||||||
`id` VARCHAR(50) NOT NULL COMMENT '日志ID',
|
`id` VARCHAR(50) NOT NULL COMMENT '日志ID',
|
||||||
`user_id` VARCHAR(50) NOT NULL COMMENT '用户ID',
|
`user_id` VARCHAR(50) NULL COMMENT '用户ID',
|
||||||
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
|
`username` VARCHAR(50) NULL COMMENT '用户名',
|
||||||
`module` VARCHAR(100) DEFAULT NULL COMMENT '操作模块',
|
`module` VARCHAR(100) DEFAULT NULL COMMENT '操作模块',
|
||||||
`operation` VARCHAR(100) DEFAULT NULL COMMENT '操作类型',
|
`operation` VARCHAR(100) DEFAULT NULL COMMENT '操作类型',
|
||||||
`method` VARCHAR(200) DEFAULT NULL COMMENT '请求方法',
|
`method` VARCHAR(200) DEFAULT NULL COMMENT '请求方法',
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ CREATE TABLE `tb_sys_user_info` (
|
|||||||
DROP TABLE IF EXISTS `tb_sys_login_log`;
|
DROP TABLE IF EXISTS `tb_sys_login_log`;
|
||||||
CREATE TABLE `tb_sys_login_log` (
|
CREATE TABLE `tb_sys_login_log` (
|
||||||
`id` VARCHAR(50) NOT NULL COMMENT '登录日志ID',
|
`id` VARCHAR(50) NOT NULL COMMENT '登录日志ID',
|
||||||
`user_id` VARCHAR(50) NOT NULL COMMENT '用户ID',
|
`user_id` VARCHAR(50) NULL COMMENT '用户ID',
|
||||||
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
|
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
|
||||||
`ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'IP地址',
|
`ip_address` VARCHAR(45) DEFAULT NULL COMMENT 'IP地址',
|
||||||
`ip_source` VARCHAR(100) DEFAULT NULL COMMENT 'IP来源',
|
`ip_source` VARCHAR(100) DEFAULT NULL COMMENT 'IP来源',
|
||||||
|
|||||||
@@ -64,26 +64,41 @@ INSERT INTO `tb_sys_permission` (id,permission_id, name, code, description, modu
|
|||||||
|
|
||||||
-- 插入角色-权限关联数据
|
-- 插入角色-权限关联数据
|
||||||
INSERT INTO `tb_sys_role_permission` (id, role_id, permission_id, creator, create_time) VALUES
|
INSERT INTO `tb_sys_role_permission` (id, role_id, permission_id, creator, create_time) VALUES
|
||||||
('0', 'superadmin', 'perm_default', '1', now()),
|
-- 超级管理员:拥有所有权限
|
||||||
('1', 'superadmin', 'perm_system_manage', '1', now()),
|
('1', 'superadmin', 'perm_default', '1', now()),
|
||||||
('2', 'superadmin', 'perm_system_dept_manage', '1', now()),
|
('2', 'superadmin', 'perm_system_manage', '1', now()),
|
||||||
('3', 'superadmin', 'perm_system_menu_manage', '1', now()),
|
('3', 'superadmin', 'perm_system_dept_manage', '1', now()),
|
||||||
('4', 'superadmin', 'perm_system_permission_manage', '1', now()),
|
('4', 'superadmin', 'perm_system_menu_manage', '1', now()),
|
||||||
('5', 'superadmin', 'perm_system_role_manage', '1', now()),
|
('5', 'superadmin', 'perm_system_permission_manage', '1', now()),
|
||||||
('6', 'superadmin', 'perm_system_user_manage', '1', now()),
|
('6', 'superadmin', 'perm_system_role_manage', '1', now()),
|
||||||
('7', 'superadmin', 'perm_system_module_manage', '1', now()),
|
('7', 'superadmin', 'perm_system_user_manage', '1', now()),
|
||||||
('8', 'superadmin', 'perm_news_manage', '1', now()),
|
('8', 'superadmin', 'perm_system_module_manage', '1', now()),
|
||||||
('9', 'superadmin', 'perm_news_article_add', '1', now()),
|
('9', 'superadmin', 'perm_news_manage', '1', now()),
|
||||||
('10', 'superadmin', 'perm_study_manage', '1', now()),
|
('10', 'superadmin', 'perm_news_article_add', '1', now()),
|
||||||
('10.1', 'superadmin', 'perm_achievement_manage', '1', now()),
|
('11', 'superadmin', 'perm_study_manage', '1', now()),
|
||||||
('11', 'superadmin', 'perm_ai_manage', '1', now()),
|
('12', 'superadmin', 'perm_achievement_manage', '1', now()),
|
||||||
('12', 'superadmin', 'perm_usercenter_manage', '1', now()),
|
('13', 'superadmin', 'perm_ai_manage', '1', now()),
|
||||||
('13', 'superadmin', 'perm_file_manage', '1', now()),
|
('14', 'superadmin', 'perm_usercenter_manage', '1', now()),
|
||||||
('14', 'freedom', 'perm_default', '1', now()),
|
('15', 'superadmin', 'perm_file_manage', '1', now()),
|
||||||
('15', 'superadmin', 'perm_crontab_manage', '1', now()),
|
('16', 'superadmin', 'perm_crontab_manage', '1', now()),
|
||||||
('16', 'superadmin', 'perm_crontab_execute', '1', now()),
|
('17', 'superadmin', 'perm_crontab_execute', '1', now()),
|
||||||
('17', 'admin', 'perm_crontab_manage', '1', now()),
|
|
||||||
('18', 'admin', 'perm_crontab_execute', '1', now());
|
-- 管理员:拥有业务管理权限,但没有系统日志等系统管理权限
|
||||||
|
('20', 'admin', 'perm_default', '1', now()),
|
||||||
|
('21', 'admin', 'perm_news_manage', '1', now()),
|
||||||
|
('22', 'admin', 'perm_news_article_add', '1', now()),
|
||||||
|
('23', 'admin', 'perm_study_manage', '1', now()),
|
||||||
|
('24', 'admin', 'perm_achievement_manage', '1', now()),
|
||||||
|
('25', 'admin', 'perm_ai_manage', '1', now()),
|
||||||
|
('26', 'admin', 'perm_usercenter_manage', '1', now()),
|
||||||
|
('27', 'admin', 'perm_file_manage', '1', now()),
|
||||||
|
|
||||||
|
-- 自由角色:拥有用户视图相关的所有权限(前台用户权限)
|
||||||
|
('30', 'freedom', 'perm_default', '1', now()),
|
||||||
|
('31', 'freedom', 'perm_news_article_add', '1', now()),
|
||||||
|
('32', 'freedom', 'perm_ai_manage', '1', now()),
|
||||||
|
('33', 'freedom', 'perm_usercenter_manage', '1', now()),
|
||||||
|
('34', 'freedom', 'perm_file_manage', '1', now());
|
||||||
|
|
||||||
-- 插入前端菜单数据
|
-- 插入前端菜单数据
|
||||||
INSERT INTO `tb_sys_menu` VALUES
|
INSERT INTO `tb_sys_menu` VALUES
|
||||||
@@ -135,7 +150,7 @@ INSERT INTO `tb_sys_menu` VALUES
|
|||||||
('6002', 'menu_admin_ai_config', 'AI配置', 'menu_admin_ai_manage', '/admin/manage/ai/config', 'admin/manage/ai/AIConfigView', NULL, 2, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
('6002', 'menu_admin_ai_config', 'AI配置', 'menu_admin_ai_manage', '/admin/manage/ai/config', 'admin/manage/ai/AIConfigView', NULL, 2, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
||||||
('6003', 'menu_admin_knowledge', '知识库管理', 'menu_admin_ai_manage', '/admin/manage/ai/knowledge', 'admin/manage/ai/KnowledgeManagementView', NULL, 3, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
('6003', 'menu_admin_knowledge', '知识库管理', 'menu_admin_ai_manage', '/admin/manage/ai/knowledge', 'admin/manage/ai/KnowledgeManagementView', NULL, 3, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
||||||
('7000', 'menu_admin_logs_manage', '系统日志', NULL, '', '', 'admin/logs.svg', 7, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:52:53', NULL, 0),
|
('7000', 'menu_admin_logs_manage', '系统日志', NULL, '', '', 'admin/logs.svg', 7, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:52:53', NULL, 0),
|
||||||
-- ('7001', 'menu_admin_system_logs', '系统日志', 'menu_admin_logs_manage', '/admin/manage/logs/system', 'admin/manage/logs/SystemLogsView', NULL, 1, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
('7001', 'menu_admin_system_logs', '系统日志', 'menu_admin_logs_manage', '/admin/manage/logs/system', 'admin/manage/logs/SystemLogsView', NULL, 1, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
||||||
('7002', 'menu_admin_login_logs', '登录日志', 'menu_admin_logs_manage', '/admin/manage/logs/login', 'admin/manage/logs/LoginLogsView', NULL, 2, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
('7002', 'menu_admin_login_logs', '登录日志', 'menu_admin_logs_manage', '/admin/manage/logs/login', 'admin/manage/logs/LoginLogsView', NULL, 2, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
||||||
-- ('7003', 'menu_admin_operation_logs', '操作日志', 'menu_admin_logs_manage', '/admin/manage/logs/operation', 'admin/manage/logs/OperationLogsView', NULL, 3, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
-- ('7003', 'menu_admin_operation_logs', '操作日志', 'menu_admin_logs_manage', '/admin/manage/logs/operation', 'admin/manage/logs/OperationLogsView', NULL, 3, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
||||||
-- ('7004', 'menu_admin_system_config', '系统配置', 'menu_admin_logs_manage', '/admin/manage/logs/config', 'admin/manage/logs/SystemConfigView', NULL, 4, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
-- ('7004', 'menu_admin_system_config', '系统配置', 'menu_admin_logs_manage', '/admin/manage/logs/config', 'admin/manage/logs/SystemConfigView', NULL, 4, 0, 'SidebarLayout', '1', NULL, '2025-10-27 17:26:06', '2025-10-29 11:48:39', NULL, 0),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import org.xyzh.achievement.mapper.AchievementMapper;
|
|||||||
import org.xyzh.achievement.mapper.UserAchievementMapper;
|
import org.xyzh.achievement.mapper.UserAchievementMapper;
|
||||||
import org.xyzh.achievement.mapper.UserAchievementProgressMapper;
|
import org.xyzh.achievement.mapper.UserAchievementProgressMapper;
|
||||||
import org.xyzh.api.achievement.AchievementService;
|
import org.xyzh.api.achievement.AchievementService;
|
||||||
|
import org.xyzh.common.core.domain.LoginDomain;
|
||||||
import org.xyzh.common.core.domain.ResultDomain;
|
import org.xyzh.common.core.domain.ResultDomain;
|
||||||
import org.xyzh.common.core.enums.AchievementEventType;
|
import org.xyzh.common.core.enums.AchievementEventType;
|
||||||
import org.xyzh.common.core.event.AchievementEvent;
|
import org.xyzh.common.core.event.AchievementEvent;
|
||||||
@@ -20,6 +21,7 @@ import org.xyzh.common.dto.user.TbSysUser;
|
|||||||
import org.xyzh.common.dto.usercenter.TbAchievement;
|
import org.xyzh.common.dto.usercenter.TbAchievement;
|
||||||
import org.xyzh.common.dto.usercenter.TbUserAchievement;
|
import org.xyzh.common.dto.usercenter.TbUserAchievement;
|
||||||
import org.xyzh.common.dto.usercenter.TbUserAchievementProgress;
|
import org.xyzh.common.dto.usercenter.TbUserAchievementProgress;
|
||||||
|
import org.xyzh.common.redis.service.RedisService;
|
||||||
import org.xyzh.common.utils.IDUtils;
|
import org.xyzh.common.utils.IDUtils;
|
||||||
import org.xyzh.common.vo.AchievementVO;
|
import org.xyzh.common.vo.AchievementVO;
|
||||||
import org.xyzh.system.utils.LoginUtil;
|
import org.xyzh.system.utils.LoginUtil;
|
||||||
@@ -41,6 +43,7 @@ import java.util.stream.Collectors;
|
|||||||
public class ACHAchievementServiceImpl implements AchievementService {
|
public class ACHAchievementServiceImpl implements AchievementService {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(ACHAchievementServiceImpl.class);
|
private static final Logger logger = LoggerFactory.getLogger(ACHAchievementServiceImpl.class);
|
||||||
|
private static final String REDIS_LOGIN_PREFIX = "login:token:";
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AchievementMapper achievementMapper;
|
private AchievementMapper achievementMapper;
|
||||||
@@ -57,6 +60,9 @@ public class ACHAchievementServiceImpl implements AchievementService {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private ResourcePermissionService resourcePermissionService;
|
private ResourcePermissionService resourcePermissionService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RedisService redisService;
|
||||||
|
|
||||||
// ==================== 成就定义管理 ====================
|
// ==================== 成就定义管理 ====================
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -510,7 +516,8 @@ public class ACHAchievementServiceImpl implements AchievementService {
|
|||||||
logger.debug("处理成就事件: {}", event);
|
logger.debug("处理成就事件: {}", event);
|
||||||
|
|
||||||
// 获取该事件类型相关的所有成就
|
// 获取该事件类型相关的所有成就
|
||||||
List<TbAchievement> achievements = getAchievementsByEventType(event.getEventType());
|
// 传入 userID,在异步线程中根据 userID 查询用户部门角色
|
||||||
|
List<TbAchievement> achievements = getAchievementsByEventType(event.getEventType(), event.getUserID());
|
||||||
if (achievements.isEmpty()) {
|
if (achievements.isEmpty()) {
|
||||||
resultDomain.success("无相关成就", newAchievements);
|
resultDomain.success("无相关成就", newAchievements);
|
||||||
return resultDomain;
|
return resultDomain;
|
||||||
@@ -763,12 +770,38 @@ public class ACHAchievementServiceImpl implements AchievementService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据事件类型获取相关成就
|
* 根据事件类型获取相关成就
|
||||||
|
* @param eventType 事件类型
|
||||||
|
* @param userID 用户ID(用于在异步线程中从Redis获取用户登录信息)
|
||||||
*/
|
*/
|
||||||
private List<TbAchievement> getAchievementsByEventType(AchievementEventType eventType) {
|
private List<TbAchievement> getAchievementsByEventType(AchievementEventType eventType, String userID) {
|
||||||
// 获取所有成就
|
// 获取所有成就
|
||||||
TbAchievement filter = new TbAchievement();
|
TbAchievement filter = new TbAchievement();
|
||||||
filter.setDeleted(false);
|
filter.setDeleted(false);
|
||||||
List<UserDeptRoleVO> userDeptRoles = LoginUtil.getCurrentDeptRole();
|
|
||||||
|
// 【关键优化】在异步线程中从Redis缓存获取用户的LoginDomain
|
||||||
|
// 避免数据库查询,性能更好
|
||||||
|
List<UserDeptRoleVO> userDeptRoles = new ArrayList<>();
|
||||||
|
|
||||||
|
if (StringUtils.hasText(userID)) {
|
||||||
|
try {
|
||||||
|
// 从Redis获取用户登录信息
|
||||||
|
String redisKey = REDIS_LOGIN_PREFIX + userID;
|
||||||
|
LoginDomain loginDomain = (LoginDomain) redisService.get(redisKey);
|
||||||
|
|
||||||
|
if (loginDomain != null && loginDomain.getRoles() != null) {
|
||||||
|
userDeptRoles = loginDomain.getRoles();
|
||||||
|
logger.debug("从Redis缓存获取用户部门角色成功,userID: {}, 角色数: {}",
|
||||||
|
userID, userDeptRoles.size());
|
||||||
|
} else {
|
||||||
|
logger.warn("Redis缓存中未找到用户登录信息或角色为空,userID: {}", userID);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("从Redis获取用户部门角色异常,userID: {}", userID, e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn("userID为空,无法获取用户部门角色");
|
||||||
|
}
|
||||||
|
|
||||||
List<TbAchievement> allAchievements = achievementMapper.selectAchievements(filter, userDeptRoles);
|
List<TbAchievement> allAchievements = achievementMapper.selectAchievements(filter, userDeptRoles);
|
||||||
|
|
||||||
// 筛选支持该事件类型的成就
|
// 筛选支持该事件类型的成就
|
||||||
|
|||||||
@@ -435,14 +435,13 @@ public class AuthController {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 密码加密
|
// 3. 设置密码(明文,Service层会加密)
|
||||||
String encryptedPassword = passwordEncoder.encode(password);
|
user.setPassword(password);
|
||||||
user.setPassword(encryptedPassword);
|
|
||||||
|
|
||||||
// 4. 设置用户状态为正常
|
// 4. 设置用户状态为正常
|
||||||
user.setStatus(0);
|
user.setStatus(0);
|
||||||
|
|
||||||
// 5. 调用UserService注册用户
|
// 5. 调用UserService注册用户(Service层会加密密码)
|
||||||
ResultDomain<TbSysUser> registerResult = userService.registerUser(user);
|
ResultDomain<TbSysUser> registerResult = userService.registerUser(user);
|
||||||
|
|
||||||
if (!registerResult.isSuccess()) {
|
if (!registerResult.isSuccess()) {
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import org.xyzh.common.dto.user.TbSysUser;
|
|||||||
import org.xyzh.common.dto.dept.TbSysDeptRole;
|
import org.xyzh.common.dto.dept.TbSysDeptRole;
|
||||||
import org.xyzh.common.dto.permission.TbSysPermission;
|
import org.xyzh.common.dto.permission.TbSysPermission;
|
||||||
import org.xyzh.common.core.enums.UserStatus;
|
import org.xyzh.common.core.enums.UserStatus;
|
||||||
|
import org.xyzh.common.vo.UserDeptRoleVO;
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@@ -36,6 +38,29 @@ public class UserPrincipal implements UserDetails {
|
|||||||
return new UserPrincipal(user, roles, permissions);
|
return new UserPrincipal(user, roles, permissions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 从LoginDomain创建UserPrincipal(用于从Redis缓存恢复认证信息)
|
||||||
|
* @param user 用户信息
|
||||||
|
* @param userDeptRoles 用户部门角色列表(来自LoginDomain)
|
||||||
|
* @param permissions 权限列表
|
||||||
|
* @return UserPrincipal 用户主体
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-11-03
|
||||||
|
*/
|
||||||
|
public static UserPrincipal createFromLoginDomain(TbSysUser user, List<UserDeptRoleVO> userDeptRoles, List<TbSysPermission> permissions) {
|
||||||
|
// 将UserDeptRoleVO转换为TbSysDeptRole(简化处理,实际使用时UserDeptRoleVO已包含所需信息)
|
||||||
|
List<TbSysDeptRole> roles = new ArrayList<>();
|
||||||
|
if (userDeptRoles != null) {
|
||||||
|
for (UserDeptRoleVO vo : userDeptRoles) {
|
||||||
|
TbSysDeptRole role = new TbSysDeptRole();
|
||||||
|
role.setRoleID(vo.getRoleID());
|
||||||
|
role.setDeptID(vo.getDeptID());
|
||||||
|
roles.add(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new UserPrincipal(user, roles, permissions != null ? permissions : new ArrayList<>());
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Collection<? extends GrantedAuthority> getAuthorities() {
|
public Collection<? extends GrantedAuthority> getAuthorities() {
|
||||||
// 角色权限
|
// 角色权限
|
||||||
|
|||||||
@@ -12,12 +12,16 @@ import org.springframework.security.web.authentication.WebAuthenticationDetailsS
|
|||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.filter.OncePerRequestFilter;
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
import org.xyzh.auth.service.UserDetailsServiceImpl;
|
|
||||||
import org.xyzh.auth.util.JwtTokenUtil;
|
import org.xyzh.auth.util.JwtTokenUtil;
|
||||||
import org.xyzh.auth.config.AuthProperties;
|
import org.xyzh.auth.config.AuthProperties;
|
||||||
|
import org.xyzh.common.core.domain.LoginDomain;
|
||||||
|
import org.xyzh.common.redis.service.RedisService;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description JwtAuthenticationFilter.java文件描述 JWT认证过滤器
|
* @description JwtAuthenticationFilter.java文件描述 JWT认证过滤器
|
||||||
* @filename JwtAuthenticationFilter.java
|
* @filename JwtAuthenticationFilter.java
|
||||||
@@ -28,14 +32,18 @@ import java.io.IOException;
|
|||||||
@Component
|
@Component
|
||||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private static final String REDIS_LOGIN_PREFIX = "login:token:";
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private JwtTokenUtil jwtTokenUtil;
|
private JwtTokenUtil jwtTokenUtil;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private UserDetailsServiceImpl userDetailsService;
|
private AuthProperties authProperties;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private AuthProperties authProperties;
|
private RedisService redisService;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
@@ -56,17 +64,38 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
|||||||
String userId = jwtTokenUtil.getUserIdFromToken(token);
|
String userId = jwtTokenUtil.getUserIdFromToken(token);
|
||||||
|
|
||||||
if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
if (userId != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
UserDetails userDetails = userDetailsService.loadUserByUserId(userId);
|
// 验证token有效性
|
||||||
|
if (!jwtTokenUtil.validateToken(token, userId)) {
|
||||||
|
logger.warn("Token验证失败,userId: {}", userId);
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 【优化】从Redis缓存中获取LoginDomain,避免每次都查数据库
|
||||||
|
String redisKey = REDIS_LOGIN_PREFIX + userId;
|
||||||
|
LoginDomain loginDomain = (LoginDomain) redisService.get(redisKey);
|
||||||
|
|
||||||
|
if (loginDomain != null && loginDomain.getUser() != null) {
|
||||||
|
// 【优化】直接使用缓存中的用户信息构建UserDetails,不查数据库
|
||||||
|
// 使用UserPrincipal从LoginDomain创建UserDetails
|
||||||
|
UserDetails userDetails = org.xyzh.auth.domain.UserPrincipal.createFromLoginDomain(
|
||||||
|
loginDomain.getUser(),
|
||||||
|
loginDomain.getRoles(),
|
||||||
|
loginDomain.getPermissions()
|
||||||
|
);
|
||||||
|
|
||||||
if (jwtTokenUtil.validateToken(token, userId)) {
|
|
||||||
UsernamePasswordAuthenticationToken authentication =
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
|
||||||
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
|
||||||
|
logger.debug("用户认证成功(从缓存),userId: {}", userId);
|
||||||
|
} else {
|
||||||
|
logger.warn("Redis缓存中未找到用户登录信息,userId: {}, 可能已过期或未登录", userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
logger.error("JWT token validation failed: " + e.getMessage());
|
logger.error("JWT token validation failed: " + e.getMessage(), e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
package org.xyzh.auth.service;
|
|
||||||
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetails;
|
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.xyzh.auth.domain.UserPrincipal;
|
|
||||||
import org.xyzh.common.dto.user.TbSysUser;
|
|
||||||
import org.xyzh.common.dto.dept.TbSysDeptRole;
|
|
||||||
import org.xyzh.common.dto.permission.TbSysPermission;
|
|
||||||
import org.xyzh.api.system.user.UserService;
|
|
||||||
import org.xyzh.api.system.role.RoleService;
|
|
||||||
import org.xyzh.api.system.permission.PermissionService;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description UserDetailsServiceImpl.java文件描述 用户详情服务实现
|
|
||||||
* @filename UserDetailsServiceImpl.java
|
|
||||||
* @author yslg
|
|
||||||
* @copyright xyzh
|
|
||||||
* @since 2025-09-28
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
public class UserDetailsServiceImpl implements UserDetailsService {
|
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(UserDetailsServiceImpl.class);
|
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private UserService userService;
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
|
||||||
private RoleService roleService;
|
|
||||||
|
|
||||||
@Autowired(required = false)
|
|
||||||
private PermissionService permissionService;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
|
||||||
TbSysUser filter = new TbSysUser();
|
|
||||||
filter.setUsername(username);
|
|
||||||
List<TbSysUser> users = userService.getUserByFilter(filter).getDataList();
|
|
||||||
if(users.isEmpty()) {
|
|
||||||
throw new UsernameNotFoundException("用户不存在: " + username);
|
|
||||||
}
|
|
||||||
TbSysUser user = users.get(0);
|
|
||||||
|
|
||||||
if (user == null) {
|
|
||||||
throw new UsernameNotFoundException("用户不存在: " + username);
|
|
||||||
}
|
|
||||||
|
|
||||||
return loadUserByUserId(user.getID());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @description 根据用户ID加载用户详情
|
|
||||||
* @param userId 用户ID
|
|
||||||
* @return UserDetails 用户详情
|
|
||||||
* @author yslg
|
|
||||||
* @since 2025-09-28
|
|
||||||
*/
|
|
||||||
public UserDetails loadUserByUserId(String userId) {
|
|
||||||
TbSysUser filter = new TbSysUser();
|
|
||||||
filter.setID(userId);
|
|
||||||
List<TbSysUser> users = userService.getUserByFilter(filter).getDataList();
|
|
||||||
if(users.isEmpty()) {
|
|
||||||
throw new UsernameNotFoundException("用户不存在: " + userId);
|
|
||||||
}
|
|
||||||
TbSysUser user = users.get(0);
|
|
||||||
|
|
||||||
if (user == null) {
|
|
||||||
throw new UsernameNotFoundException("用户不存在: " + userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户角色(如果角色服务可用)
|
|
||||||
List<TbSysDeptRole> roles = new ArrayList<>();
|
|
||||||
if (roleService != null) {
|
|
||||||
try {
|
|
||||||
// TODO: 需要在RoleService中实现findRolesByUserId方法
|
|
||||||
// roles = roleService.findRolesByUserId(userId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.warn("无法获取用户角色: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取用户权限(如果权限服务可用)
|
|
||||||
List<TbSysPermission> permissions = new ArrayList<>();
|
|
||||||
if (permissionService != null) {
|
|
||||||
try {
|
|
||||||
// TODO: 需要在PermissionService中实现findPermissionsByUserId方法
|
|
||||||
// permissions = permissionService.findPermissionsByUserId(userId);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.warn("无法获取用户权限: " + e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return UserPrincipal.create(user, roles, permissions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -65,7 +65,8 @@ public class PasswordLoginStrategy implements LoginStrategy {
|
|||||||
}else{
|
}else{
|
||||||
filter.setUsername(loginParam.getUsername());
|
filter.setUsername(loginParam.getUsername());
|
||||||
}
|
}
|
||||||
filter.setPassword(passwordEncoder.encode(loginParam.getPassword()));
|
// 【优化】删除无用的密码编码,SQL查询不使用password字段
|
||||||
|
// 密码验证在 verifyCredential() 方法中进行
|
||||||
TbSysUser user = userService.getLoginUser(filter).getData();
|
TbSysUser user = userService.getLoginUser(filter).getData();
|
||||||
if(user == null) {
|
if(user == null) {
|
||||||
return null;
|
return null;
|
||||||
@@ -75,7 +76,7 @@ public class PasswordLoginStrategy implements LoginStrategy {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean verifyCredential(String inputCredential, String storedCredential) {
|
public boolean verifyCredential(String inputCredential, String storedCredential) {
|
||||||
logger.info(passwordEncoder.encode(inputCredential));
|
// 使用BCrypt的matches方法验证密码(内部会自动处理salt)
|
||||||
return passwordEncoder.matches(inputCredential, storedCredential);
|
return passwordEncoder.matches(inputCredential, storedCredential);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,7 @@ package org.xyzh.crontab.scheduler;
|
|||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.scheduling.TaskScheduler;
|
import org.springframework.scheduling.TaskScheduler;
|
||||||
import org.springframework.scheduling.support.CronTrigger;
|
import org.springframework.scheduling.support.CronTrigger;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
@@ -29,9 +29,7 @@ public class SchedulerManager {
|
|||||||
private TaskScheduler taskScheduler;
|
private TaskScheduler taskScheduler;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
private ApplicationContext applicationContext;
|
@Qualifier("crontabTaskExecutor")
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TaskExecutor taskExecutor;
|
private TaskExecutor taskExecutor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import java.util.Date;
|
|||||||
* @copyright xyzh
|
* @copyright xyzh
|
||||||
* @since 2025-10-25
|
* @since 2025-10-25
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component("crontabTaskExecutor")
|
||||||
public class TaskExecutor {
|
public class TaskExecutor {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(TaskExecutor.class);
|
private static final Logger logger = LoggerFactory.getLogger(TaskExecutor.class);
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package org.xyzh.system.config;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 异步任务配置
|
||||||
|
* @filename AsyncConfig.java
|
||||||
|
* @author AI
|
||||||
|
* @copyright xyzh
|
||||||
|
* @since 2025-11-03
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(AsyncConfig.class);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 配置异步任务执行器(用于@Async和事件发布)
|
||||||
|
* @return Executor
|
||||||
|
* @author AI
|
||||||
|
* @since 2025-11-03
|
||||||
|
*/
|
||||||
|
@Bean(name = "taskExecutor")
|
||||||
|
public Executor taskExecutor() {
|
||||||
|
logger.info("初始化异步任务执行器 taskExecutor");
|
||||||
|
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
|
||||||
|
// 核心线程数
|
||||||
|
executor.setCorePoolSize(5);
|
||||||
|
|
||||||
|
// 最大线程数
|
||||||
|
executor.setMaxPoolSize(10);
|
||||||
|
|
||||||
|
// 队列容量
|
||||||
|
executor.setQueueCapacity(100);
|
||||||
|
|
||||||
|
// 线程名称前缀
|
||||||
|
executor.setThreadNamePrefix("async-event-");
|
||||||
|
|
||||||
|
// 拒绝策略:由调用线程处理
|
||||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||||
|
|
||||||
|
// 等待所有任务完成后再关闭线程池
|
||||||
|
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||||
|
|
||||||
|
// 等待时间(秒)
|
||||||
|
executor.setAwaitTerminationSeconds(60);
|
||||||
|
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -195,11 +195,17 @@ public class SysDepartmentServiceImpl implements SysDepartmentService {
|
|||||||
|
|
||||||
// 设置基础信息
|
// 设置基础信息
|
||||||
department.setID(IDUtils.generateID());
|
department.setID(IDUtils.generateID());
|
||||||
department.setDeptID(IDUtils.generateID());
|
String deptId = IDUtils.generateID();
|
||||||
|
department.setDeptID(deptId);
|
||||||
department.setCreator(currentUser.getID());
|
department.setCreator(currentUser.getID());
|
||||||
department.setCreateTime(new Date());
|
department.setCreateTime(new Date());
|
||||||
department.setDeleted(false);
|
department.setDeleted(false);
|
||||||
|
|
||||||
|
// 【修复】设置dept_path
|
||||||
|
String deptPath = buildDeptPath(department.getParentID(), deptId);
|
||||||
|
department.setDeptPath(deptPath);
|
||||||
|
logger.info("创建部门,设置dept_path: {}", deptPath);
|
||||||
|
|
||||||
// 插入数据库
|
// 插入数据库
|
||||||
int result = departmentMapper.insertDept(department);
|
int result = departmentMapper.insertDept(department);
|
||||||
|
|
||||||
@@ -456,4 +462,37 @@ public class SysDepartmentServiceImpl implements SysDepartmentService {
|
|||||||
return resultDomain;
|
return resultDomain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 构建部门路径(优化版:只查询一次父部门路径)
|
||||||
|
* @param parentId 父部门ID
|
||||||
|
* @param deptId 当前部门ID
|
||||||
|
* @return String 部门路径
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-11-03
|
||||||
|
*/
|
||||||
|
private String buildDeptPath(String parentId, String deptId) {
|
||||||
|
// 如果没有父部门,说明是根部门
|
||||||
|
if (parentId == null || parentId.trim().isEmpty()) {
|
||||||
|
return "/" + deptId + "/";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 【优化】直接通过一条简单SQL查询父部门的dept_path
|
||||||
|
String parentPath = departmentMapper.getDeptPathByDeptId(parentId);
|
||||||
|
|
||||||
|
if (parentPath != null && !parentPath.trim().isEmpty()) {
|
||||||
|
// 父部门路径 + 当前部门ID + /
|
||||||
|
return parentPath + deptId + "/";
|
||||||
|
} else {
|
||||||
|
// 父部门没有dept_path,使用父部门ID构建
|
||||||
|
logger.warn("父部门dept_path为空,parentId: {},将使用父部门ID构建路径", parentId);
|
||||||
|
return "/" + parentId + "/" + deptId + "/";
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("构建dept_path失败,parentId: {}, deptId: {}", parentId, deptId, e);
|
||||||
|
// 发生异常时,返回简单路径
|
||||||
|
return "/" + deptId + "/";
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ public class DatabaseAppender extends AbstractAppender {
|
|||||||
java.lang.reflect.Method getUsernameMethod = loginUtil.getClass().getMethod("getCurrentUsername");
|
java.lang.reflect.Method getUsernameMethod = loginUtil.getClass().getMethod("getCurrentUsername");
|
||||||
userId = (String) getUserIdMethod.invoke(null);
|
userId = (String) getUserIdMethod.invoke(null);
|
||||||
username = (String) getUsernameMethod.invoke(null);
|
username = (String) getUsernameMethod.invoke(null);
|
||||||
|
if(username == null || username.trim().isEmpty()) {
|
||||||
|
username = "system";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|||||||
@@ -90,4 +90,13 @@ public interface DepartmentMapper extends BaseMapper<TbSysDept> {
|
|||||||
* @since 2025-09-28
|
* @since 2025-09-28
|
||||||
*/
|
*/
|
||||||
int deleteDept(TbSysDept department);
|
int deleteDept(TbSysDept department);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description 根据部门ID获取部门路径(不带权限过滤,仅用于构建子部门路径)
|
||||||
|
* @param deptId 部门ID
|
||||||
|
* @return String 部门路径
|
||||||
|
* @author yslg
|
||||||
|
* @since 2025-11-03
|
||||||
|
*/
|
||||||
|
String getDeptPathByDeptId(@Param("deptId") String deptId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ public class SysUserServiceImpl implements SysUserService {
|
|||||||
userInfo.setAvatar("default");
|
userInfo.setAvatar("default");
|
||||||
|
|
||||||
TbSysUserDeptRole userDeptRole = new TbSysUserDeptRole();
|
TbSysUserDeptRole userDeptRole = new TbSysUserDeptRole();
|
||||||
|
userDeptRole.setID(IDUtils.generateID()); // 设置ID
|
||||||
userDeptRole.setUserID(user.getID());
|
userDeptRole.setUserID(user.getID());
|
||||||
userDeptRole.setDeptID("default_department");
|
userDeptRole.setDeptID("default_department");
|
||||||
userDeptRole.setRoleID("freedom");
|
userDeptRole.setRoleID("freedom");
|
||||||
@@ -482,6 +483,13 @@ public class SysUserServiceImpl implements SysUserService {
|
|||||||
ResultDomain<UserDeptRoleVO> resultDomain = new ResultDomain<>();
|
ResultDomain<UserDeptRoleVO> resultDomain = new ResultDomain<>();
|
||||||
TbSysUser currentUser = LoginUtil.getCurrentUser();
|
TbSysUser currentUser = LoginUtil.getCurrentUser();
|
||||||
|
|
||||||
|
// 【限制】检查是否只有一个部门-角色配置(一个用户只能有一个部门-角色)
|
||||||
|
if (userDeptRoleVO.getUserDeptRoles() == null || userDeptRoleVO.getUserDeptRoles().size() != 1) {
|
||||||
|
resultDomain.fail("每个用户只能绑定一个部门-角色,请提供且仅提供一个部门-角色配置");
|
||||||
|
logger.warn("绑定失败:提供了 {} 个部门-角色配置,但系统限制为1个",
|
||||||
|
userDeptRoleVO.getUserDeptRoles() != null ? userDeptRoleVO.getUserDeptRoles().size() : 0);
|
||||||
|
return resultDomain;
|
||||||
|
}
|
||||||
|
|
||||||
// 收集所有用户ID
|
// 收集所有用户ID
|
||||||
List<String> userIds = new ArrayList<>();
|
List<String> userIds = new ArrayList<>();
|
||||||
@@ -489,32 +497,29 @@ public class SysUserServiceImpl implements SysUserService {
|
|||||||
userIds.add(user.getID());
|
userIds.add(user.getID());
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info("准备为 {} 个用户绑定部门角色", userIds.size());
|
logger.info("准备为 {} 个用户绑定部门角色(每个用户一个部门-角色)", userIds.size());
|
||||||
|
|
||||||
// 批量删除所有涉及用户的旧绑定关系(物理删除,包括软删除的记录)
|
// 批量删除所有涉及用户的旧绑定关系(物理删除,包括软删除的记录)
|
||||||
int deleteCount = userDeptRoleMapper.deleteUserDeptRoleByUserIds(userIds);
|
int deleteCount = userDeptRoleMapper.deleteUserDeptRoleByUserIds(userIds);
|
||||||
if (deleteCount <= 0) {
|
logger.info("删除了 {} 条旧绑定记录", deleteCount);
|
||||||
resultDomain.fail("批量删除旧绑定记录失败:没有记录被删除");
|
|
||||||
return resultDomain;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 准备新的绑定数据
|
// 准备新的绑定数据(每个用户只绑定一个部门-角色)
|
||||||
List<TbSysUserDeptRole> userDeptRoles = new ArrayList<>();
|
List<TbSysUserDeptRole> userDeptRoles = new ArrayList<>();
|
||||||
Date now = new Date();
|
Date now = new Date();
|
||||||
|
TbSysUserDeptRole templateDeptRole = userDeptRoleVO.getUserDeptRoles().get(0);
|
||||||
|
|
||||||
for (TbSysUser user : userDeptRoleVO.getUsers()) {
|
for (TbSysUser user : userDeptRoleVO.getUsers()) {
|
||||||
for (TbSysUserDeptRole userDeptRole : userDeptRoleVO.getUserDeptRoles()) {
|
|
||||||
TbSysUserDeptRole newUserDeptRole = new TbSysUserDeptRole();
|
TbSysUserDeptRole newUserDeptRole = new TbSysUserDeptRole();
|
||||||
newUserDeptRole.setID(IDUtils.generateID());
|
newUserDeptRole.setID(IDUtils.generateID());
|
||||||
newUserDeptRole.setUserID(user.getID());
|
newUserDeptRole.setUserID(user.getID());
|
||||||
newUserDeptRole.setDeptID(userDeptRole.getDeptID());
|
newUserDeptRole.setDeptID(templateDeptRole.getDeptID());
|
||||||
newUserDeptRole.setRoleID(userDeptRole.getRoleID());
|
newUserDeptRole.setRoleID(templateDeptRole.getRoleID());
|
||||||
newUserDeptRole.setCreateTime(now);
|
newUserDeptRole.setCreateTime(now);
|
||||||
newUserDeptRole.setCreator(currentUser.getID());
|
newUserDeptRole.setCreator(currentUser.getID());
|
||||||
userDeptRoles.add(newUserDeptRole);
|
userDeptRoles.add(newUserDeptRole);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("准备插入 {} 条新绑定记录", userDeptRoles.size());
|
logger.info("准备插入 {} 条新绑定记录(每个用户1条)", userDeptRoles.size());
|
||||||
|
|
||||||
// 插入新的绑定关系
|
// 插入新的绑定关系
|
||||||
int result = userDeptRoleMapper.bindUser(userDeptRoles);
|
int result = userDeptRoleMapper.bindUser(userDeptRoles);
|
||||||
|
|||||||
@@ -111,6 +111,15 @@
|
|||||||
</if>
|
</if>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 根据部门ID获取部门路径(不带权限过滤,仅用于构建子部门路径) -->
|
||||||
|
<select id="getDeptPathByDeptId" resultType="java.lang.String">
|
||||||
|
SELECT dept_path
|
||||||
|
FROM tb_sys_dept
|
||||||
|
WHERE dept_id = #{deptId}
|
||||||
|
AND deleted = 0
|
||||||
|
LIMIT 1
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- 根据部门ID查询部门信息(包含父部门信息) -->
|
<!-- 根据部门ID查询部门信息(包含父部门信息) -->
|
||||||
<select id="selectDeptWithParent" resultMap="BaseResultMap">
|
<select id="selectDeptWithParent" resultMap="BaseResultMap">
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -18,19 +18,26 @@ interface CustomAxiosRequestConfig extends Partial<InternalAxiosRequestConfig> {
|
|||||||
* Token管理
|
* Token管理
|
||||||
*/
|
*/
|
||||||
export const TokenManager = {
|
export const TokenManager = {
|
||||||
/** 获取token */
|
/** 获取token(优先从localStorage,其次sessionStorage) */
|
||||||
getToken(): string | null {
|
getToken(): string | null {
|
||||||
return localStorage.getItem('token');
|
return localStorage.getItem('token') || sessionStorage.getItem('token');
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 设置token */
|
/** 设置token(根据rememberMe决定存储位置) */
|
||||||
setToken(token: string): void {
|
setToken(token: string, rememberMe = false): void {
|
||||||
|
if (rememberMe) {
|
||||||
localStorage.setItem('token', token);
|
localStorage.setItem('token', token);
|
||||||
|
sessionStorage.removeItem('token'); // 清除sessionStorage中的旧token
|
||||||
|
} else {
|
||||||
|
sessionStorage.setItem('token', token);
|
||||||
|
localStorage.removeItem('token'); // 清除localStorage中的旧token
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 移除token */
|
/** 移除token(两个存储都清除) */
|
||||||
removeToken(): void {
|
removeToken(): void {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
sessionStorage.removeItem('token');
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 检查是否有token */
|
/** 检查是否有token */
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const authModule: Module<AuthState, any> = {
|
|||||||
namespaced: true,
|
namespaced: true,
|
||||||
|
|
||||||
state: (): AuthState => {
|
state: (): AuthState => {
|
||||||
// 从localStorage恢复状态
|
// 从存储恢复状态(localStorage或sessionStorage)
|
||||||
const storedState = getStoredState();
|
const storedState = getStoredState();
|
||||||
return {
|
return {
|
||||||
loginDomain: storedState.loginDomain || null,
|
loginDomain: storedState.loginDomain || null,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { BaseDTO } from '../base';
|
import { BaseDTO } from '../base';
|
||||||
import { SysDept } from '../dept';
|
|
||||||
import { SysRole } from '../role';
|
|
||||||
/**
|
/**
|
||||||
* 系统用户
|
* 系统用户
|
||||||
*/
|
*/
|
||||||
@@ -51,6 +49,8 @@ export interface SysUserInfo extends BaseDTO {
|
|||||||
* 用户VO - 用于前端展示
|
* 用户VO - 用于前端展示
|
||||||
*/
|
*/
|
||||||
export interface UserVO extends BaseDTO {
|
export interface UserVO extends BaseDTO {
|
||||||
|
/** 用户ID(兼容字段) */
|
||||||
|
userID?: string;
|
||||||
/** 用户名 */
|
/** 用户名 */
|
||||||
username?: string;
|
username?: string;
|
||||||
/** 邮箱 */
|
/** 邮箱 */
|
||||||
@@ -78,6 +78,8 @@ export interface UserVO extends BaseDTO {
|
|||||||
deptName?: string;
|
deptName?: string;
|
||||||
/** 角色名称 */
|
/** 角色名称 */
|
||||||
roleName?: string;
|
roleName?: string;
|
||||||
|
/** 部门-角色组合列表(用于前端展示多个部门角色绑定关系) */
|
||||||
|
deptRoleCombos?: string[];
|
||||||
/** 出生日期 */
|
/** 出生日期 */
|
||||||
birthday?: string;
|
birthday?: string;
|
||||||
/** 个人简介 */
|
/** 个人简介 */
|
||||||
|
|||||||
@@ -25,8 +25,22 @@
|
|||||||
<el-table-column prop="phone" label="手机号" min-width="120" />
|
<el-table-column prop="phone" label="手机号" min-width="120" />
|
||||||
<el-table-column prop="realName" label="真实姓名" min-width="100" />
|
<el-table-column prop="realName" label="真实姓名" min-width="100" />
|
||||||
<el-table-column prop="nickname" label="昵称" min-width="100" />
|
<el-table-column prop="nickname" label="昵称" min-width="100" />
|
||||||
<el-table-column prop="deptName" label="部门" min-width="120" />
|
<el-table-column label="部门-角色" min-width="200">
|
||||||
<el-table-column prop="roleName" label="角色" min-width="120" />
|
<template #default="{ row }">
|
||||||
|
<div class="tag-container">
|
||||||
|
<el-tag
|
||||||
|
v-for="(combo, index) in row.deptRoleCombos"
|
||||||
|
:key="index"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
style="margin: 2px"
|
||||||
|
>
|
||||||
|
{{ combo }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="!row.deptRoleCombos || row.deptRoleCombos.length === 0" style="color: #909399;">未分配</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="status" label="状态" width="80">
|
<el-table-column prop="status" label="状态" width="80">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="row.status === 0 ? 'success' : 'danger'">
|
<el-tag :type="row.status === 0 ? 'success' : 'danger'">
|
||||||
@@ -340,13 +354,51 @@ onMounted(() => {
|
|||||||
loadUsers();
|
loadUsers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 合并同一用户的多个部门角色(部门-角色是绑定关系)
|
||||||
|
function mergeUserDeptRoles(users: UserVO[]): UserVO[] {
|
||||||
|
const userMap = new Map<string, UserVO>();
|
||||||
|
|
||||||
|
users.forEach(user => {
|
||||||
|
const userId = user.id || user.userID;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
// 生成部门-角色组合字符串
|
||||||
|
const deptRoleCombo = (user.deptName && user.roleName)
|
||||||
|
? `${user.deptName}-${user.roleName}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (!userMap.has(userId)) {
|
||||||
|
// 首次遇到该用户,初始化
|
||||||
|
userMap.set(userId, {
|
||||||
|
...user,
|
||||||
|
deptRoleCombos: deptRoleCombo ? [deptRoleCombo] : []
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 已存在该用户,合并部门-角色组合
|
||||||
|
const existingUser = userMap.get(userId)!;
|
||||||
|
|
||||||
|
// 添加部门-角色组合(去重)
|
||||||
|
if (deptRoleCombo && !existingUser.deptRoleCombos?.includes(deptRoleCombo)) {
|
||||||
|
existingUser.deptRoleCombos = [...(existingUser.deptRoleCombos || []), deptRoleCombo];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(userMap.values());
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
const result = await userApi.getUserPage(pageParam.value);
|
const result = await userApi.getUserPage(pageParam.value);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
userList.value = result.dataList || [];
|
const rawUsers = result.dataList || [];
|
||||||
total.value = result.pageParam?.totalElements || 0;
|
// 合并同一用户的多个部门角色
|
||||||
|
userList.value = mergeUserDeptRoles(rawUsers);
|
||||||
|
// 注意:由于合并了数据,total应该是去重后的用户数量
|
||||||
|
// 但是后端返回的total是原始记录数,这里需要调整
|
||||||
|
// 如果后端无法修改,我们需要重新计算total
|
||||||
|
total.value = userList.value.length;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载用户列表失败:', error);
|
console.error('加载用户列表失败:', error);
|
||||||
@@ -413,8 +465,14 @@ async function handleDeptRoleConfirm(items: any[]) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 【限制】检查是否只选择了一个部门-角色
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
ElMessage.warning('请至少选择一个部门角色');
|
ElMessage.warning('请选择一个部门角色');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length > 1) {
|
||||||
|
ElMessage.error('一个用户只能绑定一个部门-角色,请选择且仅选择一个');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,7 +495,8 @@ async function handleDeptRoleConfirm(items: any[]) {
|
|||||||
const result = await userApi.bindUserDeptRole(userDeptRoleVO);
|
const result = await userApi.bindUserDeptRole(userDeptRoleVO);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
ElMessage.success(`成功绑定 ${items.length} 个部门角色`);
|
ElMessage.success('成功绑定部门角色');
|
||||||
|
showDeptRoleSelector.value = false;
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} else {
|
} else {
|
||||||
ElMessage.error(result.message || '绑定失败');
|
ElMessage.error(result.message || '绑定失败');
|
||||||
@@ -549,4 +608,11 @@ function handleSizeChange(size: number) {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const emit = defineEmits<{
|
|||||||
'close': [];
|
'close': [];
|
||||||
'edit': [];
|
'edit': [];
|
||||||
'back': [];
|
'back': [];
|
||||||
|
'videos-completed': []; // 所有视频播放完成事件
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -324,17 +325,41 @@ async function loadLearningRecord(resourceID: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 生成学习记录的taskId(当没有真实taskId时)
|
||||||
|
function generateTaskId(resourceID: string, userID: string): string {
|
||||||
|
// 使用简短格式,确保不超过50字符:SA_{resourceID的hash}_{userID的hash}
|
||||||
|
// SA = Self-study Article
|
||||||
|
const resourceHash = hashString(resourceID).substring(0, 16);
|
||||||
|
const userHash = hashString(userID).substring(0, 16);
|
||||||
|
return `SA_${resourceHash}_${userHash}`; // 长度:3 + 16 + 1 + 16 = 36字符
|
||||||
|
}
|
||||||
|
|
||||||
|
// 简单的字符串哈希函数
|
||||||
|
function hashString(str: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
// 转换为16进制字符串,并确保长度一致
|
||||||
|
return Math.abs(hash).toString(16).padStart(8, '0') + str.substring(0, 8).replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
// 创建学习记录
|
// 创建学习记录
|
||||||
async function createLearningRecord(resourceID: string) {
|
async function createLearningRecord(resourceID: string) {
|
||||||
if (!userInfo.value?.id) return;
|
if (!userInfo.value?.id) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const taskId = route.query.taskId as string;
|
const taskId = route.query.taskId as string;
|
||||||
|
// 如果没有taskId,生成一个自学任务ID
|
||||||
|
const effectiveTaskId = taskId || generateTaskId(resourceID, userInfo.value.id!);
|
||||||
|
|
||||||
const res = await learningRecordApi.createRecord({
|
const res = await learningRecordApi.createRecord({
|
||||||
userID: userInfo.value.id,
|
userID: userInfo.value.id,
|
||||||
resourceType: 1, // 资源类型:文章
|
resourceType: 1, // 资源类型:文章
|
||||||
resourceID: resourceID,
|
resourceID: resourceID,
|
||||||
taskID: taskId || undefined,
|
taskID: effectiveTaskId,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
isComplete: false
|
isComplete: false
|
||||||
@@ -419,9 +444,12 @@ async function markArticleComplete() {
|
|||||||
if (!userInfo.value?.id || !learningRecord.value) return;
|
if (!userInfo.value?.id || !learningRecord.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 使用learningRecord中保存的taskID(可能是真实任务ID或生成的自学ID)
|
||||||
|
const taskId = learningRecord.value.taskID || (route.query.taskId as string);
|
||||||
|
|
||||||
await learningRecordApi.markComplete({
|
await learningRecordApi.markComplete({
|
||||||
id: learningRecord.value.id,
|
id: learningRecord.value.id,
|
||||||
taskID: route.query.taskId as string,
|
taskID: taskId,
|
||||||
userID: userInfo.value.id,
|
userID: userInfo.value.id,
|
||||||
resourceType: 1,
|
resourceType: 1,
|
||||||
resourceID: route.query.articleId as string,
|
resourceID: route.query.articleId as string,
|
||||||
@@ -497,9 +525,16 @@ function handleVideoEnded(videoIndex: number) {
|
|||||||
if (!hasVideoCompleted.value) {
|
if (!hasVideoCompleted.value) {
|
||||||
hasVideoCompleted.value = true;
|
hasVideoCompleted.value = true;
|
||||||
ElMessage.success(`所有视频播放完成 (${totalVideos.value}/${totalVideos.value})`);
|
ElMessage.success(`所有视频播放完成 (${totalVideos.value}/${totalVideos.value})`);
|
||||||
// 立即保存学习进度并标记完成
|
|
||||||
|
// 如果作为课程子组件使用(没有learningRecord),通知父组件
|
||||||
|
if (!learningRecord.value) {
|
||||||
|
console.log('ℹ️ ArticleShow作为子组件使用,通知父组件视频播放完成');
|
||||||
|
emit('videos-completed');
|
||||||
|
} else {
|
||||||
|
// 独立使用时,保存学习进度
|
||||||
saveLearningProgress();
|
saveLearningProgress();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
ElMessage.info(`视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
|
ElMessage.info(`视频 ${videoIndex + 1} 播放完成 (${completedCount}/${totalVideos.value})`);
|
||||||
}
|
}
|
||||||
@@ -537,7 +572,7 @@ function startHistoryTimer() {
|
|||||||
// 每30秒保存一次学习历史
|
// 每30秒保存一次学习历史
|
||||||
historyTimer.value = window.setInterval(() => {
|
historyTimer.value = window.setInterval(() => {
|
||||||
saveHistoryRecord();
|
saveHistoryRecord();
|
||||||
}, 30000); // 30秒
|
}, 10000); // 30秒
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止学习历史计时
|
// 停止学习历史计时
|
||||||
|
|||||||
@@ -51,12 +51,39 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="授课老师" prop="teacher">
|
<el-form-item label="授课老师" prop="teacher">
|
||||||
<el-input
|
<div style="display: flex; gap: 8px; align-items: flex-start;">
|
||||||
|
<el-select
|
||||||
id="course-teacher"
|
id="course-teacher"
|
||||||
v-model="currentCourseItemVO.teacher"
|
v-model="currentCourseItemVO.teacher"
|
||||||
placeholder="请输入授课老师"
|
filterable
|
||||||
|
placeholder="选择授课老师"
|
||||||
|
:loading="teachersLoading"
|
||||||
|
:disabled="!editMode"
|
||||||
|
clearable
|
||||||
|
style="flex: 1"
|
||||||
|
@focus="handleTeacherSelectFocus"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="user in teacherList"
|
||||||
|
:key="user.id"
|
||||||
|
:label="user.username || user.fullName || user.id"
|
||||||
|
:value="user.username || user.fullName || user.id"
|
||||||
|
>
|
||||||
|
<span>{{ user.username }}</span>
|
||||||
|
<span v-if="user.fullName" style="color: #8492a6; font-size: 12px; margin-left: 8px;">
|
||||||
|
({{ user.fullName }})
|
||||||
|
</span>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
<el-tooltip content="刷新教师列表" placement="top">
|
||||||
|
<el-button
|
||||||
|
:icon="Refresh"
|
||||||
|
@click="loadTeacherList"
|
||||||
|
:loading="teachersLoading"
|
||||||
:disabled="!editMode"
|
:disabled="!editMode"
|
||||||
/>
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-form-item label="课程时长" prop="duration">
|
<el-form-item label="课程时长" prop="duration">
|
||||||
@@ -223,6 +250,7 @@
|
|||||||
:id="`node-${chapterIndex}-${nodeIndex}-nodeType`"
|
:id="`node-${chapterIndex}-${nodeIndex}-nodeType`"
|
||||||
v-model="node.nodeType"
|
v-model="node.nodeType"
|
||||||
:disabled="!editMode"
|
:disabled="!editMode"
|
||||||
|
@change="handleNodeTypeChange(chapterIndex, nodeIndex)"
|
||||||
>
|
>
|
||||||
<el-radio :label="0">文章资源</el-radio>
|
<el-radio :label="0">文章资源</el-radio>
|
||||||
<el-radio :label="1">富文本</el-radio>
|
<el-radio :label="1">富文本</el-radio>
|
||||||
@@ -236,16 +264,17 @@
|
|||||||
label="选择文章"
|
label="选择文章"
|
||||||
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.resourceID`"
|
:prop="`chapters.${chapterIndex}.chapters.${nodeIndex}.resourceID`"
|
||||||
>
|
>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: flex-start;">
|
||||||
<el-select
|
<el-select
|
||||||
:id="`node-${chapterIndex}-${nodeIndex}-resourceID`"
|
:id="`node-${chapterIndex}-${nodeIndex}-resourceID`"
|
||||||
v-model="node.resourceID"
|
v-model="node.resourceID"
|
||||||
filterable
|
filterable
|
||||||
remote
|
placeholder="选择文章(可输入标题搜索)"
|
||||||
placeholder="搜索并选择文章"
|
|
||||||
:remote-method="getNodeSearchMethod(chapterIndex, nodeIndex)"
|
|
||||||
:loading="getNodeLoading(chapterIndex, nodeIndex)"
|
:loading="getNodeLoading(chapterIndex, nodeIndex)"
|
||||||
:disabled="!editMode"
|
:disabled="!editMode"
|
||||||
style="width: 100%"
|
clearable
|
||||||
|
style="flex: 1"
|
||||||
|
@focus="handleSelectFocus(chapterIndex, nodeIndex)"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="article in getNodeArticleOptions(chapterIndex, nodeIndex)"
|
v-for="article in getNodeArticleOptions(chapterIndex, nodeIndex)"
|
||||||
@@ -254,6 +283,15 @@
|
|||||||
:value="article.resourceID"
|
:value="article.resourceID"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-tooltip content="加载更多文章" placement="top">
|
||||||
|
<el-button
|
||||||
|
:icon="Refresh"
|
||||||
|
@click="loadArticlesForNode(chapterIndex, nodeIndex)"
|
||||||
|
:loading="getNodeLoading(chapterIndex, nodeIndex)"
|
||||||
|
:disabled="!editMode"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<!-- 富文本编辑 - 移除 label 关联 -->
|
<!-- 富文本编辑 - 移除 label 关联 -->
|
||||||
@@ -354,14 +392,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, onMounted } from 'vue';
|
||||||
import { ElMessage } from 'element-plus';
|
import { ElMessage } from 'element-plus';
|
||||||
import { Plus, ArrowLeft } from '@element-plus/icons-vue';
|
import { Plus, ArrowLeft, Refresh } from '@element-plus/icons-vue';
|
||||||
import { FileUpload } from '@/components/file';
|
import { FileUpload } from '@/components/file';
|
||||||
import { RichTextComponent } from '@/components/text';
|
import { RichTextComponent } from '@/components/text';
|
||||||
import { courseApi } from '@/apis/study';
|
import { courseApi } from '@/apis/study';
|
||||||
import { resourceApi } from '@/apis/resource';
|
import { resourceApi } from '@/apis/resource';
|
||||||
|
import { userApi } from '@/apis/system';
|
||||||
import type { CourseItemVO } from '@/types/study';
|
import type { CourseItemVO } from '@/types/study';
|
||||||
import type { Resource } from '@/types/resource';
|
import type { Resource } from '@/types/resource';
|
||||||
import type { SysFile } from '@/types';
|
import type { SysFile, UserVO } from '@/types';
|
||||||
import { FILE_DOWNLOAD_URL } from '@/config';
|
import { FILE_DOWNLOAD_URL } from '@/config';
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'CourseAdd'
|
name: 'CourseAdd'
|
||||||
@@ -377,11 +416,18 @@ const emit = defineEmits<{
|
|||||||
cancel: [];
|
cancel: [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
// 定义节点扩展类型(用于添加运行时属性)
|
||||||
|
type NodeWithExtras = CourseItemVO & { loading?: boolean; articleOptions?: Resource[] };
|
||||||
|
|
||||||
const formRef = ref();
|
const formRef = ref();
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
const activeChapters = ref<number[]>([]);
|
const activeChapters = ref<number[]>([]);
|
||||||
const editMode = ref(true);
|
const editMode = ref(true);
|
||||||
|
|
||||||
|
// 教师列表
|
||||||
|
const teacherList = ref<UserVO[]>([]);
|
||||||
|
const teachersLoading = ref(false);
|
||||||
|
|
||||||
// 原始数据(用于比对)
|
// 原始数据(用于比对)
|
||||||
const originalCourseItemVO = ref<CourseItemVO>();
|
const originalCourseItemVO = ref<CourseItemVO>();
|
||||||
// 当前编辑的数据
|
// 当前编辑的数据
|
||||||
@@ -403,6 +449,9 @@ const rules = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// 加载教师列表
|
||||||
|
loadTeacherList();
|
||||||
|
|
||||||
if (props.courseID) {
|
if (props.courseID) {
|
||||||
loadCourse();
|
loadCourse();
|
||||||
}
|
}
|
||||||
@@ -421,11 +470,18 @@ async function loadCourse() {
|
|||||||
courseData.chapters = [];
|
courseData.chapters = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保每个章节的 chapters 是数组(节点列表)
|
// 确保每个章节的 chapters 是数组(节点列表),并初始化扩展属性
|
||||||
courseData.chapters.forEach((chapterVO: CourseItemVO) => {
|
courseData.chapters.forEach((chapterVO: CourseItemVO) => {
|
||||||
if (!chapterVO.chapters) {
|
if (!chapterVO.chapters) {
|
||||||
chapterVO.chapters = [];
|
chapterVO.chapters = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为每个节点添加扩展属性
|
||||||
|
chapterVO.chapters.forEach((node: CourseItemVO) => {
|
||||||
|
const nodeWithExtras = node as NodeWithExtras;
|
||||||
|
nodeWithExtras.loading = false;
|
||||||
|
nodeWithExtras.articleOptions = [];
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (courseData.status === 1) {
|
if (courseData.status === 1) {
|
||||||
@@ -436,6 +492,9 @@ async function loadCourse() {
|
|||||||
// 设置当前编辑数据
|
// 设置当前编辑数据
|
||||||
currentCourseItemVO.value = JSON.parse(JSON.stringify(courseData));
|
currentCourseItemVO.value = JSON.parse(JSON.stringify(courseData));
|
||||||
console.log(currentCourseItemVO.value);
|
console.log(currentCourseItemVO.value);
|
||||||
|
|
||||||
|
// 加载所有文章资源节点的文章列表
|
||||||
|
await loadArticlesForAllNodes();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载课程失败:', error);
|
console.error('加载课程失败:', error);
|
||||||
@@ -443,6 +502,24 @@ async function loadCourse() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为所有文章资源类型的节点加载文章列表
|
||||||
|
async function loadArticlesForAllNodes() {
|
||||||
|
if (!currentCourseItemVO.value.chapters) return;
|
||||||
|
|
||||||
|
for (let chapterIndex = 0; chapterIndex < currentCourseItemVO.value.chapters.length; chapterIndex++) {
|
||||||
|
const chapter = currentCourseItemVO.value.chapters[chapterIndex];
|
||||||
|
if (!chapter.chapters) continue;
|
||||||
|
|
||||||
|
for (let nodeIndex = 0; nodeIndex < chapter.chapters.length; nodeIndex++) {
|
||||||
|
const node = chapter.chapters[nodeIndex] as NodeWithExtras;
|
||||||
|
// 只为文章资源类型的节点加载文章列表
|
||||||
|
if (node.nodeType === 0) {
|
||||||
|
await loadArticlesForNode(chapterIndex, nodeIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加章节
|
// 添加章节
|
||||||
function addChapter() {
|
function addChapter() {
|
||||||
const newChapterVO: CourseItemVO = {
|
const newChapterVO: CourseItemVO = {
|
||||||
@@ -467,7 +544,7 @@ function removeChapter(index: number) {
|
|||||||
// 添加节点
|
// 添加节点
|
||||||
function addNode(chapterIndex: number) {
|
function addNode(chapterIndex: number) {
|
||||||
const nodeIndex = currentCourseItemVO.value.chapters![chapterIndex].chapters!.length;
|
const nodeIndex = currentCourseItemVO.value.chapters![chapterIndex].chapters!.length;
|
||||||
const newNode: CourseItemVO & { loading?: boolean; articleOptions?: Resource[]; searchMethod?: (query: string) => void } = {
|
const newNode: NodeWithExtras = {
|
||||||
nodeID: '',
|
nodeID: '',
|
||||||
chapterID: currentCourseItemVO.value.chapters![chapterIndex].chapterID,
|
chapterID: currentCourseItemVO.value.chapters![chapterIndex].chapterID,
|
||||||
name: '',
|
name: '',
|
||||||
@@ -477,10 +554,12 @@ function addNode(chapterIndex: number) {
|
|||||||
isRequired: 1,
|
isRequired: 1,
|
||||||
orderNum: nodeIndex,
|
orderNum: nodeIndex,
|
||||||
loading: false,
|
loading: false,
|
||||||
articleOptions: [],
|
articleOptions: []
|
||||||
searchMethod: (query: string) => searchArticles(query, chapterIndex, nodeIndex)
|
|
||||||
};
|
};
|
||||||
currentCourseItemVO.value.chapters![chapterIndex].chapters!.push(newNode);
|
currentCourseItemVO.value.chapters![chapterIndex].chapters!.push(newNode);
|
||||||
|
|
||||||
|
// 默认是文章资源类型,立即加载文章列表
|
||||||
|
loadArticlesForNode(chapterIndex, nodeIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除节点
|
// 删除节点
|
||||||
@@ -488,12 +567,36 @@ function removeNode(chapterIndex: number, nodeIndex: number) {
|
|||||||
currentCourseItemVO.value.chapters![chapterIndex].chapters!.splice(nodeIndex, 1);
|
currentCourseItemVO.value.chapters![chapterIndex].chapters!.splice(nodeIndex, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeWithExtras = CourseItemVO & { loading?: boolean; articleOptions?: Resource[]; searchMethod?: (query: string) => void };
|
// 处理节点类型变化
|
||||||
|
function handleNodeTypeChange(chapterIndex: number, nodeIndex: number) {
|
||||||
// 辅助函数:获取节点的 searchMethod
|
|
||||||
function getNodeSearchMethod(chapterIndex: number, nodeIndex: number) {
|
|
||||||
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
|
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
|
||||||
return node.searchMethod;
|
|
||||||
|
// 如果切换为文章资源类型,加载文章列表
|
||||||
|
if (node.nodeType === 0) {
|
||||||
|
loadArticlesForNode(chapterIndex, nodeIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 为节点加载文章列表
|
||||||
|
async function loadArticlesForNode(chapterIndex: number, nodeIndex: number) {
|
||||||
|
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
|
||||||
|
|
||||||
|
node.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await resourceApi.getResourcePage({
|
||||||
|
pageNumber: 1,
|
||||||
|
pageSize: 100 // 加载前100条文章供选择
|
||||||
|
});
|
||||||
|
if (res.success && res.dataList) {
|
||||||
|
node.articleOptions = res.dataList;
|
||||||
|
console.log(`✅ 已为节点加载 ${res.dataList.length} 篇文章`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载文章列表失败:', error);
|
||||||
|
ElMessage.error('加载文章列表失败');
|
||||||
|
} finally {
|
||||||
|
node.loading = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 辅助函数:获取节点的 loading 状态
|
// 辅助函数:获取节点的 loading 状态
|
||||||
@@ -508,26 +611,41 @@ function getNodeArticleOptions(chapterIndex: number, nodeIndex: number) {
|
|||||||
return node.articleOptions || [];
|
return node.articleOptions || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索文章
|
// 处理下拉框获得焦点事件
|
||||||
async function searchArticles(query: string, chapterIndex: number, nodeIndex: number) {
|
function handleSelectFocus(chapterIndex: number, nodeIndex: number) {
|
||||||
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
|
const node = currentCourseItemVO.value.chapters![chapterIndex].chapters![nodeIndex] as NodeWithExtras;
|
||||||
if (!query) {
|
|
||||||
node.articleOptions = [];
|
// 如果还没有加载文章列表,则加载
|
||||||
return;
|
if (!node.articleOptions || node.articleOptions.length === 0) {
|
||||||
|
loadArticlesForNode(chapterIndex, nodeIndex);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
node.loading = true;
|
// 加载教师列表
|
||||||
|
async function loadTeacherList() {
|
||||||
|
teachersLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await resourceApi.getResourceList({
|
const res = await userApi.getUserPage({
|
||||||
keyword: query
|
pageNumber: 1,
|
||||||
|
pageSize: 100
|
||||||
});
|
});
|
||||||
if (res.success && res.dataList) {
|
if (res.success && res.dataList) {
|
||||||
node.articleOptions = res.dataList;
|
teacherList.value = res.dataList;
|
||||||
|
console.log(`✅ 已加载 ${res.dataList.length} 位教师`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('搜索文章失败:', error);
|
console.error('加载教师列表失败:', error);
|
||||||
|
ElMessage.error('加载教师列表失败');
|
||||||
} finally {
|
} finally {
|
||||||
node.loading = false;
|
teachersLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理教师选择框获得焦点
|
||||||
|
function handleTeacherSelectFocus() {
|
||||||
|
// 如果还没有加载教师列表,则加载
|
||||||
|
if (teacherList.value.length === 0) {
|
||||||
|
loadTeacherList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,7 +98,12 @@
|
|||||||
|
|
||||||
<!-- 文章资源 -->
|
<!-- 文章资源 -->
|
||||||
<div v-if="currentNode.nodeType === 0 && articleData" class="article-content">
|
<div v-if="currentNode.nodeType === 0 && articleData" class="article-content">
|
||||||
<ArticleShowView :as-dialog="false" :article-data="articleData" :category-list="[]" />
|
<ArticleShowView
|
||||||
|
:as-dialog="false"
|
||||||
|
:article-data="articleData"
|
||||||
|
:category-list="[]"
|
||||||
|
@videos-completed="handleArticleVideosCompleted"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 富文本内容 -->
|
<!-- 富文本内容 -->
|
||||||
@@ -135,13 +140,19 @@
|
|||||||
|
|
||||||
<!-- 学习操作 -->
|
<!-- 学习操作 -->
|
||||||
<div class="learning-actions">
|
<div class="learning-actions">
|
||||||
<el-button @click="markAsComplete" :type="isCurrentNodeCompleted ? 'success' : 'primary'"
|
<!-- 完成状态标签(仅显示,不可手动标记) -->
|
||||||
:disabled="isCurrentNodeCompleted">
|
<el-tag v-if="isCurrentNodeCompleted" type="success" size="large">
|
||||||
<el-icon>
|
<el-icon style="margin-right: 4px;">
|
||||||
<CircleCheck />
|
<CircleCheck />
|
||||||
</el-icon>
|
</el-icon>
|
||||||
{{ isCurrentNodeCompleted ? '已完成' : '标记为完成' }}
|
已完成
|
||||||
</el-button>
|
</el-tag>
|
||||||
|
<el-tag v-else type="info" size="large">
|
||||||
|
<el-icon style="margin-right: 4px;">
|
||||||
|
<InfoFilled />
|
||||||
|
</el-icon>
|
||||||
|
学习中(滚动到底部或视频播放完成后自动标记)
|
||||||
|
</el-tag>
|
||||||
|
|
||||||
<div class="navigation-buttons">
|
<div class="navigation-buttons">
|
||||||
<el-button @click="gotoPrevious" :disabled="!hasPrevious" :icon="ArrowLeft">
|
<el-button @click="gotoPrevious" :disabled="!hasPrevious" :icon="ArrowLeft">
|
||||||
@@ -181,7 +192,8 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Upload,
|
Upload,
|
||||||
Clock,
|
Clock,
|
||||||
Download
|
Download,
|
||||||
|
InfoFilled
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
import { ArticleShowView } from '@/views/public/article';
|
import { ArticleShowView } from '@/views/public/article';
|
||||||
import { courseApi } from '@/apis/study';
|
import { courseApi } from '@/apis/study';
|
||||||
@@ -334,7 +346,8 @@ watch(currentNode, async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
startLearningTimer();
|
// 不在这里启动定时器,等待学习记录加载完成后再启动
|
||||||
|
// startLearningTimer(); 移到loadLearningRecord和createLearningRecord成功后
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -388,12 +401,19 @@ async function loadLearningRecord() {
|
|||||||
|
|
||||||
if (res.success && res.dataList && res.dataList.length > 0) {
|
if (res.success && res.dataList && res.dataList.length > 0) {
|
||||||
learningRecord.value = res.dataList[0];
|
learningRecord.value = res.dataList[0];
|
||||||
|
console.log('✅ 学习记录加载成功:', learningRecord.value);
|
||||||
|
|
||||||
// 从本地存储加载已完成的节点列表
|
// 从本地存储加载已完成的节点列表
|
||||||
const savedProgress = localStorage.getItem(`course_${props.courseId}_nodes`);
|
const savedProgress = localStorage.getItem(`course_${props.courseId}_nodes`);
|
||||||
if (savedProgress) {
|
if (savedProgress) {
|
||||||
completedNodes.value = new Set(JSON.parse(savedProgress));
|
completedNodes.value = new Set(JSON.parse(savedProgress));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 学习记录加载成功后,启动学习计时器
|
||||||
|
if (!learningRecord.value.isComplete) {
|
||||||
|
startLearningTimer();
|
||||||
|
console.log('⏱️ 学习计时器已启动');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 没有学习记录,创建新的
|
// 没有学习记录,创建新的
|
||||||
await createLearningRecord();
|
await createLearningRecord();
|
||||||
@@ -403,6 +423,33 @@ async function loadLearningRecord() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 简单的字符串哈希函数
|
||||||
|
function hashString(str: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const char = str.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + char;
|
||||||
|
hash = hash & hash; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
// 转换为16进制字符串,并确保长度一致
|
||||||
|
return Math.abs(hash).toString(16).padStart(8, '0') + str.substring(0, 8).replace(/[^a-zA-Z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成课程学习记录的taskId(当没有真实taskId时)
|
||||||
|
function generateCourseTaskId(
|
||||||
|
courseId: string,
|
||||||
|
chapterId?: string,
|
||||||
|
nodeId?: string,
|
||||||
|
userId?: string
|
||||||
|
): string {
|
||||||
|
// 使用简短格式,确保不超过50字符:SC_{组合hash}
|
||||||
|
// SC = Self-study Course
|
||||||
|
const combinedString = `${courseId}_${chapterId || ''}_${nodeId || ''}_${userId || ''}`;
|
||||||
|
const combinedHash = hashString(combinedString).substring(0, 20);
|
||||||
|
const courseHash = hashString(courseId).substring(0, 10);
|
||||||
|
return `SC_${courseHash}_${combinedHash}`; // 长度:3 + 10 + 1 + 20 = 34字符
|
||||||
|
}
|
||||||
|
|
||||||
// 创建学习记录
|
// 创建学习记录
|
||||||
async function createLearningRecord() {
|
async function createLearningRecord() {
|
||||||
if (!userInfo.value?.id) return;
|
if (!userInfo.value?.id) return;
|
||||||
@@ -413,18 +460,31 @@ async function createLearningRecord() {
|
|||||||
courseItemVO.value?.chapterNodes?.[currentChapter.chapterID]?.[currentNodeIndex.value] :
|
courseItemVO.value?.chapterNodes?.[currentChapter.chapterID]?.[currentNodeIndex.value] :
|
||||||
null;
|
null;
|
||||||
|
|
||||||
|
const taskId = route.query.taskId as string;
|
||||||
|
// 如果没有taskId,生成一个自学任务ID
|
||||||
|
const effectiveTaskId = taskId || generateCourseTaskId(
|
||||||
|
props.courseId,
|
||||||
|
currentChapter?.chapterID,
|
||||||
|
currentNodeData?.nodeID,
|
||||||
|
userInfo.value.id
|
||||||
|
);
|
||||||
|
|
||||||
const res = await learningRecordApi.createRecord({
|
const res = await learningRecordApi.createRecord({
|
||||||
userID: userInfo.value.id,
|
userID: userInfo.value.id,
|
||||||
resourceType: 2, // 课程
|
resourceType: 2, // 课程
|
||||||
courseID: props.courseId,
|
courseID: props.courseId,
|
||||||
chapterID: currentChapter?.chapterID,
|
chapterID: currentChapter?.chapterID,
|
||||||
nodeID: currentNodeData?.nodeID,
|
nodeID: currentNodeData?.nodeID,
|
||||||
taskID: route.query.taskId as string
|
taskID: effectiveTaskId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
learningRecord.value = res.data;
|
learningRecord.value = res.data;
|
||||||
console.log('学习记录创建成功');
|
console.log('✅ 学习记录创建成功,taskID:', effectiveTaskId);
|
||||||
|
|
||||||
|
// 学习记录创建成功后,启动学习计时器
|
||||||
|
startLearningTimer();
|
||||||
|
console.log('⏱️ 学习计时器已启动');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('创建学习记录失败:', error);
|
console.error('创建学习记录失败:', error);
|
||||||
@@ -512,23 +572,6 @@ function gotoNext() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记为完成
|
|
||||||
async function markAsComplete() {
|
|
||||||
if (!currentNode.value) return;
|
|
||||||
|
|
||||||
const nodeKey = `${currentChapterIndex.value}-${currentNodeIndex.value}`;
|
|
||||||
await markNodeComplete(nodeKey);
|
|
||||||
|
|
||||||
ElMessage.success('已标记为完成');
|
|
||||||
|
|
||||||
// 自动跳转到下一节
|
|
||||||
if (hasNext.value) {
|
|
||||||
setTimeout(() => {
|
|
||||||
gotoNext();
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 判断节点是否完成
|
// 判断节点是否完成
|
||||||
function isNodeCompleted(chapterIndex: number, nodeIndex: number): boolean {
|
function isNodeCompleted(chapterIndex: number, nodeIndex: number): boolean {
|
||||||
const nodeKey = `${chapterIndex}-${nodeIndex}`;
|
const nodeKey = `${chapterIndex}-${nodeIndex}`;
|
||||||
@@ -560,7 +603,15 @@ function stopLearningTimer() {
|
|||||||
|
|
||||||
// 保存学习进度
|
// 保存学习进度
|
||||||
async function saveLearningProgress() {
|
async function saveLearningProgress() {
|
||||||
if (!userInfo.value?.id || !learningRecord.value) return;
|
if (!userInfo.value?.id) {
|
||||||
|
console.warn('⚠️ 无法保存学习进度:用户信息不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!learningRecord.value) {
|
||||||
|
console.warn('⚠️ 无法保存学习进度:学习记录不存在');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 如果课程已完成,不再保存进度
|
// 如果课程已完成,不再保存进度
|
||||||
if (learningRecord.value.isComplete) {
|
if (learningRecord.value.isComplete) {
|
||||||
@@ -591,8 +642,10 @@ async function saveLearningProgress() {
|
|||||||
|
|
||||||
// 重置开始时间
|
// 重置开始时间
|
||||||
learningStartTime.value = currentTime;
|
learningStartTime.value = currentTime;
|
||||||
|
|
||||||
|
console.log(`💾 学习进度已保存 - 时长: ${updatedRecord.duration}秒, 进度: ${updatedRecord.progress}%`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存学习进度失败:', error);
|
console.error('❌ 保存学习进度失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,12 +681,15 @@ async function markCourseComplete() {
|
|||||||
if (!userInfo.value?.id || !learningRecord.value) return;
|
if (!userInfo.value?.id || !learningRecord.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 使用learningRecord中保存的taskID(可能是真实任务ID或生成的自学ID)
|
||||||
|
const taskId = learningRecord.value.taskID || (route.query.taskId as string);
|
||||||
|
|
||||||
await learningRecordApi.markComplete({
|
await learningRecordApi.markComplete({
|
||||||
id: learningRecord.value.id,
|
id: learningRecord.value.id,
|
||||||
userID: userInfo.value.id,
|
userID: userInfo.value.id,
|
||||||
resourceType: 2,
|
resourceType: 2,
|
||||||
resourceID: props.courseId,
|
resourceID: props.courseId,
|
||||||
taskID: route.query.taskId as string,
|
taskID: taskId,
|
||||||
progress: 100,
|
progress: 100,
|
||||||
isComplete: true
|
isComplete: true
|
||||||
});
|
});
|
||||||
@@ -681,9 +737,13 @@ function handleVideoProgress(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理视频结束
|
// 处理视频结束
|
||||||
function handleVideoEnded() {
|
async function handleVideoEnded() {
|
||||||
if (!isCurrentNodeCompleted.value) {
|
if (!isCurrentNodeCompleted.value) {
|
||||||
markAsComplete();
|
ElMessage.success('视频播放完成,已自动标记为完成');
|
||||||
|
|
||||||
|
// 自动标记当前节点为完成
|
||||||
|
const nodeKey = `${currentChapterIndex.value}-${currentNodeIndex.value}`;
|
||||||
|
await markNodeComplete(nodeKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,7 +793,7 @@ function initRichTextVideoListeners() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理富文本视频播放结束
|
// 处理富文本视频播放结束
|
||||||
function handleRichTextVideoEnded(videoIndex: number) {
|
async function handleRichTextVideoEnded(videoIndex: number) {
|
||||||
// 标记该视频已完成
|
// 标记该视频已完成
|
||||||
completedRichTextVideos.value.add(videoIndex);
|
completedRichTextVideos.value.add(videoIndex);
|
||||||
|
|
||||||
@@ -742,12 +802,24 @@ function handleRichTextVideoEnded(videoIndex: number) {
|
|||||||
// 检查是否所有视频都已完成
|
// 检查是否所有视频都已完成
|
||||||
if (completedCount >= totalRichTextVideos.value) {
|
if (completedCount >= totalRichTextVideos.value) {
|
||||||
if (!isCurrentNodeCompleted.value) {
|
if (!isCurrentNodeCompleted.value) {
|
||||||
ElMessage.success(`所有视频播放完成 (${totalRichTextVideos.value}/${totalRichTextVideos.value})`);
|
ElMessage.success(`所有视频播放完成 (${totalRichTextVideos.value}/${totalRichTextVideos.value}),已自动标记为完成`);
|
||||||
markAsComplete();
|
|
||||||
|
// 自动标记当前节点为完成
|
||||||
|
const nodeKey = `${currentChapterIndex.value}-${currentNodeIndex.value}`;
|
||||||
|
await markNodeComplete(nodeKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理文章视频播放完成(从ArticleShow组件emit的事件)
|
||||||
|
async function handleArticleVideosCompleted() {
|
||||||
|
if (!isCurrentNodeCompleted.value) {
|
||||||
|
console.log('📹 文章中的所有视频播放完成,自动标记节点为完成');
|
||||||
|
const nodeKey = `${currentChapterIndex.value}-${currentNodeIndex.value}`;
|
||||||
|
await markNodeComplete(nodeKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理音频进度
|
// 处理音频进度
|
||||||
function handleAudioProgress(event: Event) {
|
function handleAudioProgress(event: Event) {
|
||||||
const audio = event.target as HTMLAudioElement;
|
const audio = event.target as HTMLAudioElement;
|
||||||
@@ -759,9 +831,13 @@ function handleAudioProgress(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 处理音频结束
|
// 处理音频结束
|
||||||
function handleAudioEnded() {
|
async function handleAudioEnded() {
|
||||||
if (!isCurrentNodeCompleted.value) {
|
if (!isCurrentNodeCompleted.value) {
|
||||||
markAsComplete();
|
ElMessage.success('音频播放完成,已自动标记为完成');
|
||||||
|
|
||||||
|
// 自动标记当前节点为完成
|
||||||
|
const nodeKey = `${currentChapterIndex.value}-${currentNodeIndex.value}`;
|
||||||
|
await markNodeComplete(nodeKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ const loginForm = reactive<LoginParam>({
|
|||||||
captcha: '',
|
captcha: '',
|
||||||
captchaId: '',
|
captchaId: '',
|
||||||
rememberMe: false,
|
rememberMe: false,
|
||||||
agree: false
|
agree: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// 计算属性:登录模式标题
|
// 计算属性:登录模式标题
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ const registerForm = reactive<RegisterParam>({
|
|||||||
emailCode: '',
|
emailCode: '',
|
||||||
smsSessionId: '',
|
smsSessionId: '',
|
||||||
emailSessionId: '',
|
emailSessionId: '',
|
||||||
agree: false
|
agree: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// 根据注册方式显示不同的标题
|
// 根据注册方式显示不同的标题
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ async function loadResources() {
|
|||||||
const filter: ResourceSearchParams = {
|
const filter: ResourceSearchParams = {
|
||||||
tagID: props.tagID,
|
tagID: props.tagID,
|
||||||
keyword: props.searchKeyword,
|
keyword: props.searchKeyword,
|
||||||
// status: 1 // 只加载已发布的
|
status: 1 // 只加载已发布的
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageParam: PageParam = {
|
const pageParam: PageParam = {
|
||||||
|
|||||||
Reference in New Issue
Block a user