Merge remote-tracking branch 'origin/dev2' into dev2

This commit is contained in:
Lqc 2025-09-22 15:34:03 +08:00
commit 3c21fc3500
26 changed files with 5310 additions and 66 deletions

View File

@ -24,10 +24,13 @@ import org.jeecg.modules.aiol.entity.AiolChatMember;
import org.jeecg.modules.aiol.entity.AiolChatMessage; import org.jeecg.modules.aiol.entity.AiolChatMessage;
import org.jeecg.modules.aiol.entity.AiolClass; import org.jeecg.modules.aiol.entity.AiolClass;
import org.jeecg.modules.aiol.entity.AiolClassStudent; import org.jeecg.modules.aiol.entity.AiolClassStudent;
import org.jeecg.modules.aiol.dto.ChatWithUnreadCountDTO;
import org.jeecg.modules.aiol.mapper.AiolChatMemberMapper; import org.jeecg.modules.aiol.mapper.AiolChatMemberMapper;
import org.jeecg.modules.aiol.mapper.AiolChatMessageMapper; import org.jeecg.modules.aiol.mapper.AiolChatMessageMapper;
import org.jeecg.modules.aiol.mapper.AiolClassMapper; import org.jeecg.modules.aiol.mapper.AiolClassMapper;
import org.jeecg.modules.aiol.mapper.AiolClassStudentMapper; import org.jeecg.modules.aiol.mapper.AiolClassStudentMapper;
import org.jeecg.modules.system.mapper.SysUserRoleMapper;
import org.jeecg.modules.aiol.constant.RoleConst;
import org.jeecg.modules.aiol.service.IAiolChatService; import org.jeecg.modules.aiol.service.IAiolChatService;
import org.jeecg.modules.system.entity.SysUser; import org.jeecg.modules.system.entity.SysUser;
import org.jeecg.modules.system.mapper.SysUserMapper; import org.jeecg.modules.system.mapper.SysUserMapper;
@ -85,6 +88,9 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
@Autowired @Autowired
private AiolClassStudentMapper aiolClassStudentMapper; private AiolClassStudentMapper aiolClassStudentMapper;
@Autowired
private SysUserRoleMapper sysUserRoleMapper;
/** /**
* 分页列表查询 * 分页列表查询
* *
@ -218,9 +224,9 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
* @param request * @param request
* @return * @return
*/ */
@Operation(summary = "查询当前用户会话列表", description = "根据当前登录用户ID查询其参与的会话列表") @Operation(summary = "查询当前用户会话列表", description = "根据当前登录用户ID查询其参与的会话列表,包含未读消息数")
@GetMapping(value = "/my_chats") @GetMapping(value = "/my_chats")
public Result<List<AiolChat>> queryMyChatList(HttpServletRequest request) { public Result<List<ChatWithUnreadCountDTO>> queryMyChatList(HttpServletRequest request) {
try { try {
// 1. 从token获取当前用户信息 // 1. 从token获取当前用户信息
String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN); String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN);
@ -235,7 +241,7 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
return Result.error("用户信息不存在"); return Result.error("用户信息不存在");
} }
// 2. 根据用户ID查询会话成员表获取chat_id列表 // 2. 根据用户ID查询会话成员表获取chat_id列表和last_read_msg_id
QueryWrapper<AiolChatMember> memberWrapper = new QueryWrapper<>(); QueryWrapper<AiolChatMember> memberWrapper = new QueryWrapper<>();
memberWrapper.eq("user_id", sysUser.getId()); memberWrapper.eq("user_id", sysUser.getId());
List<AiolChatMember> chatMembers = aiolChatMemberMapper.selectList(memberWrapper); List<AiolChatMember> chatMembers = aiolChatMemberMapper.selectList(memberWrapper);
@ -256,37 +262,16 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
List<AiolChat> chatList = aiolChatService.list(chatWrapper); List<AiolChat> chatList = aiolChatService.list(chatWrapper);
// 5. 处理私聊类型的会话获取对方用户信息 // 5. 转换为包含未读消息数的DTO列表
List<ChatWithUnreadCountDTO> resultList = new java.util.ArrayList<>();
for (AiolChat chat : chatList) { for (AiolChat chat : chatList) {
if (chat.getType() != null && chat.getType() == 0) { ChatWithUnreadCountDTO chatDTO = convertToChatWithUnreadCount(chat, chatMembers, sysUser.getId());
// 私聊类型需要获取对方用户信息 resultList.add(chatDTO);
try {
// 查询该会话的成员排除当前用户
QueryWrapper<AiolChatMember> otherMemberWrapper = new QueryWrapper<>();
otherMemberWrapper.eq("chat_id", chat.getId())
.ne("user_id", sysUser.getId());
List<AiolChatMember> otherMembers = aiolChatMemberMapper.selectList(otherMemberWrapper);
if (!otherMembers.isEmpty()) {
// 获取对方用户ID
String otherUserId = otherMembers.get(0).getUserId();
// 查询对方用户信息
SysUser otherUser = sysUserMapper.selectById(otherUserId);
if (otherUser != null) {
// 替换会话的name和avatar为对方用户信息
chat.setName(otherUser.getRealname());
chat.setAvatar(otherUser.getAvatar());
}
}
} catch (Exception e) {
log.warn("获取私聊对方用户信息失败: chatId={}, error={}", chat.getId(), e.getMessage());
}
}
} }
log.info("用户 {} 查询到 {} 个会话", username, chatList.size()); log.info("用户 {} 查询到 {} 个会话", username, resultList.size());
return Result.OK(chatList); return Result.OK(resultList);
} catch (Exception e) { } catch (Exception e) {
log.error("查询用户会话列表失败: {}", e.getMessage(), e); log.error("查询用户会话列表失败: {}", e.getMessage(), e);
@ -300,7 +285,7 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
* @param chatId 会话ID * @param chatId 会话ID
* @return * @return
*/ */
@Operation(summary = "查询群聊会话成员列表", description = "根据会话ID查询该会话的所有成员信息包括用户ID、真实姓名和头像") @Operation(summary = "查询群聊会话成员列表", description = "根据会话ID查询该会话的所有成员信息包括用户ID、真实姓名、头像和教师身份")
@GetMapping(value = "/{chatId}/members") @GetMapping(value = "/{chatId}/members")
public Result<List<Map<String, Object>>> queryChatMembers(@PathVariable(value = "chatId") String chatId) { public Result<List<Map<String, Object>>> queryChatMembers(@PathVariable(value = "chatId") String chatId) {
try { try {
@ -321,7 +306,26 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
// 3. 查询用户信息 // 3. 查询用户信息
List<SysUser> userList = sysUserMapper.selectByIds(userIds); List<SysUser> userList = sysUserMapper.selectByIds(userIds);
// 4. 构建返回结果 // 4. 查询所有用户的教师角色身份
Map<String, Boolean> teacherStatusMap = new java.util.HashMap<>();
if (!userIds.isEmpty()) {
try {
QueryWrapper<org.jeecg.modules.system.entity.SysUserRole> roleWrapper = new QueryWrapper<>();
roleWrapper.eq("role_id", RoleConst.TEACHER_ROLE_ID)
.in("user_id", userIds);
List<org.jeecg.modules.system.entity.SysUserRole> teacherRoleList = sysUserRoleMapper.selectList(roleWrapper);
// 构建教师身份映射
for (org.jeecg.modules.system.entity.SysUserRole userRole : teacherRoleList) {
teacherStatusMap.put(userRole.getUserId(), true);
}
} catch (Exception e) {
log.warn("查询教师角色身份失败: error={}", e.getMessage());
}
}
// 5. 构建返回结果
List<Map<String, Object>> result = new java.util.ArrayList<>(); List<Map<String, Object>> result = new java.util.ArrayList<>();
for (SysUser user : userList) { for (SysUser user : userList) {
Map<String, Object> memberInfo = new java.util.HashMap<>(); Map<String, Object> memberInfo = new java.util.HashMap<>();
@ -329,6 +333,10 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
memberInfo.put("realname", user.getRealname()); memberInfo.put("realname", user.getRealname());
memberInfo.put("avatar", user.getAvatar()); memberInfo.put("avatar", user.getAvatar());
// 添加教师身份标记
boolean isTeacher = teacherStatusMap.getOrDefault(user.getId(), false);
memberInfo.put("isTeacher", isTeacher);
// 可选添加更多用户信息 // 可选添加更多用户信息
memberInfo.put("username", user.getUsername()); memberInfo.put("username", user.getUsername());
memberInfo.put("phone", user.getPhone()); memberInfo.put("phone", user.getPhone());
@ -502,4 +510,458 @@ public class AiolChatController extends JeecgController<AiolChat, IAiolChatServi
return Result.error("查询会话详情失败: " + e.getMessage()); return Result.error("查询会话详情失败: " + e.getMessage());
} }
} }
/**
* 开启全员禁言
*
* @param chatId 会话ID
* @return
*/
@AutoLog(value = "会话-开启全员禁言")
@Operation(summary = "开启全员禁言", description = "开启群聊的全员禁言功能设置iz_all_muted字段为1")
@PostMapping(value = "/{chatId}/mute_all")
public Result<String> muteAll(@PathVariable(value = "chatId") String chatId) {
try {
return updateChatSetting(chatId, "iz_all_muted", 1, "开启全员禁言");
} catch (Exception e) {
log.error("开启全员禁言失败: chatId={}, error={}", chatId, e.getMessage(), e);
return Result.error("开启全员禁言失败: " + e.getMessage());
}
}
/**
* 关闭全员禁言
*
* @param chatId 会话ID
* @return
*/
@AutoLog(value = "会话-关闭全员禁言")
@Operation(summary = "关闭全员禁言", description = "关闭群聊的全员禁言功能设置iz_all_muted字段为0")
@PostMapping(value = "/{chatId}/unmute_all")
public Result<String> unmuteAll(@PathVariable(value = "chatId") String chatId) {
try {
return updateChatSetting(chatId, "iz_all_muted", 0, "关闭全员禁言");
} catch (Exception e) {
log.error("关闭全员禁言失败: chatId={}, error={}", chatId, e.getMessage(), e);
return Result.error("关闭全员禁言失败: " + e.getMessage());
}
}
/**
* 开启显示教师标签
*
* @param chatId 会话ID
* @return
*/
@AutoLog(value = "会话-开启显示教师标签")
@Operation(summary = "开启显示教师标签", description = "开启群聊中显示教师标签功能设置show_label字段为1")
@PostMapping(value = "/{chatId}/show_label")
public Result<String> showLabel(@PathVariable(value = "chatId") String chatId) {
try {
return updateChatSetting(chatId, "show_label", 1, "开启显示教师标签");
} catch (Exception e) {
log.error("开启显示教师标签失败: chatId={}, error={}", chatId, e.getMessage(), e);
return Result.error("开启显示教师标签失败: " + e.getMessage());
}
}
/**
* 关闭显示教师标签
*
* @param chatId 会话ID
* @return
*/
@AutoLog(value = "会话-关闭显示教师标签")
@Operation(summary = "关闭显示教师标签", description = "关闭群聊中显示教师标签功能设置show_label字段为0")
@PostMapping(value = "/{chatId}/hide_label")
public Result<String> hideLabel(@PathVariable(value = "chatId") String chatId) {
try {
return updateChatSetting(chatId, "show_label", 0, "关闭显示教师标签");
} catch (Exception e) {
log.error("关闭显示教师标签失败: chatId={}, error={}", chatId, e.getMessage(), e);
return Result.error("关闭显示教师标签失败: " + e.getMessage());
}
}
/**
* 通用更新会话设置的方法
*
* @param chatId 会话ID
* @param fieldName 字段名
* @param value 字段值
* @param operationName 操作名称
* @return
*/
private Result<String> updateChatSetting(String chatId, String fieldName, Integer value, String operationName) {
// 1. 查询会话是否存在
AiolChat chat = aiolChatService.getById(chatId);
if (chat == null) {
return Result.error("会话不存在");
}
// 2. 根据字段名设置相应的值
if ("iz_all_muted".equals(fieldName)) {
chat.setIzAllMuted(value);
} else if ("show_label".equals(fieldName)) {
chat.setShowLabel(value);
} else {
return Result.error("无效的字段名");
}
// 3. 更新数据库
boolean updated = aiolChatService.updateById(chat);
if (!updated) {
return Result.error(operationName + "失败");
}
log.info("{}成功: chatId={}, {}={}", operationName, chatId, fieldName, value);
return Result.OK(operationName + "成功!");
}
/**
* 禁言群聊成员
*
* @param chatId 会话ID
* @param userId 用户ID
* @return
*/
@AutoLog(value = "会话-禁言群聊成员")
@Operation(summary = "禁言群聊成员", description = "禁言指定群聊成员设置iz_muted字段为1")
@PostMapping(value = "/{chatId}/mute_member/{userId}")
public Result<String> muteMember(@PathVariable(value = "chatId") String chatId,
@PathVariable(value = "userId") String userId) {
try {
return updateMemberSetting(chatId, userId, "iz_muted", 1, "禁言群聊成员");
} catch (Exception e) {
log.error("禁言群聊成员失败: chatId={}, userId={}, error={}", chatId, userId, e.getMessage(), e);
return Result.error("禁言群聊成员失败: " + e.getMessage());
}
}
/**
* 解除禁言群聊成员
*
* @param chatId 会话ID
* @param userId 用户ID
* @return
*/
@AutoLog(value = "会话-解除禁言群聊成员")
@Operation(summary = "解除禁言群聊成员", description = "解除指定群聊成员的禁言状态设置iz_muted字段为0")
@PostMapping(value = "/{chatId}/unmute_member/{userId}")
public Result<String> unmuteMember(@PathVariable(value = "chatId") String chatId,
@PathVariable(value = "userId") String userId) {
try {
return updateMemberSetting(chatId, userId, "iz_muted", 0, "解除禁言群聊成员");
} catch (Exception e) {
log.error("解除禁言群聊成员失败: chatId={}, userId={}, error={}", chatId, userId, e.getMessage(), e);
return Result.error("解除禁言群聊成员失败: " + e.getMessage());
}
}
/**
* 开启免打扰
*
* @param chatId 会话ID
* @param userId 用户ID
* @return
*/
@AutoLog(value = "会话-开启免打扰")
@Operation(summary = "开启免打扰", description = "为指定用户开启群聊免打扰功能设置iz_not_disturb字段为1")
@PostMapping(value = "/{chatId}/enable_not_disturb/{userId}")
public Result<String> enableNotDisturb(@PathVariable(value = "chatId") String chatId,
@PathVariable(value = "userId") String userId) {
try {
return updateMemberSetting(chatId, userId, "iz_not_disturb", 1, "开启免打扰");
} catch (Exception e) {
log.error("开启免打扰失败: chatId={}, userId={}, error={}", chatId, userId, e.getMessage(), e);
return Result.error("开启免打扰失败: " + e.getMessage());
}
}
/**
* 关闭免打扰
*
* @param chatId 会话ID
* @param userId 用户ID
* @return
*/
@AutoLog(value = "会话-关闭免打扰")
@Operation(summary = "关闭免打扰", description = "为指定用户关闭群聊免打扰功能设置iz_not_disturb字段为0")
@PostMapping(value = "/{chatId}/disable_not_disturb/{userId}")
public Result<String> disableNotDisturb(@PathVariable(value = "chatId") String chatId,
@PathVariable(value = "userId") String userId) {
try {
return updateMemberSetting(chatId, userId, "iz_not_disturb", 0, "关闭免打扰");
} catch (Exception e) {
log.error("关闭免打扰失败: chatId={}, userId={}, error={}", chatId, userId, e.getMessage(), e);
return Result.error("关闭免打扰失败: " + e.getMessage());
}
}
/**
* 通用更新群聊成员设置的方法
*
* @param chatId 会话ID
* @param userId 用户ID
* @param fieldName 字段名
* @param value 字段值
* @param operationName 操作名称
* @return
*/
private Result<String> updateMemberSetting(String chatId, String userId, String fieldName, Integer value, String operationName) {
// 1. 查询群聊成员是否存在
QueryWrapper<AiolChatMember> memberWrapper = new QueryWrapper<>();
memberWrapper.eq("chat_id", chatId).eq("user_id", userId);
AiolChatMember chatMember = aiolChatMemberMapper.selectOne(memberWrapper);
if (chatMember == null) {
return Result.error("该用户不是群聊成员或会话不存在");
}
// 2. 根据字段名设置相应的值
if ("iz_muted".equals(fieldName)) {
chatMember.setIzMuted(value);
} else if ("iz_not_disturb".equals(fieldName)) {
chatMember.setIzNotDisturb(value);
} else {
return Result.error("无效的字段名");
}
// 3. 更新数据库
boolean updated = aiolChatMemberMapper.updateById(chatMember) > 0;
if (!updated) {
return Result.error(operationName + "失败");
}
log.info("{}成功: chatId={}, userId={}, {}={}", operationName, chatId, userId, fieldName, value);
return Result.OK(operationName + "成功!");
}
/**
* 将AiolChat转换为包含未读消息数的DTO
* @param chat 会话实体
* @param chatMembers 会话成员列表
* @param userId 当前用户ID
* @return 包含未读消息数的DTO
*/
private ChatWithUnreadCountDTO convertToChatWithUnreadCount(AiolChat chat, List<AiolChatMember> chatMembers, String userId) {
ChatWithUnreadCountDTO chatDTO = new ChatWithUnreadCountDTO();
// 复制基本属性
chatDTO.setId(chat.getId());
chatDTO.setType(chat.getType());
chatDTO.setName(chat.getName());
chatDTO.setAvatar(chat.getAvatar());
chatDTO.setRefId(chat.getRefId());
chatDTO.setIzAllMuted(chat.getIzAllMuted());
chatDTO.setShowLabel(chat.getShowLabel());
chatDTO.setCreateBy(chat.getCreateBy());
chatDTO.setCreateTime(chat.getCreateTime());
chatDTO.setUpdateBy(chat.getUpdateBy());
chatDTO.setUpdateTime(chat.getUpdateTime());
try {
// 1. 计算未读消息数
AiolChatMember currentUserMember = chatMembers.stream()
.filter(member -> member.getChatId().equals(chat.getId()) && member.getUserId().equals(userId))
.findFirst()
.orElse(null);
int unreadCount = 0;
if (currentUserMember != null) {
String lastReadMsgId = currentUserMember.getLastReadMsgId();
// 查询该会话中last_read_msg_id之后的消息数量
QueryWrapper<AiolChatMessage> messageWrapper = new QueryWrapper<>();
messageWrapper.eq("chat_id", chat.getId());
if (lastReadMsgId != null && !lastReadMsgId.trim().isEmpty()) {
// 如果有最后读取的消息ID查询该消息之后的消息
QueryWrapper<AiolChatMessage> lastReadMsgWrapper = new QueryWrapper<>();
lastReadMsgWrapper.eq("chat_id", chat.getId()).eq("id", lastReadMsgId);
AiolChatMessage lastReadMsg = aiolChatMessageMapper.selectOne(lastReadMsgWrapper);
if (lastReadMsg != null) {
// 查询创建时间晚于最后读取消息的消息数量
messageWrapper.gt("create_time", lastReadMsg.getCreateTime());
}
}
Long count = aiolChatMessageMapper.selectCount(messageWrapper);
unreadCount = count != null ? count.intValue() : 0;
}
chatDTO.setUnreadCount(unreadCount);
// 2. 处理私聊类型的会话获取对方用户信息
if (chat.getType() != null && chat.getType() == 0) {
// 私聊类型需要获取对方用户信息
try {
// 查询该会话的成员排除当前用户
QueryWrapper<AiolChatMember> otherMemberWrapper = new QueryWrapper<>();
otherMemberWrapper.eq("chat_id", chat.getId())
.ne("user_id", userId);
List<AiolChatMember> otherMembers = aiolChatMemberMapper.selectList(otherMemberWrapper);
if (!otherMembers.isEmpty()) {
// 获取对方用户ID
String otherUserId = otherMembers.get(0).getUserId();
// 查询对方用户信息
SysUser otherUser = sysUserMapper.selectById(otherUserId);
if (otherUser != null) {
// 替换会话的name和avatar为对方用户信息
chatDTO.setName(otherUser.getRealname());
chatDTO.setAvatar(otherUser.getAvatar());
}
}
} catch (Exception e) {
log.warn("获取私聊对方用户信息失败: chatId={}, error={}", chat.getId(), e.getMessage());
}
}
} catch (Exception e) {
log.error("转换会话信息失败: chatId={}, error={}", chat.getId(), e.getMessage(), e);
// 即使转换失败也返回基本的会话信息
chatDTO.setUnreadCount(0);
}
return chatDTO;
}
/**
* 更新用户最后读取的消息ID
*
* @param chatId 会话ID
* @param messageId 消息ID
* @param request HTTP请求对象
* @return
*/
@AutoLog(value = "会话-更新最后读取消息ID")
@Operation(summary = "更新最后读取消息ID", description = "更新当前用户在指定会话中的最后读取消息ID")
@PostMapping(value = "/{chatId}/update_last_read/{messageId}")
public Result<String> updateLastReadMsgId(@PathVariable(value = "chatId") String chatId,
@PathVariable(value = "messageId") String messageId,
HttpServletRequest request) {
try {
// 1. 从token获取当前用户信息
String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN);
if (token == null || token.trim().isEmpty()) {
return Result.error("用户未登录");
}
String username = JwtUtil.getUsername(token);
LoginUser sysUser = sysBaseApi.getUserByName(username);
if (sysUser == null) {
return Result.error("用户信息不存在");
}
// 2. 查询群聊成员是否存在
QueryWrapper<AiolChatMember> memberWrapper = new QueryWrapper<>();
memberWrapper.eq("chat_id", chatId).eq("user_id", sysUser.getId());
AiolChatMember chatMember = aiolChatMemberMapper.selectOne(memberWrapper);
if (chatMember == null) {
return Result.error("该用户不是群聊成员或会话不存在");
}
// 3. 验证消息是否存在
AiolChatMessage message = aiolChatMessageMapper.selectById(messageId);
if (message == null || !message.getChatId().equals(chatId)) {
return Result.error("消息不存在或不属于该会话");
}
// 4. 更新最后读取的消息ID
chatMember.setLastReadMsgId(messageId);
boolean updated = aiolChatMemberMapper.updateById(chatMember) > 0;
if (!updated) {
return Result.error("更新失败");
}
log.info("用户 {} 更新会话 {} 的最后读取消息ID为 {}", username, chatId, messageId);
return Result.OK("更新成功!");
} catch (Exception e) {
log.error("更新最后读取消息ID失败: chatId={}, messageId={}, error={}", chatId, messageId, e.getMessage(), e);
return Result.error("更新失败: " + e.getMessage());
}
}
/**
* 退出群聊
*
* @param chatId 会话ID
* @param request HTTP请求对象
* @return
*/
@AutoLog(value = "会话-退出群聊")
@Operation(summary = "退出群聊", description = "当前用户退出指定的群聊会话从aiol_chat_member表中删除用户记录")
@DeleteMapping(value = "/{chatId}/exit")
public Result<String> exitChat(@PathVariable(value = "chatId") String chatId,
HttpServletRequest request) {
try {
// 1. 从token获取当前用户信息
String token = request.getHeader(CommonConstant.X_ACCESS_TOKEN);
if (token == null || token.trim().isEmpty()) {
return Result.error("用户未登录");
}
String username = JwtUtil.getUsername(token);
LoginUser sysUser = sysBaseApi.getUserByName(username);
if (sysUser == null) {
return Result.error("用户信息不存在");
}
// 2. 验证会话是否存在
AiolChat chat = aiolChatService.getById(chatId);
if (chat == null) {
return Result.error("会话不存在");
}
// 3. 查询用户是否为该会话的成员
QueryWrapper<AiolChatMember> memberWrapper = new QueryWrapper<>();
memberWrapper.eq("chat_id", chatId).eq("user_id", sysUser.getId());
AiolChatMember chatMember = aiolChatMemberMapper.selectOne(memberWrapper);
if (chatMember == null) {
return Result.error("您不是该会话的成员");
}
// 4. 检查是否为会话创建者可选不允许创建者退出
if (chat.getCreateBy() != null && chat.getCreateBy().equals(sysUser.getId())) {
// 可选如果创建者退出需要转移创建者权限或不允许退出
log.warn("会话创建者尝试退出群聊: chatId={}, userId={}", chatId, sysUser.getId());
// 这里可以选择是否允许创建者退出或者需要先转移权限
// return Result.error("群聊创建者不能直接退出,请先转移群主权限");
}
// 5. 从会话成员表中删除该用户
boolean deleted = aiolChatMemberMapper.deleteById(chatMember.getId()) > 0;
if (!deleted) {
return Result.error("退出群聊失败");
}
// 6. 检查会话是否还有其他成员如果没有则删除会话可选
QueryWrapper<AiolChatMember> remainingMemberWrapper = new QueryWrapper<>();
remainingMemberWrapper.eq("chat_id", chatId);
long remainingMemberCount = aiolChatMemberMapper.selectCount(remainingMemberWrapper);
if (remainingMemberCount == 0) {
// 如果没有其他成员了删除会话
aiolChatService.removeById(chatId);
log.info("会话 {} 无成员,已自动删除", chatId);
}
log.info("用户 {} 成功退出群聊 {}", username, chatId);
return Result.OK("退出群聊成功!");
} catch (Exception e) {
log.error("退出群聊失败: chatId={}, error={}", chatId, e.getMessage(), e);
return Result.error("退出群聊失败: " + e.getMessage());
}
}
} }

View File

@ -365,4 +365,33 @@ public class AiolCommentController extends JeecgController<AiolComment, IAiolCom
return Result.error("点赞失败: " + e.getMessage()); return Result.error("点赞失败: " + e.getMessage());
} }
} }
@AutoLog(value = "评论-置顶")
@Operation(summary = "置顶评论", description = "将指定评论置顶设置iz_top字段为1")
@RequiresPermissions("aiol:aiol_comment:edit")
@GetMapping(value = "/top/{commentId}")
public Result<String> topComment(@PathVariable("commentId") String commentId) {
try {
// 1. 查询评论是否存在
AiolComment comment = aiolCommentService.getById(commentId);
if (comment == null) {
return Result.error("评论不存在");
}
// 2. 设置置顶状态
comment.setIzTop(1);
boolean updated = aiolCommentService.updateById(comment);
if (!updated) {
return Result.error("置顶失败");
}
log.info("评论置顶成功: commentId={}", commentId);
return Result.OK("置顶成功!");
} catch (Exception e) {
log.error("评论置顶失败: commentId={}, error={}", commentId, e.getMessage(), e);
return Result.error("置顶失败: " + e.getMessage());
}
}
} }

View File

@ -16,8 +16,12 @@ import org.jeecg.common.system.query.QueryRuleEnum;
import org.jeecg.common.util.oConvertUtils; import org.jeecg.common.util.oConvertUtils;
import org.jeecg.config.shiro.IgnoreAuth; import org.jeecg.config.shiro.IgnoreAuth;
import org.jeecg.modules.aiol.entity.AiolDiscussion; import org.jeecg.modules.aiol.entity.AiolDiscussion;
import org.jeecg.modules.aiol.dto.DiscussionWithSectionDTO;
import org.jeecg.modules.aiol.dto.AiolDiscussionSaveDTO;
import org.jeecg.modules.aiol.service.IAiolDiscussionService; import org.jeecg.modules.aiol.service.IAiolDiscussionService;
import org.jeecg.modules.aiol.service.IAiolEntityLinkService; import org.jeecg.modules.aiol.service.IAiolEntityLinkService;
import org.jeecg.modules.aiol.service.IAiolCourseSectionService;
import org.jeecg.modules.aiol.entity.AiolCourseSection;
import org.jeecg.modules.aiol.constant.EntityLinkConst; import org.jeecg.modules.aiol.constant.EntityLinkConst;
import org.jeecg.common.system.util.JwtUtil; import org.jeecg.common.system.util.JwtUtil;
import org.jeecg.common.system.vo.LoginUser; import org.jeecg.common.system.vo.LoginUser;
@ -61,6 +65,8 @@ public class AiolDiscussionController extends JeecgController<AiolDiscussion, IA
@Autowired @Autowired
private IAiolEntityLinkService aiolEntityLinkService; private IAiolEntityLinkService aiolEntityLinkService;
@Autowired @Autowired
private IAiolCourseSectionService aiolCourseSectionService;
@Autowired
private ISysBaseAPI sysBaseApi; private ISysBaseAPI sysBaseApi;
/** /**
@ -90,21 +96,26 @@ public class AiolDiscussionController extends JeecgController<AiolDiscussion, IA
/** /**
* 添加 * 添加
* *
* @param aiolDiscussion * @param aiolDiscussionSaveDTO 讨论保存DTO包含章节ID
* @param sectionId 可选的章节ID如果传入则创建与章节的关联关系
* @return * @return
*/ */
@AutoLog(value = "讨论-添加") @AutoLog(value = "讨论-添加")
@Operation(summary="讨论-添加", description="添加讨论,可选择关联到指定章节") @Operation(summary="讨论-添加", description="添加讨论,可选择关联到指定章节")
@RequiresPermissions("aiol:aiol_discussion:add") @RequiresPermissions("aiol:aiol_discussion:add")
@PostMapping(value = "/add") @PostMapping(value = "/add")
public Result<String> add(@RequestBody AiolDiscussion aiolDiscussion, public Result<String> add(@RequestBody AiolDiscussionSaveDTO aiolDiscussionSaveDTO) {
@RequestParam(value = "sectionId", required = false) String sectionId) {
try { try {
// 创建讨论实体
AiolDiscussion aiolDiscussion = new AiolDiscussion();
aiolDiscussion.setTitle(aiolDiscussionSaveDTO.getTitle());
aiolDiscussion.setDescription(aiolDiscussionSaveDTO.getDescription());
aiolDiscussion.setCourseId(aiolDiscussionSaveDTO.getCourseId());
// 保存讨论记录 // 保存讨论记录
aiolDiscussionService.save(aiolDiscussion); aiolDiscussionService.save(aiolDiscussion);
// 如果传入了sectionId创建与章节的关联关系 // 如果传入了sectionId创建与章节的关联关系
String sectionId = aiolDiscussionSaveDTO.getSectionId();
if (sectionId != null && !sectionId.trim().isEmpty()) { if (sectionId != null && !sectionId.trim().isEmpty()) {
aiolEntityLinkService.save( aiolEntityLinkService.save(
EntityLinkConst.SourceType.COURSE_SECTION, EntityLinkConst.SourceType.COURSE_SECTION,
@ -115,7 +126,7 @@ public class AiolDiscussionController extends JeecgController<AiolDiscussion, IA
log.info("讨论 {} 成功关联到章节 {}", aiolDiscussion.getId(), sectionId); log.info("讨论 {} 成功关联到章节 {}", aiolDiscussion.getId(), sectionId);
} }
return Result.OK("添加成功!"); return Result.OK(aiolDiscussion.getId());
} catch (Exception e) { } catch (Exception e) {
log.error("添加讨论失败: {}", e.getMessage(), e); log.error("添加讨论失败: {}", e.getMessage(), e);
return Result.error("添加讨论失败: " + e.getMessage()); return Result.error("添加讨论失败: " + e.getMessage());
@ -211,22 +222,71 @@ public class AiolDiscussionController extends JeecgController<AiolDiscussion, IA
} }
@AutoLog(value = "讨论-查询课程下的讨论列表") @AutoLog(value = "讨论-查询课程下的讨论列表")
@Operation(summary = "查询课程讨论列表", description = "查询课程讨论列表") @Operation(summary = "查询课程讨论列表", description = "查询课程讨论列表包含关联的章节ID")
@GetMapping(value = "/teacher_list") @GetMapping(value = "/teacher_list")
public Result<List<AiolDiscussion>> queryCourseDiscussions(HttpServletRequest request, @RequestParam(value = "courseId") String courseId) { public Result<List<DiscussionWithSectionDTO>> queryCourseDiscussions(HttpServletRequest request, @RequestParam(value = "courseId") String courseId) {
try { try {
// 2. 查询课程下的讨论列表 // 1. 查询课程下的讨论列表
QueryWrapper<AiolDiscussion> queryWrapper = new QueryWrapper<>(); QueryWrapper<AiolDiscussion> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("course_id", courseId) queryWrapper.eq("course_id", courseId)
.orderByDesc("create_time"); .orderByDesc("create_time");
List<AiolDiscussion> discussionList = aiolDiscussionService.list(queryWrapper); List<AiolDiscussion> discussionList = aiolDiscussionService.list(queryWrapper);
return Result.OK(discussionList); // 2. 转换为包含章节信息的DTO列表
List<DiscussionWithSectionDTO> resultList = discussionList.stream()
.map(this::convertToDiscussionWithSection)
.collect(Collectors.toList());
return Result.OK(resultList);
} catch (Exception e) { } catch (Exception e) {
log.error("查询用户讨论列表失败: error={}", e.getMessage(), e); log.error("查询课程讨论列表失败: error={}", e.getMessage(), e);
return Result.error("查询用户讨论列表失败: " + e.getMessage()); return Result.error("查询课程讨论列表失败: " + e.getMessage());
} }
} }
/**
* 将AiolDiscussion转换为包含章节信息的DTO
* @param discussion 讨论实体
* @return 包含章节ID的DTO
*/
private DiscussionWithSectionDTO convertToDiscussionWithSection(AiolDiscussion discussion) {
DiscussionWithSectionDTO dto = new DiscussionWithSectionDTO();
// 复制基本属性
dto.setId(discussion.getId());
dto.setTitle(discussion.getTitle());
dto.setDescription(discussion.getDescription());
dto.setCourseId(discussion.getCourseId());
dto.setCreateBy(discussion.getCreateBy());
dto.setCreateTime(discussion.getCreateTime());
dto.setUpdateBy(discussion.getUpdateBy());
dto.setUpdateTime(discussion.getUpdateTime());
try {
// 查询关联的章节ID
String sectionId = aiolEntityLinkService.listSourceId(
EntityLinkConst.TargetType.DISCUSSION,
discussion.getId(),
EntityLinkConst.SourceType.COURSE_SECTION
);
if (sectionId != null) {
dto.setSectionId(sectionId);
// 查询章节名称
AiolCourseSection section = aiolCourseSectionService.getById(sectionId);
if (section != null && section.getName() != null) {
dto.setSectionName(section.getName());
}
}
} catch (Exception e) {
log.error("查询讨论关联章节失败: discussionId={}, error={}", discussion.getId(), e.getMessage(), e);
// 即使查询失败也返回基本的讨论信息
}
return dto;
}
} }

View File

@ -22,10 +22,15 @@ import org.jeecg.common.constant.CommonConstant;
import org.jeecg.common.util.oConvertUtils; import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.aiol.dto.AiolHomeworkSaveDTO; import org.jeecg.modules.aiol.dto.AiolHomeworkSaveDTO;
import org.jeecg.modules.aiol.dto.StudentSubmitHomework; import org.jeecg.modules.aiol.dto.StudentSubmitHomework;
import org.jeecg.modules.aiol.dto.HomeworkWithDetailsDTO;
import org.jeecg.modules.aiol.entity.AiolHomework; import org.jeecg.modules.aiol.entity.AiolHomework;
import org.jeecg.modules.aiol.entity.AiolHomeworkSubmit; import org.jeecg.modules.aiol.entity.AiolHomeworkSubmit;
import org.jeecg.modules.aiol.entity.AiolClass;
import org.jeecg.modules.aiol.entity.AiolCourseSection;
import org.jeecg.modules.aiol.service.IAiolHomeworkService; import org.jeecg.modules.aiol.service.IAiolHomeworkService;
import org.jeecg.modules.aiol.service.IAiolEntityLinkService; import org.jeecg.modules.aiol.service.IAiolEntityLinkService;
import org.jeecg.modules.aiol.service.IAiolClassService;
import org.jeecg.modules.aiol.service.IAiolCourseSectionService;
import org.jeecg.modules.aiol.constant.EntityLinkConst; import org.jeecg.modules.aiol.constant.EntityLinkConst;
import org.jeecg.modules.aiol.mapper.AiolClassStudentMapper; import org.jeecg.modules.aiol.mapper.AiolClassStudentMapper;
import org.jeecg.modules.aiol.mapper.AiolCourseSignupMapper; import org.jeecg.modules.aiol.mapper.AiolCourseSignupMapper;
@ -162,14 +167,17 @@ public class AiolHomeworkController extends JeecgController<AiolHomework, IAiolH
* @return * @return
*/ */
//@AutoLog(value = "作业-通过id查询") //@AutoLog(value = "作业-通过id查询")
@Operation(summary = "作业-通过id查询") @Operation(summary = "作业-通过id查询", description = "根据ID查询作业详情包含班级名和章节信息")
@GetMapping(value = "/queryById") @GetMapping(value = "/queryById")
public Result<AiolHomework> queryById(@RequestParam(name = "id", required = true) String id) { public Result<HomeworkWithDetailsDTO> queryById(@RequestParam(name = "id", required = true) String id) {
AiolHomework aiolHomework = aiolHomeworkService.getById(id); AiolHomework aiolHomework = aiolHomeworkService.getById(id);
if (aiolHomework == null) { if (aiolHomework == null) {
return Result.error("未找到对应数据"); return Result.error("未找到对应数据");
} }
return Result.OK(aiolHomework);
// 转换为包含详情的DTO
HomeworkWithDetailsDTO result = convertToHomeworkWithDetails(aiolHomework);
return Result.OK(result);
} }
/** /**
@ -212,6 +220,12 @@ public class AiolHomeworkController extends JeecgController<AiolHomework, IAiolH
@Autowired @Autowired
private ISysBaseAPI sysBaseApi; private ISysBaseAPI sysBaseApi;
@Autowired
private IAiolClassService aiolClassService;
@Autowired
private IAiolCourseSectionService aiolCourseSectionService;
@GetMapping("/course/{courseId}") @GetMapping("/course/{courseId}")
@Operation(summary = "查询课程作业") @Operation(summary = "查询课程作业")
public Result<List<AiolHomework>> list(@PathVariable String courseId) { public Result<List<AiolHomework>> list(@PathVariable String courseId) {
@ -219,9 +233,9 @@ public class AiolHomeworkController extends JeecgController<AiolHomework, IAiolH
} }
@AutoLog(value = "作业-教师端查询作业列表") @AutoLog(value = "作业-教师端查询作业列表")
@Operation(summary = "查询课程作业列表", description = "查询当前登录教师创建的作业列表") @Operation(summary = "查询课程作业列表", description = "查询当前登录教师创建的作业列表,包含班级名和章节信息")
@GetMapping(value = "/teacher_list") @GetMapping(value = "/teacher_list")
public Result<List<AiolHomework>> teacherList(@RequestParam(value = "courseId") String courseId, HttpServletRequest request) { public Result<List<HomeworkWithDetailsDTO>> teacherList(@RequestParam(value = "courseId") String courseId, HttpServletRequest request) {
try { try {
QueryWrapper<AiolHomework> queryWrapper = new QueryWrapper<>(); QueryWrapper<AiolHomework> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("course_id", courseId) queryWrapper.eq("course_id", courseId)
@ -229,7 +243,12 @@ public class AiolHomeworkController extends JeecgController<AiolHomework, IAiolH
List<AiolHomework> homeworkList = aiolHomeworkService.list(queryWrapper); List<AiolHomework> homeworkList = aiolHomeworkService.list(queryWrapper);
return Result.OK(homeworkList); // 转换为包含详情的DTO列表
List<HomeworkWithDetailsDTO> resultList = homeworkList.stream()
.map(this::convertToHomeworkWithDetails)
.collect(Collectors.toList());
return Result.OK(resultList);
} catch (Exception e) { } catch (Exception e) {
log.error("查询教师作业列表失败: error={}", e.getMessage(), e); log.error("查询教师作业列表失败: error={}", e.getMessage(), e);
@ -422,4 +441,56 @@ public class AiolHomeworkController extends JeecgController<AiolHomework, IAiolH
return Result.error("批阅失败: " + e.getMessage()); return Result.error("批阅失败: " + e.getMessage());
} }
} }
/**
* 将AiolHomework转换为包含详情的DTO
* @param homework 作业实体
* @return 包含班级名和章节信息的DTO
*/
private HomeworkWithDetailsDTO convertToHomeworkWithDetails(AiolHomework homework) {
HomeworkWithDetailsDTO dto = new HomeworkWithDetailsDTO();
BeanUtils.copyProperties(homework, dto);
try {
// 1. 查询班级信息
List<String> classNames = new ArrayList<>();
String classId = homework.getClassId();
if (classId != null && !classId.trim().isEmpty()) {
String[] classIds = classId.split(",");
for (String singleClassId : classIds) {
if (singleClassId != null && !singleClassId.trim().isEmpty()) {
singleClassId = singleClassId.trim();
AiolClass aiolClass = aiolClassService.getById(singleClassId);
if (aiolClass != null && aiolClass.getName() != null) {
classNames.add(aiolClass.getName());
}
}
}
}
dto.setClassNames(classNames);
// 2. 查询章节信息
List<String> sectionIds = entityLinkBizService.listSourceIds(
EntityLinkConst.TargetType.HOMEWORK,
homework.getId(),
EntityLinkConst.SourceType.COURSE_SECTION
);
if (!sectionIds.isEmpty()) {
String sectionId = sectionIds.get(0); // 取第一个章节ID
dto.setSectionId(sectionId);
AiolCourseSection section = aiolCourseSectionService.getById(sectionId);
if (section != null && section.getName() != null) {
dto.setSectionTitle(section.getName());
}
}
} catch (Exception e) {
log.error("转换作业详情失败: homeworkId={}, error={}", homework.getId(), e.getMessage(), e);
// 即使转换失败也返回基本的作业信息
}
return dto;
}
} }

View File

@ -0,0 +1,21 @@
package org.jeecg.modules.aiol.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.jeecg.modules.aiol.entity.AiolDiscussion;
/**
* @Description: 讨论保存DTO
* @Author: jeecg-boot
* @Date: 2025-01-16
* @Version: V1.0
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "讨论保存DTO")
public class AiolDiscussionSaveDTO extends AiolDiscussion {
@Schema(description = "关联的章节ID")
private String sectionId;
}

View File

@ -11,10 +11,6 @@ import org.jeecg.modules.aiol.entity.AiolHomework;
@Data @Data
@Schema(description = "作业保存DTO") @Schema(description = "作业保存DTO")
public class AiolHomeworkSaveDTO extends AiolHomework { public class AiolHomeworkSaveDTO extends AiolHomework {
@Schema(description = "班级ID多个用逗号分割")
private String classId;
@Schema(description = "章节ID") @Schema(description = "章节ID")
private String sectionId; private String sectionId;
} }

View File

@ -0,0 +1,21 @@
package org.jeecg.modules.aiol.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.jeecg.modules.aiol.entity.AiolChat;
/**
* @Description: 会话详情DTO包含未读消息数
* @Author: jeecg-boot
* @Date: 2025-01-16
* @Version: V1.0
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "会话详情,包含未读消息数")
public class ChatWithUnreadCountDTO extends AiolChat {
@Schema(description = "未读消息数")
private Integer unreadCount;
}

View File

@ -0,0 +1,24 @@
package org.jeecg.modules.aiol.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.jeecg.modules.aiol.entity.AiolDiscussion;
/**
* @Description: 讨论详情DTO
* @Author: jeecg-boot
* @Date: 2025-01-16
* @Version: V1.0
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "讨论详情")
public class DiscussionWithSectionDTO extends AiolDiscussion {
@Schema(description = "关联的章节ID")
private String sectionId;
@Schema(description = "章节名称")
private String sectionName;
}

View File

@ -0,0 +1,29 @@
package org.jeecg.modules.aiol.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.jeecg.modules.aiol.entity.AiolHomework;
import java.util.List;
/**
* @Description: 作业详情DTO
* @Author: jeecg-boot
* @Date: 2025-01-16
* @Version: V1.0
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "作业详情")
public class HomeworkWithDetailsDTO extends AiolHomework {
@Schema(description = "班级名称列表")
private List<String> classNames;
@Schema(description = "章节ID")
private String sectionId;
@Schema(description = "章节标题")
private String sectionTitle;
}

View File

@ -60,7 +60,7 @@ public class AiolChatMember implements Serializable {
/**最后已读消息id*/ /**最后已读消息id*/
@Excel(name = "最后已读消息id", width = 15) @Excel(name = "最后已读消息id", width = 15)
@Schema(description = "最后已读消息id") @Schema(description = "最后已读消息id")
private java.lang.Integer lastReadMsgId; private java.lang.String lastReadMsgId;
/**创建人*/ /**创建人*/
@Schema(description = "创建人") @Schema(description = "创建人")
private java.lang.String createBy; private java.lang.String createBy;

View File

@ -41,6 +41,10 @@ public class AiolHomework implements Serializable {
@Excel(name = "所属课程id", width = 15) @Excel(name = "所属课程id", width = 15)
@Schema(description = "所属课程id") @Schema(description = "所属课程id")
private java.lang.String courseId; private java.lang.String courseId;
/**班级id*/
@Excel(name = "班级id", width = 15)
@Schema(description = "班级id")
private java.lang.String classId;
/**标题*/ /**标题*/
@Excel(name = "标题", width = 15) @Excel(name = "标题", width = 15)
@Schema(description = "标题") @Schema(description = "标题")

View File

@ -21,6 +21,15 @@ public interface IAiolEntityLinkService extends IService<AiolEntityLink> {
*/ */
List<String> listTargetIds(String sourceType, String sourceId, String targetType); List<String> listTargetIds(String sourceType, String sourceId, String targetType);
/**
* 根据主体与内容类型查询绑定的 target_id
* @param sourceType 主体类型
* @param sourceId 主体ID
* @param targetType 内容类型
* @return target_id
*/
String listTargetId(String sourceType, String sourceId, String targetType);
/** /**
* 根据内容与主体类型查询绑定的 source_id 列表 * 根据内容与主体类型查询绑定的 source_id 列表
* @param targetType 内容类型 * @param targetType 内容类型
@ -30,6 +39,16 @@ public interface IAiolEntityLinkService extends IService<AiolEntityLink> {
*/ */
List<String> listSourceIds(String targetType, String targetId, String sourceType); List<String> listSourceIds(String targetType, String targetId, String sourceType);
/**
* 根据内容与主体类型查询绑定的 source_id
* @param targetType 内容类型
* @param targetId 内容ID
* @param sourceType 主体类型
* @return source_id
*/
String listSourceId(String targetType, String targetId, String sourceType);
/** /**
* 保存主体与内容类型的绑定关系 * 保存主体与内容类型的绑定关系
* @param sourceType 主体类型 * @param sourceType 主体类型

View File

@ -54,4 +54,32 @@ public class AiolEntityLinkServiceImpl extends ServiceImpl<AiolEntityLinkMapper,
entityLink.setTargetId(targetId); entityLink.setTargetId(targetId);
this.save(entityLink); this.save(entityLink);
} }
@Override
public String listSourceId(String targetType, String targetId, String sourceType) {
LambdaQueryWrapper<AiolEntityLink> qw = new LambdaQueryWrapper<>();
qw.eq(AiolEntityLink::getTargetType, targetType)
.eq(AiolEntityLink::getTargetId, targetId)
.eq(AiolEntityLink::getSourceType, sourceType)
.select(AiolEntityLink::getSourceId)
.last("LIMIT 1");
return this.list(qw).stream()
.map(AiolEntityLink::getSourceId)
.findFirst()
.orElse(null);
}
@Override
public String listTargetId(String sourceType, String sourceId, String targetType) {
LambdaQueryWrapper<AiolEntityLink> qw = new LambdaQueryWrapper<>();
qw.eq(AiolEntityLink::getSourceType, sourceType)
.eq(AiolEntityLink::getSourceId, sourceId)
.eq(AiolEntityLink::getTargetType, targetType)
.select(AiolEntityLink::getTargetId)
.last("LIMIT 1");
return this.list(qw).stream()
.map(AiolEntityLink::getTargetId)
.findFirst()
.orElse(null);
}
} }

View File

@ -10,6 +10,11 @@ export const columns: BasicColumn[] = [
align:"center", align:"center",
dataIndex: 'courseId' dataIndex: 'courseId'
}, },
{
title: '班级id',
align:"center",
dataIndex: 'classId'
},
{ {
title: '标题', title: '标题',
align:"center", align:"center",
@ -76,6 +81,11 @@ export const formSchema: FormSchema[] = [
field: 'courseId', field: 'courseId',
component: 'Input', component: 'Input',
}, },
{
label: '班级id',
field: 'classId',
component: 'Input',
},
{ {
label: '标题', label: '标题',
field: 'title', field: 'title',
@ -161,17 +171,18 @@ export const formSchema: FormSchema[] = [
// //
export const superQuerySchema = { export const superQuerySchema = {
courseId: {title: '所属课程id',order: 0,view: 'text', type: 'string',}, courseId: {title: '所属课程id',order: 0,view: 'text', type: 'string',},
title: {title: '标题',order: 1,view: 'text', type: 'string',}, classId: {title: '班级id',order: 1,view: 'text', type: 'string',},
description: {title: '说明',order: 2,view: 'umeditor', type: 'string',}, title: {title: '标题',order: 2,view: 'text', type: 'string',},
attachment: {title: '附件',order: 3,view: 'file', type: 'string',}, description: {title: '说明',order: 3,view: 'umeditor', type: 'string',},
maxScore: {title: '满分',order: 4,view: 'number', type: 'number',}, attachment: {title: '附件',order: 4,view: 'file', type: 'string',},
passScore: {title: '及格分数',order: 5,view: 'number', type: 'number',}, maxScore: {title: '满分',order: 5,view: 'number', type: 'number',},
startTime: {title: '开始时间',order: 6,view: 'datetime', type: 'string',}, passScore: {title: '及格分数',order: 6,view: 'number', type: 'number',},
endTime: {title: '结束时间',order: 7,view: 'datetime', type: 'string',}, startTime: {title: '开始时间',order: 7,view: 'datetime', type: 'string',},
status: {title: '状态',order: 8,view: 'number', type: 'number',dictCode: 'course_status',}, endTime: {title: '结束时间',order: 8,view: 'datetime', type: 'string',},
allowMakeup: {title: '是否允许补交',order: 9,view: 'number', type: 'number',}, status: {title: '状态',order: 9,view: 'number', type: 'number',dictCode: 'course_status',},
makeupTime: {title: '补交截止时间',order: 10,view: 'datetime', type: 'string',}, allowMakeup: {title: '是否允许补交',order: 10,view: 'number', type: 'number',},
notifyTime: {title: '作业通知时间',order: 11,view: 'number', type: 'number',}, makeupTime: {title: '补交截止时间',order: 11,view: 'datetime', type: 'string',},
notifyTime: {title: '作业通知时间',order: 12,view: 'number', type: 'number',},
}; };
/** /**

View File

@ -0,0 +1,539 @@
<template>
<div class="design-editor-fullscreen">
<!-- 顶部工具栏 -->
<div class="editor-header">
<div class="header-left">
<a-button @click="handleBack" type="text" size="small">
<template #icon>
<Icon icon="ion:arrow-back" />
</template>
返回
</a-button>
<a-divider type="vertical" />
<span class="page-title">{{ pageTitle }}</span>
</div>
<div class="header-right">
<a-space>
<a-button @click="handlePreview" size="small">
<template #icon>
<Icon icon="ion:eye-outline" />
</template>
预览
</a-button>
<a-button @click="handleSave" type="primary" size="small">
<template #icon>
<Icon icon="ion:save-outline" />
</template>
保存
</a-button>
</a-space>
</div>
</div>
<!-- 全屏主体内容区域 -->
<div class="editor-main">
<!-- 左侧悬停组件库容器 -->
<div
class="component-library-container"
@mouseenter="showComponentLibrary = true"
@mouseleave="handleLibraryMouseLeave"
>
<!-- 触发器 -->
<div class="component-library-trigger">
<div class="trigger-tab">
<Icon icon="ion:apps-outline" />
<span>组件库</span>
</div>
</div>
<!-- 组件库面板 -->
<div
class="component-library-panel"
:class="{ 'show': showComponentLibrary }"
>
<div class="library-header">
<h3>组件库</h3>
<a-button
type="text"
size="small"
@click="showComponentLibrary = false"
>
<Icon icon="ion:close" />
</a-button>
</div>
<div class="library-content">
<ComponentLibrary
@drag-start="handleDragStart"
@component-add="handleComponentAdd"
/>
</div>
</div>
</div>
<!-- 设计画布区域 -->
<div class="canvas-area">
<DesignCanvas
:components="pageComponents"
:selected-component="selectedComponent"
@component-select="handleComponentSelect"
@component-drop="handleComponentDrop"
@component-move="handleComponentMove"
@components-change="handleComponentsChange"
style="width: 100%; height: 100%;"
/>
</div>
<!-- 右侧属性面板 -->
<div
class="property-panel"
:class="{ 'show': selectedComponent }"
>
<div class="property-header">
<h3>属性设置</h3>
<a-button
type="text"
size="small"
@click="selectedComponent = null"
>
<Icon icon="ion:close" />
</a-button>
</div>
<div class="property-content">
<PropertyEditor
:component="selectedComponent"
@property-change="handlePropertyChange"
/>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import ComponentLibrary from './components/ComponentLibrary.vue';
import DesignCanvas from './components/DesignCanvas.vue';
import PropertyEditor from './components/PropertyEditor.vue';
const router = useRouter();
const route = useRoute();
const { createMessage } = useMessage();
const pageId = computed(() => route.query.id as string);
const pageTitle = ref('新建门户');
const selectedComponent = ref<any>(null);
const pageComponents = ref<any[]>([]);
const showComponentLibrary = ref(false);
function handleBack() {
router.push('/online-design/portal');
}
function handleLibraryMouseLeave() {
showComponentLibrary.value = false;
}
function handlePreview() {
if (pageId.value) {
const routeData = router.resolve(`/online-design/preview?id=${pageId.value}`);
window.open(routeData.href, '_blank');
} else {
createMessage.warning('请先保存页面');
}
}
function handleSave() {
const pageData = {
id: pageId.value || generateId(),
name: pageTitle.value,
components: pageComponents.value,
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
};
// localStorage
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
const existingIndex = savedPages.findIndex(p => p.id === pageData.id);
if (existingIndex >= 0) {
savedPages[existingIndex] = pageData;
} else {
savedPages.push(pageData);
}
localStorage.setItem('online-design-pages', JSON.stringify(savedPages));
console.log('保存页面数据:', pageData);
createMessage.success('保存成功');
//
if (!pageId.value) {
router.replace(`/online-design/editor?id=${pageData.id}`);
}
}
function generateId() {
return 'page_' + Date.now() + '_' + Math.random().toString(36).substring(2, 11);
}
function createComponent(type: string) {
const componentId = `${type}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
const baseComponent = {
id: componentId,
type: type,
props: {},
data: {},
style: {},
isDragging: false,
};
//
switch (type) {
case 'carousel':
return {
...baseComponent,
props: {
autoplay: true,
dots: true,
},
data: {
images: [
{ url: 'https://via.placeholder.com/300x150/1890ff/white?text=轮播图1', alt: '轮播图1' },
{ url: 'https://via.placeholder.com/300x150/52c41a/white?text=轮播图2', alt: '轮播图2' },
{ url: 'https://via.placeholder.com/300x150/fa8c16/white?text=轮播图3', alt: '轮播图3' },
],
},
};
case 'text':
return {
...baseComponent,
props: {
content: '这是一段示例文本内容',
fontSize: '14px',
color: '#333',
textAlign: 'center',
},
};
case 'image':
return {
...baseComponent,
props: {
src: 'https://via.placeholder.com/300x150/1890ff/white?text=示例图片',
alt: '示例图片',
width: '100%',
},
};
case 'button':
return {
...baseComponent,
props: {
text: '按钮',
type: 'primary',
size: 'middle',
},
};
default:
return baseComponent;
}
}
function getComponentName(type: string) {
const nameMap = {
carousel: '轮播图',
text: '文本',
image: '图片',
button: '按钮',
container: '容器',
columns: '分栏',
};
return nameMap[type] || type;
}
function handleDragStart(componentType: string) {
console.log('开始拖拽组件:', componentType);
}
function handleComponentAdd(componentType: string) {
console.log('添加组件:', componentType);
//
const newComponent = createComponent(componentType);
pageComponents.value.push(newComponent);
selectedComponent.value = newComponent;
createMessage.success(`${getComponentName(componentType)}组件已添加`);
}
function handleComponentSelect(component: any) {
selectedComponent.value = component;
}
function handleComponentDrop(newComponent: any) {
console.log('组件放置:', newComponent);
pageComponents.value.push(newComponent);
selectedComponent.value = newComponent;
}
function handleComponentMove(event: any) {
console.log('组件移动:', event);
// TODO:
}
function handleComponentsChange(components: any[]) {
pageComponents.value = components;
}
function handlePropertyChange(updatedComponent: any) {
const index = pageComponents.value.findIndex(c => c.id === updatedComponent.id);
if (index !== -1) {
pageComponents.value[index] = updatedComponent;
selectedComponent.value = updatedComponent;
}
}
function loadPageData() {
if (pageId.value) {
// localStorage
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
const pageData = savedPages.find((p: any) => p.id === pageId.value);
if (pageData) {
pageTitle.value = `编辑门户 - ${pageData.name}`;
pageComponents.value = pageData.components || [];
} else {
pageTitle.value = `编辑门户 - ${pageId.value}`;
pageComponents.value = [];
}
} else {
pageTitle.value = '新建门户';
pageComponents.value = [];
}
}
onMounted(() => {
loadPageData();
});
</script>
<style scoped>
.design-editor-fullscreen {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
background: #f5f5f5;
display: flex;
flex-direction: column;
}
/* 确保在布局容器中也能全屏显示 */
:deep(.ant-layout-content) {
padding: 0 !important;
margin: 0 !important;
}
:deep(.main-content) {
padding: 0 !important;
margin: 0 !important;
}
.editor-header {
height: 48px;
background: white;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
}
.page-title {
font-size: 14px;
font-weight: 500;
margin-left: 8px;
color: #333;
}
.editor-main {
flex: 1;
position: relative;
overflow: hidden;
}
/* 左侧组件库容器 */
.component-library-container {
position: fixed;
left: 0;
top: 48px;
bottom: 0;
z-index: 100;
pointer-events: none;
}
.component-library-container * {
pointer-events: auto;
}
/* 左侧组件库触发器 */
.component-library-trigger {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
}
.trigger-tab {
background: #1890ff;
color: white;
padding: 12px 8px;
border-radius: 0 8px 8px 0;
writing-mode: vertical-lr;
text-orientation: mixed;
font-size: 12px;
font-weight: 500;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.trigger-tab:hover {
background: #40a9ff;
transform: translateX(2px);
}
.trigger-tab span {
writing-mode: vertical-lr;
text-orientation: mixed;
}
/* 组件库面板 */
.component-library-panel {
position: absolute;
left: -320px;
top: 0;
bottom: 0;
width: 320px;
background: white;
border-right: 1px solid #e8e8e8;
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.15);
transition: left 0.3s ease;
display: flex;
flex-direction: column;
}
.component-library-panel.show {
left: 0;
}
.library-header {
height: 48px;
padding: 0 16px;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
background: #fafafa;
}
.library-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #333;
}
.library-content {
flex: 1;
overflow-y: auto;
}
/* 设计画布区域 */
.canvas-area {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
transition: right 0.3s ease;
background: #f0f2f5;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 右侧属性面板 */
.property-panel {
position: fixed;
right: -360px;
top: 48px;
bottom: 0;
width: 360px;
background: white;
border-left: 1px solid #e8e8e8;
box-shadow: -2px 0 12px rgba(0, 0, 0, 0.15);
transition: right 0.3s ease;
z-index: 99;
display: flex;
flex-direction: column;
}
.property-panel.show {
right: 0;
}
.property-panel.show ~ .canvas-area {
right: 360px;
}
.property-header {
height: 48px;
padding: 0 16px;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
background: #fafafa;
}
.property-header h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #333;
}
.property-content {
flex: 1;
overflow-y: auto;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.component-library-panel {
width: 280px;
}
.property-panel {
width: 320px;
}
.property-panel.show ~ .canvas-area {
right: 320px;
}
}
</style>

View File

@ -0,0 +1,224 @@
<template>
<div class="preview-container">
<!-- 预览工具栏 -->
<div class="preview-toolbar">
<div class="toolbar-left">
<a-button @click="handleBack" type="text">
<template #icon>
<Icon icon="ion:arrow-back" />
</template>
返回
</a-button>
<a-divider type="vertical" />
<span class="preview-title">{{ pageTitle }} - 预览</span>
</div>
<div class="toolbar-right">
<a-space>
<a-button @click="handleEdit">
<template #icon>
<Icon icon="ion:create-outline" />
</template>
编辑
</a-button>
<a-button @click="handleRefresh">
<template #icon>
<Icon icon="ion:refresh-outline" />
</template>
刷新
</a-button>
</a-space>
</div>
</div>
<!-- 预览内容区域 -->
<div class="preview-content">
<div class="preview-wrapper">
<div class="preview-page" :style="pageStyle">
<!-- 渲染页面组件 -->
<div
v-for="(component, index) in pageComponents"
:key="component.id || index"
class="preview-component"
:style="getComponentStyle(component)"
>
<component
:is="getComponentType(component.type)"
v-bind="component.props"
:data="component.data"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { getComponentType as getDesignComponentType } from './components/design-components';
const router = useRouter();
const route = useRoute();
const { createMessage } = useMessage();
const pageId = computed(() => route.query.id as string);
const pageTitle = ref('门户预览');
const pageComponents = ref([]);
const pageStyle = ref({
minHeight: '100%',
background: '#ffffff',
});
function handleBack() {
router.push('/online-design/portal');
}
function handleEdit() {
router.push(`/online-design/editor?id=${pageId.value}`);
}
function handleRefresh() {
loadPageData();
createMessage.success('刷新成功');
}
function getComponentType(type: string) {
return getDesignComponentType(type);
}
function getComponentStyle(component: any) {
return {
position: 'relative',
width: component.style?.width || '100%',
height: component.style?.height || 'auto',
margin: component.style?.margin || '0',
padding: component.style?.padding || '0',
...component.style,
};
}
function loadPageData() {
// TODO:
if (pageId.value) {
//
pageTitle.value = `门户页面 ${pageId.value}`;
pageComponents.value = [
{
id: '1',
type: 'carousel',
props: {
autoplay: true,
dots: true,
},
data: {
images: [
{ url: 'https://via.placeholder.com/800x300/4CAF50/white?text=Slide+1', alt: 'Slide 1' },
{ url: 'https://via.placeholder.com/800x300/2196F3/white?text=Slide+2', alt: 'Slide 2' },
{ url: 'https://via.placeholder.com/800x300/FF9800/white?text=Slide+3', alt: 'Slide 3' },
],
},
style: {
width: '100%',
height: '300px',
marginBottom: '20px',
},
},
{
id: '2',
type: 'text',
props: {
tag: 'h1',
},
data: {
content: '欢迎来到我们的企业门户',
},
style: {
textAlign: 'center',
fontSize: '32px',
fontWeight: 'bold',
color: '#333',
marginBottom: '20px',
},
},
{
id: '3',
type: 'text',
props: {
tag: 'p',
},
data: {
content: '这是一个使用在线设计器创建的门户页面示例。您可以通过拖拽组件来快速构建美观的页面。',
},
style: {
textAlign: 'center',
fontSize: '16px',
color: '#666',
lineHeight: '1.6',
maxWidth: '800px',
margin: '0 auto 40px',
},
},
];
}
}
onMounted(() => {
loadPageData();
});
</script>
<style scoped>
.preview-container {
height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.preview-toolbar {
height: 60px;
background: white;
border-bottom: 1px solid #e8e8e8;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.toolbar-left {
display: flex;
align-items: center;
}
.preview-title {
font-size: 16px;
font-weight: 500;
margin-left: 10px;
}
.preview-content {
flex: 1;
overflow: auto;
padding: 20px;
}
.preview-wrapper {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.preview-page {
padding: 20px;
}
.preview-component {
margin-bottom: 10px;
}
</style>

View File

@ -0,0 +1,328 @@
<template>
<div class="component-library">
<div class="library-header">
<h3>组件库</h3>
</div>
<div class="library-content">
<!-- 基础组件分类 -->
<div class="component-category">
<div class="category-title">
<Icon icon="ion:cube-outline" />
<span>基础组件</span>
</div>
<div class="component-list">
<!-- 轮播图组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'carousel')"
@dragend="handleDragEnd"
@click="handleAddComponent('carousel')"
>
<div class="component-icon">
<Icon icon="ion:images-outline" />
</div>
<div class="component-info">
<div class="component-name">轮播图</div>
<div class="component-desc">图片轮播展示</div>
</div>
<div class="add-button">
<Icon icon="ion:add-outline" />
</div>
</div>
<!-- 文本组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'text')"
@dragend="handleDragEnd"
@click="handleAddComponent('text')"
>
<div class="component-icon">
<Icon icon="ion:text-outline" />
</div>
<div class="component-info">
<div class="component-name">文本</div>
<div class="component-desc">文字内容展示</div>
</div>
<div class="add-button">
<Icon icon="ion:add-outline" />
</div>
</div>
<!-- 图片组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'image')"
@dragend="handleDragEnd"
@click="handleAddComponent('image')"
>
<div class="component-icon">
<Icon icon="ion:image-outline" />
</div>
<div class="component-info">
<div class="component-name">图片</div>
<div class="component-desc">单张图片展示</div>
</div>
<div class="add-button">
<Icon icon="ion:add-outline" />
</div>
</div>
<!-- 按钮组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'button')"
@dragend="handleDragEnd"
@click="handleAddComponent('button')"
>
<div class="component-icon">
<Icon icon="ion:radio-button-on-outline" />
</div>
<div class="component-info">
<div class="component-name">按钮</div>
<div class="component-desc">交互按钮</div>
</div>
<div class="add-button">
<Icon icon="ion:add-outline" />
</div>
</div>
</div>
</div>
<!-- 布局组件分类 -->
<div class="component-category">
<div class="category-title">
<Icon icon="ion:grid-outline" />
<span>布局组件</span>
</div>
<div class="component-list">
<!-- 容器组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'container')"
@dragend="handleDragEnd"
>
<div class="component-icon">
<Icon icon="ion:square-outline" />
</div>
<div class="component-info">
<div class="component-name">容器</div>
<div class="component-desc">内容容器</div>
</div>
</div>
<!-- 分栏组件 -->
<div
class="component-item"
draggable="true"
@dragstart="handleDragStart($event, 'columns')"
@dragend="handleDragEnd"
>
<div class="component-icon">
<Icon icon="ion:apps-outline" />
</div>
<div class="component-info">
<div class="component-name">分栏</div>
<div class="component-desc">多列布局</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { Icon } from '/@/components/Icon';
const emit = defineEmits(['drag-start', 'drag-end', 'component-add']);
function handleDragStart(event: DragEvent, componentType: string) {
if (event.dataTransfer) {
//
event.dataTransfer.setData('component-type', componentType);
event.dataTransfer.effectAllowed = 'copy';
//
const dragImage = createDragImage(componentType);
event.dataTransfer.setDragImage(dragImage, 50, 25);
}
emit('drag-start', componentType);
}
function handleDragEnd() {
emit('drag-end');
}
function handleAddComponent(componentType: string) {
emit('component-add', componentType);
}
function createDragImage(componentType: string) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 100;
canvas.height = 50;
if (ctx) {
ctx.fillStyle = '#1890ff';
ctx.fillRect(0, 0, 100, 50);
ctx.fillStyle = 'white';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(getComponentName(componentType), 50, 30);
}
return canvas;
}
function getComponentName(type: string) {
const nameMap = {
carousel: '轮播图',
text: '文本',
image: '图片',
button: '按钮',
container: '容器',
columns: '分栏',
};
return nameMap[type] || type;
}
</script>
<style scoped>
.component-library {
height: 100%;
display: flex;
flex-direction: column;
}
.library-header {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.library-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #333;
}
.library-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.component-category {
margin-bottom: 24px;
}
.category-title {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
color: #666;
}
.category-title .anticon {
margin-right: 8px;
}
.component-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.component-item {
position: relative;
display: flex;
align-items: center;
padding: 12px;
background: #fafafa;
border: 1px solid #e8e8e8;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.component-item:hover {
background: #f0f9ff;
border-color: #1890ff;
box-shadow: 0 2px 4px rgba(24, 144, 255, 0.1);
}
.component-item:hover .add-button {
opacity: 1;
transform: scale(1);
}
.component-item:active {
cursor: grabbing;
}
.component-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: white;
border-radius: 4px;
margin-right: 12px;
color: #1890ff;
font-size: 16px;
}
.component-info {
flex: 1;
}
.component-name {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 2px;
}
.component-desc {
font-size: 12px;
color: #999;
}
.add-button {
position: absolute;
top: 50%;
right: 12px;
transform: translateY(-50%) scale(0.8);
width: 24px;
height: 24px;
background: #1890ff;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
opacity: 0;
transition: all 0.2s;
cursor: pointer;
}
.add-button:hover {
background: #40a9ff;
transform: translateY(-50%) scale(1.1);
}
</style>

View File

@ -0,0 +1,831 @@
<template>
<div class="design-canvas">
<!-- 简化的工具栏 -->
<div class="canvas-toolbar">
<div class="toolbar-left">
<span class="canvas-title">设计画布</span>
</div>
<div class="toolbar-right">
<a-space size="small">
<!-- 网格控制 -->
<a-switch
v-model:checked="showGrid"
size="small"
/>
<span class="control-label">网格</span>
<!-- 缩放控制 -->
<a-button size="small" @click="handleZoomOut" :disabled="zoomLevel <= 0.5">
<Icon icon="ion:remove-outline" />
</a-button>
<span class="zoom-display">{{ Math.round(zoomLevel * 100) }}%</span>
<a-button size="small" @click="handleZoomIn" :disabled="zoomLevel >= 2">
<Icon icon="ion:add-outline" />
</a-button>
<a-button size="small" @click="handleZoomReset">
<Icon icon="ion:refresh-outline" />
</a-button>
<a-divider type="vertical" />
<a-button size="small" @click="handleClearAll" danger>
<Icon icon="ion:trash-outline" />
清空
</a-button>
</a-space>
</div>
</div>
<!-- 全屏画布容器 -->
<div class="canvas-container">
<div
class="canvas-content"
:class="{ 'drag-over': isDragOver, 'show-grid': showGrid }"
:style="canvasStyle"
@drop="handleDrop"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
>
<!-- 网格背景 -->
<div class="grid-background" v-if="showGrid"></div>
<!-- 空状态提示 -->
<div v-if="components.length === 0" class="empty-canvas">
<div class="empty-icon">
<Icon icon="ion:cube-outline" />
</div>
<div class="empty-text">
<p>从左侧组件库拖拽组件到网格中开始设计</p>
<p class="empty-subtext">支持网格布局和组件缩放</p>
</div>
</div>
<!-- 网格布局组件容器 -->
<div v-else class="components-grid">
<div
v-for="(element, index) in componentList"
:key="element.id"
class="component-item"
:class="{ 'selected': selectedComponent?.id === element.id, 'dragging': element.isDragging }"
:style="getComponentStyle(element, index)"
@click="handleComponentClick(element)"
@mousedown="handleMouseDown(element, $event)"
>
<!-- 组件工具栏 -->
<div class="component-toolbar" v-show="selectedComponent?.id === element.id">
<a-space size="small">
<a-button size="small" type="text" @click.stop="handleCopy(element)">
<Icon icon="ion:copy-outline" />
</a-button>
<a-button size="small" type="text" danger @click.stop="handleDelete(index)">
<Icon icon="ion:trash-outline" />
</a-button>
</a-space>
</div>
<!-- 组件内容 -->
<div class="component-content">
<component
:is="getComponentType(element.type)"
v-bind="element.props"
:data="element.data"
:design-mode="true"
/>
</div>
<!-- 缩放手柄 -->
<div
v-show="selectedComponent?.id === element.id"
class="resize-handles"
>
<div class="resize-handle resize-se" @mousedown.stop="handleResizeStart(element, $event, 'se')"></div>
<div class="resize-handle resize-sw" @mousedown.stop="handleResizeStart(element, $event, 'sw')"></div>
<div class="resize-handle resize-ne" @mousedown.stop="handleResizeStart(element, $event, 'ne')"></div>
<div class="resize-handle resize-nw" @mousedown.stop="handleResizeStart(element, $event, 'nw')"></div>
<div class="resize-handle resize-n" @mousedown.stop="handleResizeStart(element, $event, 'n')"></div>
<div class="resize-handle resize-s" @mousedown.stop="handleResizeStart(element, $event, 's')"></div>
<div class="resize-handle resize-e" @mousedown.stop="handleResizeStart(element, $event, 'e')"></div>
<div class="resize-handle resize-w" @mousedown.stop="handleResizeStart(element, $event, 'w')"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { getComponentType as getDesignComponentType, createComponentInstance } from './design-components';
interface ComponentData {
id: string;
type: string;
props: Record<string, any>;
data: Record<string, any>;
style: Record<string, any>;
//
gridX?: number;
gridY?: number;
gridWidth?: number;
gridHeight?: number;
}
const props = defineProps<{
components: ComponentData[];
selectedComponent: ComponentData | null;
}>();
const emit = defineEmits(['component-select', 'component-drop', 'component-move', 'components-change']);
const { createMessage } = useMessage();
const isDragOver = ref(false);
const componentList = computed({
get: () => props.components,
set: (value) => emit('components-change', value),
});
//
const showGrid = ref(true);
const zoomLevel = ref(1);
//
const componentWidth = ref(33.33); // (1/3)
const componentHeight = ref(200); //
const componentsPerRow = ref(3); //
//
const isDragging = ref(false);
const dragData = ref<{
component: ComponentData;
startX: number;
startY: number;
startIndex: number;
longPressTimer: number | null;
} | null>(null);
//
const canvasStyle = computed(() => ({
transform: `scale(${zoomLevel.value})`,
transformOrigin: 'top left',
width: '100%',
minHeight: '100%',
}));
function handleDragOver(event: DragEvent) {
event.preventDefault();
event.dataTransfer!.dropEffect = 'copy';
}
function handleDragEnter(event: DragEvent) {
event.preventDefault();
isDragOver.value = true;
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
// false
if (!(event.currentTarget as Element).contains(event.relatedTarget as Node)) {
isDragOver.value = false;
}
}
function handleDrop(event: DragEvent) {
event.preventDefault();
isDragOver.value = false;
const componentType = event.dataTransfer?.getData('component-type');
if (componentType) {
const newComponent = createComponent(componentType);
emit('component-drop', newComponent);
}
}
function createComponent(type: string): ComponentData {
const component = createComponentInstance(type);
//
const nextPosition = getNextPosition();
component.gridIndex = nextPosition;
return component;
}
//
function getNextPosition(): number {
return componentList.value.length;
}
//
function getComponentStyle(component: ComponentData, index: number) {
const row = Math.floor(index / componentsPerRow.value);
const col = index % componentsPerRow.value;
return {
position: 'absolute',
left: `${col * componentWidth.value}%`,
top: `${row * (componentHeight.value + 20)}px`, // 20px
width: `${componentWidth.value - 1}%`, // 1%
height: `${componentHeight.value}px`,
zIndex: component.isDragging ? 1000 : 1,
};
}
//
function findNextAvailablePosition() {
const occupied = new Set<string>();
//
componentList.value.forEach(comp => {
if (comp.gridX !== undefined && comp.gridY !== undefined &&
comp.gridWidth !== undefined && comp.gridHeight !== undefined) {
for (let x = comp.gridX; x < comp.gridX + comp.gridWidth; x++) {
for (let y = comp.gridY; y < comp.gridY + comp.gridHeight; y++) {
occupied.add(`${x},${y}`);
}
}
}
});
//
for (let y = 0; y < gridRows; y++) {
for (let x = 0; x < gridCols; x++) {
if (!occupied.has(`${x},${y}`)) {
return { x, y };
}
}
}
return { x: 0, y: 0 }; //
}
//
function getDefaultWidth(type: string): number {
switch (type) {
case 'carousel': return 12; //
case 'image': return 6; // 1/4
case 'text': return 8; // 1/3
case 'button': return 4; // 1/6
default: return 6;
}
}
//
function startDragging(component: ComponentData, startX: number, startY: number, startIndex: number) {
isDragging.value = true;
component.isDragging = true;
createMessage.info('长按拖拽模式已激活');
}
//
function updateDragPosition(event: MouseEvent) {
if (!dragData.value) return;
//
const rect = document.querySelector('.components-grid')?.getBoundingClientRect();
if (!rect) return;
const relativeX = event.clientX - rect.left;
const relativeY = event.clientY - rect.top;
const col = Math.floor(relativeX / (rect.width / componentsPerRow.value));
const row = Math.floor(relativeY / (componentHeight.value + 20));
const newIndex = Math.min(
Math.max(0, row * componentsPerRow.value + col),
componentList.value.length - 1
);
//
if (newIndex !== dragData.value.startIndex) {
reorderComponents(dragData.value.startIndex, newIndex);
dragData.value.startIndex = newIndex;
}
}
//
function reorderComponents(fromIndex: number, toIndex: number) {
const newComponents = [...componentList.value];
const [movedComponent] = newComponents.splice(fromIndex, 1);
newComponents.splice(toIndex, 0, movedComponent);
emit('components-change', newComponents);
}
//
function finishDragging() {
if (dragData.value) {
dragData.value.component.isDragging = false;
}
isDragging.value = false;
createMessage.success('组件位置已更新');
}
function handleComponentClick(component: ComponentData) {
if (!isDragging.value) {
emit('component-select', component);
}
}
//
function handleMouseDown(component: ComponentData, event: MouseEvent) {
event.preventDefault();
const startX = event.clientX;
const startY = event.clientY;
const startIndex = componentList.value.findIndex(c => c.id === component.id);
//
const longPressTimer = setTimeout(() => {
startDragging(component, startX, startY, startIndex);
}, 500); // 500ms
dragData.value = {
component,
startX,
startY,
startIndex,
longPressTimer,
};
//
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mousemove', handleMouseMove);
}
function handleMouseMove(event: MouseEvent) {
if (!dragData.value) return;
const deltaX = Math.abs(event.clientX - dragData.value.startX);
const deltaY = Math.abs(event.clientY - dragData.value.startY);
//
if ((deltaX > 10 || deltaY > 10) && dragData.value.longPressTimer) {
clearTimeout(dragData.value.longPressTimer);
dragData.value.longPressTimer = null;
}
//
if (isDragging.value && dragData.value) {
updateDragPosition(event);
}
}
function handleMouseUp() {
if (dragData.value?.longPressTimer) {
clearTimeout(dragData.value.longPressTimer);
}
if (isDragging.value) {
finishDragging();
}
dragData.value = null;
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mousemove', handleMouseMove);
}
//
function handleZoomIn() {
if (zoomLevel.value < 2) {
zoomLevel.value = Math.min(2, zoomLevel.value + 0.1);
}
}
function handleZoomOut() {
if (zoomLevel.value > 0.5) {
zoomLevel.value = Math.max(0.5, zoomLevel.value - 0.1);
}
}
function handleZoomReset() {
zoomLevel.value = 1;
}
//
function getGridItemStyle(component: ComponentData) {
const x = component.gridX || 0;
const y = component.gridY || 0;
const width = component.gridWidth || 6;
const height = component.gridHeight || 4;
return {
position: 'absolute',
left: `${x * gridSize}px`,
top: `${y * gridSize}px`,
width: `${width * gridSize}px`,
height: `${height * gridSize}px`,
...component.style,
};
}
//
function handleResizeStart(component: ComponentData, event: MouseEvent, direction?: string) {
if (!direction) return; //
event.preventDefault();
event.stopPropagation();
isResizing.value = true;
resizeData.value = {
component,
startX: event.clientX,
startY: event.clientY,
startWidth: component.gridWidth || 6,
startHeight: component.gridHeight || 4,
direction,
};
document.addEventListener('mousemove', handleResizeMove);
document.addEventListener('mouseup', handleResizeEnd);
}
function handleResizeMove(event: MouseEvent) {
if (!isResizing.value || !resizeData.value) return;
const { component, startX, startY, startWidth, startHeight, direction } = resizeData.value;
const deltaX = Math.round((event.clientX - startX) / (gridSize * zoomLevel.value));
const deltaY = Math.round((event.clientY - startY) / (gridSize * zoomLevel.value));
let newWidth = startWidth;
let newHeight = startHeight;
//
if (direction.includes('e')) newWidth = Math.max(1, startWidth + deltaX);
if (direction.includes('w')) newWidth = Math.max(1, startWidth - deltaX);
if (direction.includes('s')) newHeight = Math.max(1, startHeight + deltaY);
if (direction.includes('n')) newHeight = Math.max(1, startHeight - deltaY);
//
component.gridWidth = newWidth;
component.gridHeight = newHeight;
emit('component-select', component); //
}
function handleResizeEnd() {
isResizing.value = false;
resizeData.value = null;
document.removeEventListener('mousemove', handleResizeMove);
document.removeEventListener('mouseup', handleResizeEnd);
}
function handleMoveUp(index: number) {
if (index > 0) {
const newComponents = [...componentList.value];
[newComponents[index - 1], newComponents[index]] = [newComponents[index], newComponents[index - 1]];
emit('components-change', newComponents);
}
}
function handleMoveDown(index: number) {
if (index < componentList.value.length - 1) {
const newComponents = [...componentList.value];
[newComponents[index], newComponents[index + 1]] = [newComponents[index + 1], newComponents[index]];
emit('components-change', newComponents);
}
}
function handleCopy(component: ComponentData) {
const newComponent = {
...component,
id: `${component.type}_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`,
};
const newComponents = [...componentList.value, newComponent];
emit('components-change', newComponents);
createMessage.success('组件复制成功');
}
function handleDelete(index: number) {
const newComponents = componentList.value.filter((_, i) => i !== index);
emit('components-change', newComponents);
emit('component-select', null);
createMessage.success('组件删除成功');
}
function handleClearAll() {
emit('components-change', []);
emit('component-select', null);
createMessage.success('画布已清空');
}
function handleSortStart() {
//
}
function handleSortEnd() {
//
}
function getComponentType(type: string) {
return getDesignComponentType(type);
}
//
onMounted(() => {
//
componentList.value.forEach(component => {
if (component.gridX === undefined || component.gridY === undefined) {
const nextPosition = findNextAvailablePosition();
component.gridX = nextPosition.x;
component.gridY = nextPosition.y;
component.gridWidth = component.gridWidth || getDefaultWidth(component.type);
component.gridHeight = component.gridHeight || getDefaultHeight(component.type);
}
});
});
onUnmounted(() => {
//
document.removeEventListener('mousemove', handleResizeMove);
document.removeEventListener('mouseup', handleResizeEnd);
});
</script>
<style scoped>
.design-canvas {
width: 100% !important;
height: 100%;
display: flex;
flex-direction: column;
background: white;
position: relative;
max-width: none !important;
}
.canvas-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
.canvas-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
.canvas-controls {
display: flex;
align-items: center;
gap: 20px;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 8px;
}
.zoom-display {
min-width: 50px;
text-align: center;
font-size: 12px;
color: #666;
}
.grid-controls {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: #666;
}
.canvas-container {
flex: 1;
overflow: auto;
background: #f0f2f5;
position: relative;
width: 100% !important;
height: 100%;
max-width: none !important;
}
.canvas-content {
width: 100% !important;
min-height: calc(100vh - 48px);
background: white;
position: relative;
padding: 20px;
transition: all 0.2s;
box-sizing: border-box;
flex: 1;
max-width: none !important;
}
.canvas-content.drag-over {
background-color: #f0f9ff;
border: 2px dashed #1890ff;
}
.canvas-content.show-grid {
background-image:
linear-gradient(to right, #e8e8e8 1px, transparent 1px),
linear-gradient(to bottom, #e8e8e8 1px, transparent 1px);
background-size: 40px 40px;
}
.grid-background {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
background-image:
linear-gradient(to right, #e8e8e8 1px, transparent 1px),
linear-gradient(to bottom, #e8e8e8 1px, transparent 1px);
background-size: 40px 40px;
}
.empty-canvas {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #999;
z-index: 1;
}
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-text {
text-align: center;
}
.empty-text p {
margin: 0;
font-size: 16px;
}
.empty-subtext {
font-size: 14px !important;
margin-top: 8px !important;
}
/* 网格布局容器 */
.components-grid {
position: relative;
width: 100%;
min-height: 100%;
padding: 20px;
}
/* 组件项样式 */
.component-item {
position: absolute;
border: 2px solid transparent;
border-radius: 8px;
transition: all 0.2s;
cursor: pointer;
background: #fafafa;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
user-select: none;
}
.component-item:hover {
border-color: #d9d9d9;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.component-item.selected {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
background: white;
}
.component-item.dragging {
opacity: 0.8;
transform: scale(1.05);
z-index: 1000 !important;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
.component-toolbar {
position: absolute;
top: 8px;
right: 8px;
background: white;
border: 1px solid #d9d9d9;
border-radius: 6px;
padding: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
z-index: 10;
opacity: 0.9;
}
.component-content {
padding: 12px;
height: 100%;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
/* 缩放手柄样式 */
.resize-handles {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.resize-handle {
position: absolute;
background: #1890ff;
border: 1px solid white;
pointer-events: auto;
z-index: 10;
}
.resize-handle:hover {
background: #40a9ff;
}
/* 角落手柄 */
.resize-se {
bottom: -4px;
right: -4px;
width: 8px;
height: 8px;
cursor: se-resize;
}
.resize-sw {
bottom: -4px;
left: -4px;
width: 8px;
height: 8px;
cursor: sw-resize;
}
.resize-ne {
top: -4px;
right: -4px;
width: 8px;
height: 8px;
cursor: ne-resize;
}
.resize-nw {
top: -4px;
left: -4px;
width: 8px;
height: 8px;
cursor: nw-resize;
}
/* 边缘手柄 */
.resize-n {
top: -4px;
left: 50%;
transform: translateX(-50%);
width: 8px;
height: 8px;
cursor: n-resize;
}
.resize-s {
bottom: -4px;
left: 50%;
transform: translateX(-50%);
width: 8px;
height: 8px;
cursor: s-resize;
}
.resize-e {
right: -4px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
cursor: e-resize;
}
.resize-w {
left: -4px;
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
cursor: w-resize;
}
</style>

View File

@ -0,0 +1,854 @@
<template>
<div class="property-editor">
<div class="editor-header">
<h3>属性编辑</h3>
</div>
<div class="editor-content">
<!-- 未选中组件时的提示 -->
<div v-if="!component" class="empty-state">
<div class="empty-icon">
<Icon icon="ion:settings-outline" />
</div>
<p>请选择一个组件来编辑属性</p>
</div>
<!-- 组件属性编辑表单 -->
<div v-else class="property-form">
<!-- 组件基本信息 -->
<div class="property-section">
<div class="section-title">
<Icon icon="ion:information-circle-outline" />
<span>基本信息</span>
</div>
<div class="form-item">
<label>组件类型</label>
<a-input :value="getComponentTypeName(component.type)" disabled />
</div>
<div class="form-item">
<label>组件ID</label>
<a-input :value="component.id" disabled />
</div>
</div>
<!-- 轮播图属性 -->
<template v-if="component.type === 'carousel'">
<div class="property-section">
<div class="section-title">
<Icon icon="ion:images-outline" />
<span>轮播设置</span>
</div>
<div class="form-item">
<label>自动播放</label>
<a-switch
:checked="component.props.autoplay"
@change="updateProperty('props.autoplay', $event)"
/>
</div>
<div class="form-item">
<label>显示指示点</label>
<a-switch
:checked="component.props.dots"
@change="updateProperty('props.dots', $event)"
/>
</div>
<div class="form-item">
<label>显示箭头</label>
<a-switch
:checked="component.props.arrows"
@change="updateProperty('props.arrows', $event)"
/>
</div>
</div>
<div class="property-section">
<div class="section-title">
<Icon icon="ion:image-outline" />
<span>图片管理</span>
</div>
<div class="image-list">
<div
v-for="(image, index) in component.data.images"
:key="index"
class="carousel-image-item"
>
<!-- 图片预览区域 -->
<div class="carousel-image-preview">
<div v-if="image.url" class="preview-container">
<img :src="image.url" :alt="image.alt" />
<div class="preview-overlay">
<a-button size="small" @click="removeCarouselImage(index)" danger>
<template #icon>
<Icon icon="ion:trash-outline" />
</template>
</a-button>
</div>
</div>
<!-- 上传区域 -->
<div v-else class="carousel-upload-area" @click="triggerCarouselFileInput(index)">
<Icon icon="ion:cloud-upload-outline" />
<p>点击上传图片</p>
</div>
<!-- 隐藏的文件输入 -->
<input
:ref="el => setCarouselFileInputRef(index, el)"
type="file"
accept="image/*"
style="display: none"
@change="handleCarouselFileUpload(index, $event)"
/>
</div>
<!-- 图片信息编辑 -->
<div class="carousel-image-info">
<div class="form-row">
<a-input
v-model:value="image.url"
placeholder="或输入图片URL"
size="small"
@change="updateImageData"
/>
<a-button size="small" @click="triggerCarouselFileInput(index)">
<template #icon>
<Icon icon="ion:folder-open-outline" />
</template>
</a-button>
</div>
<a-input
v-model:value="image.title"
placeholder="图片标题(可选)"
size="small"
@change="updateImageData"
/>
<a-input
v-model:value="image.description"
placeholder="图片描述(可选)"
size="small"
@change="updateImageData"
/>
<a-input
v-model:value="image.alt"
placeholder="替代文本(可选)"
size="small"
@change="updateImageData"
/>
</div>
</div>
<a-button @click="addImage" type="dashed" block>
<template #icon>
<Icon icon="ion:add" />
</template>
添加图片
</a-button>
</div>
</div>
</template>
<!-- 文本属性 -->
<template v-if="component.type === 'text'">
<div class="property-section">
<div class="section-title">
<Icon icon="ion:text-outline" />
<span>文本内容</span>
</div>
<div class="form-item">
<label>标签类型</label>
<a-select
:value="component.props.tag"
@change="updateProperty('props.tag', $event)"
style="width: 100%"
>
<a-select-option value="p">段落 (p)</a-select-option>
<a-select-option value="h1">标题1 (h1)</a-select-option>
<a-select-option value="h2">标题2 (h2)</a-select-option>
<a-select-option value="h3">标题3 (h3)</a-select-option>
<a-select-option value="h4">标题4 (h4)</a-select-option>
<a-select-option value="span">行内文本 (span)</a-select-option>
</a-select>
</div>
<div class="form-item">
<label>文本内容</label>
<a-textarea
:value="component.data.content"
@change="updateProperty('data.content', $event.target.value)"
:rows="4"
placeholder="请输入文本内容"
/>
</div>
</div>
</template>
<!-- 图片属性 -->
<template v-if="component.type === 'image'">
<div class="property-section">
<div class="section-title">
<Icon icon="ion:image-outline" />
<span>图片设置</span>
</div>
<div class="form-item">
<label>图片</label>
<div class="image-upload-container">
<!-- 图片预览 -->
<div v-if="component.data.src" class="image-preview-box">
<img :src="component.data.src" alt="预览图片" />
<div class="image-overlay">
<a-button size="small" @click="removeCurrentImage" danger>
<template #icon>
<Icon icon="ion:trash-outline" />
</template>
</a-button>
</div>
</div>
<!-- 上传区域 -->
<div v-else class="upload-area" @click="triggerFileInput">
<Icon icon="ion:cloud-upload-outline" />
<p>点击上传图片</p>
<p class="upload-tip">支持 JPGPNGGIF 格式</p>
</div>
<!-- 隐藏的文件输入 -->
<input
ref="fileInput"
type="file"
accept="image/*"
style="display: none"
@change="handleFileUpload"
/>
</div>
<!-- URL输入框 -->
<div class="url-input-container">
<a-input
:value="component.data.src"
@change="updateProperty('data.src', $event.target.value)"
placeholder="或输入图片URL"
size="small"
/>
<a-button size="small" @click="triggerFileInput">
<template #icon>
<Icon icon="ion:folder-open-outline" />
</template>
选择文件
</a-button>
</div>
</div>
<div class="form-item">
<label>替代文本</label>
<a-input
:value="component.props.alt"
@change="updateProperty('props.alt', $event.target.value)"
placeholder="图片描述"
/>
</div>
<div class="form-item">
<label>显示方式</label>
<a-select
:value="component.props.objectFit"
@change="updateProperty('props.objectFit', $event)"
style="width: 100%"
>
<a-select-option value="cover">覆盖</a-select-option>
<a-select-option value="contain">包含</a-select-option>
<a-select-option value="fill">填充</a-select-option>
<a-select-option value="none">原始</a-select-option>
<a-select-option value="scale-down">缩小</a-select-option>
</a-select>
</div>
</div>
</template>
<!-- 按钮属性 -->
<template v-if="component.type === 'button'">
<div class="property-section">
<div class="section-title">
<Icon icon="ion:radio-button-on-outline" />
<span>按钮设置</span>
</div>
<div class="form-item">
<label>按钮文字</label>
<a-input
:value="component.data.text"
@change="updateProperty('data.text', $event.target.value)"
placeholder="按钮文字"
/>
</div>
<div class="form-item">
<label>按钮类型</label>
<a-select
:value="component.props.type"
@change="updateProperty('props.type', $event)"
style="width: 100%"
>
<a-select-option value="primary">主要按钮</a-select-option>
<a-select-option value="default">默认按钮</a-select-option>
<a-select-option value="dashed">虚线按钮</a-select-option>
<a-select-option value="text">文本按钮</a-select-option>
<a-select-option value="link">链接按钮</a-select-option>
</a-select>
</div>
<div class="form-item">
<label>按钮大小</label>
<a-select
:value="component.props.size"
@change="updateProperty('props.size', $event)"
style="width: 100%"
>
<a-select-option value="large"></a-select-option>
<a-select-option value="middle"></a-select-option>
<a-select-option value="small"></a-select-option>
</a-select>
</div>
</div>
</template>
<!-- 样式设置 -->
<div class="property-section">
<div class="section-title">
<Icon icon="ion:color-palette-outline" />
<span>样式设置</span>
</div>
<div class="form-item">
<label>宽度</label>
<a-input
:value="component.style.width"
@change="updateProperty('style.width', $event.target.value)"
placeholder="如: 100%, 300px"
/>
</div>
<div class="form-item">
<label>高度</label>
<a-input
:value="component.style.height"
@change="updateProperty('style.height', $event.target.value)"
placeholder="如: auto, 200px"
/>
</div>
<div class="form-item">
<label>外边距</label>
<a-input
:value="component.style.margin"
@change="updateProperty('style.margin', $event.target.value)"
placeholder="如: 10px, 10px 20px"
/>
</div>
<div class="form-item">
<label>内边距</label>
<a-input
:value="component.style.padding"
@change="updateProperty('style.padding', $event.target.value)"
placeholder="如: 10px, 10px 20px"
/>
</div>
<!-- 文本样式 -->
<template v-if="component.type === 'text'">
<div class="form-item">
<label>字体大小</label>
<a-input
:value="component.style.fontSize"
@change="updateProperty('style.fontSize', $event.target.value)"
placeholder="如: 16px, 1.2em"
/>
</div>
<div class="form-item">
<label>字体颜色</label>
<a-input
:value="component.style.color"
@change="updateProperty('style.color', $event.target.value)"
placeholder="如: #333, red"
/>
</div>
<div class="form-item">
<label>文本对齐</label>
<a-select
:value="component.style.textAlign"
@change="updateProperty('style.textAlign', $event)"
style="width: 100%"
>
<a-select-option value="left">左对齐</a-select-option>
<a-select-option value="center">居中</a-select-option>
<a-select-option value="right">右对齐</a-select-option>
<a-select-option value="justify">两端对齐</a-select-option>
</a-select>
</div>
</template>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
interface ComponentData {
id: string;
type: string;
props: Record<string, any>;
data: Record<string, any>;
style: Record<string, any>;
}
const props = defineProps<{
component: ComponentData | null;
}>();
const emit = defineEmits(['property-change']);
const { createMessage } = useMessage();
const fileInput = ref<HTMLInputElement>();
const carouselFileInputs = ref<Record<number, HTMLInputElement>>({});
function getComponentTypeName(type: string) {
const typeNames = {
carousel: '轮播图',
text: '文本',
image: '图片',
button: '按钮',
container: '容器',
columns: '分栏',
};
return typeNames[type] || type;
}
function updateProperty(path: string, value: any) {
if (!props.component) return;
const keys = path.split('.');
const component = { ...props.component };
let target = component;
for (let i = 0; i < keys.length - 1; i++) {
if (!target[keys[i]]) {
target[keys[i]] = {};
}
target = target[keys[i]];
}
target[keys[keys.length - 1]] = value;
emit('property-change', component);
}
function addImage() {
if (!props.component || props.component.type !== 'carousel') return;
const newImage = {
url: 'https://via.placeholder.com/800x300/666/white?text=New+Image',
alt: 'New Image',
};
const component = { ...props.component };
component.data.images = [...component.data.images, newImage];
emit('property-change', component);
}
function removeImage(index: number) {
if (!props.component || props.component.type !== 'carousel') return;
const component = { ...props.component };
component.data.images = component.data.images.filter((_, i) => i !== index);
emit('property-change', component);
}
function updateImageData() {
if (!props.component) return;
emit('property-change', { ...props.component });
}
//
function triggerFileInput() {
fileInput.value?.click();
}
function handleFileUpload(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
//
if (!file.type.startsWith('image/')) {
createMessage.error('请选择图片文件!');
return;
}
// 5MB
if (file.size > 5 * 1024 * 1024) {
createMessage.error('图片大小不能超过5MB');
return;
}
// 使FileReaderbase64
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target?.result as string;
updateProperty('data.src', base64);
createMessage.success('图片上传成功!');
};
reader.onerror = () => {
createMessage.error('图片读取失败!');
};
reader.readAsDataURL(file);
// input
target.value = '';
}
function removeCurrentImage() {
updateProperty('data.src', '');
createMessage.success('图片已移除!');
}
//
function setCarouselFileInputRef(index: number, el: HTMLInputElement | null) {
if (el) {
carouselFileInputs.value[index] = el;
}
}
function triggerCarouselFileInput(index: number) {
carouselFileInputs.value[index]?.click();
}
function handleCarouselFileUpload(index: number, event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
//
if (!file.type.startsWith('image/')) {
createMessage.error('请选择图片文件!');
return;
}
// 5MB
if (file.size > 5 * 1024 * 1024) {
createMessage.error('图片大小不能超过5MB');
return;
}
// 使FileReaderbase64
const reader = new FileReader();
reader.onload = (e) => {
const base64 = e.target?.result as string;
// URL
if (props.component && props.component.data.images[index]) {
props.component.data.images[index].url = base64;
updateImageData();
createMessage.success('图片上传成功!');
}
};
reader.onerror = () => {
createMessage.error('图片读取失败!');
};
reader.readAsDataURL(file);
// input
target.value = '';
}
function removeCarouselImage(index: number) {
if (props.component && props.component.data.images) {
props.component.data.images.splice(index, 1);
updateImageData();
createMessage.success('图片已删除!');
}
}
</script>
<style scoped>
.property-editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editor-header {
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.editor-header h3 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #333;
}
.editor-content {
flex: 1;
overflow-y: auto;
padding: 16px;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
color: #999;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.property-section {
margin-bottom: 24px;
}
.section-title {
display: flex;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
font-weight: 500;
color: #666;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
}
.section-title .anticon {
margin-right: 8px;
}
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
margin-bottom: 4px;
font-size: 12px;
color: #666;
font-weight: 500;
}
.image-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.image-item {
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 12px;
}
.image-preview {
margin-bottom: 8px;
}
.image-preview img {
width: 100%;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.image-actions {
display: flex;
gap: 8px;
align-items: center;
}
.image-actions .ant-input {
flex: 1;
}
/* 图片上传样式 */
.image-upload-container {
margin-bottom: 12px;
}
.image-preview-box {
position: relative;
width: 100%;
height: 120px;
border: 1px solid #e8e8e8;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}
.image-preview-box img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.image-preview-box:hover .image-overlay {
opacity: 1;
}
.upload-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 120px;
border: 2px dashed #d9d9d9;
border-radius: 6px;
background: #fafafa;
cursor: pointer;
transition: all 0.2s;
margin-bottom: 8px;
}
.upload-area:hover {
border-color: #1890ff;
background: #f0f8ff;
}
.upload-area .anticon {
font-size: 32px;
color: #999;
margin-bottom: 8px;
}
.upload-area p {
margin: 0;
color: #666;
font-size: 14px;
}
.upload-tip {
font-size: 12px !important;
color: #999 !important;
margin-top: 4px !important;
}
.url-input-container {
display: flex;
gap: 8px;
align-items: center;
}
.url-input-container .ant-input {
flex: 1;
}
/* 轮播图图片管理样式 */
.carousel-image-item {
border: 1px solid #e8e8e8;
border-radius: 6px;
padding: 12px;
margin-bottom: 12px;
background: #fafafa;
}
.carousel-image-preview {
margin-bottom: 12px;
}
.preview-container {
position: relative;
width: 100%;
height: 80px;
border-radius: 4px;
overflow: hidden;
}
.preview-container img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.preview-container:hover .preview-overlay {
opacity: 1;
}
.carousel-upload-area {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 80px;
border: 2px dashed #d9d9d9;
border-radius: 4px;
background: #fff;
cursor: pointer;
transition: all 0.2s;
}
.carousel-upload-area:hover {
border-color: #1890ff;
background: #f0f8ff;
}
.carousel-upload-area .anticon {
font-size: 20px;
color: #999;
margin-bottom: 4px;
}
.carousel-upload-area p {
margin: 0;
color: #666;
font-size: 12px;
}
.carousel-image-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-row {
display: flex;
gap: 8px;
align-items: center;
}
.form-row .ant-input {
flex: 1;
}
</style>

View File

@ -0,0 +1,232 @@
<template>
<div class="button-component" :class="{ 'design-mode': designMode }">
<a-button
v-bind="buttonProps"
:type="type"
:size="size"
:shape="shape"
:loading="loading"
:disabled="disabled"
:ghost="ghost"
:danger="danger"
:block="block"
class="button-content"
:style="buttonStyle"
@click="handleClick"
>
<template #icon v-if="iconName">
<Icon :icon="iconName" />
</template>
{{ text }}
</a-button>
<!-- 设计模式下的空状态 -->
<div v-if="designMode && !text" class="empty-button">
<div class="empty-content">
<Icon icon="ion:radio-button-on-outline" />
<p>按钮组件</p>
<p class="empty-tip">请在右侧属性面板设置按钮文字</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { Icon } from '/@/components/Icon';
interface ButtonData {
text: string;
url?: string;
target?: '_blank' | '_self' | '_parent' | '_top';
}
const props = withDefaults(defineProps<{
//
type?: 'primary' | 'default' | 'dashed' | 'text' | 'link';
size?: 'large' | 'middle' | 'small';
shape?: 'default' | 'circle' | 'round';
loading?: boolean;
disabled?: boolean;
ghost?: boolean;
danger?: boolean;
block?: boolean;
//
iconName?: string;
//
data?: ButtonData;
//
designMode?: boolean;
//
onClick?: () => void;
//
[key: string]: any;
}>(), {
type: 'primary',
size: 'middle',
shape: 'default',
loading: false,
disabled: false,
ghost: false,
danger: false,
block: false,
designMode: false,
data: () => ({ text: '' }),
});
const emit = defineEmits(['click']);
const text = computed(() => props.data?.text || '');
const buttonProps = computed(() => {
const {
type, size, shape, loading, disabled, ghost, danger, block,
iconName, data, designMode, onClick, ...rest
} = props;
return rest;
});
const buttonStyle = computed(() => {
const style: Record<string, any> = {};
// props
const styleProps = [
'width', 'height', 'margin', 'padding',
'backgroundColor', 'borderColor', 'color',
'fontSize', 'fontWeight', 'borderRadius',
'boxShadow', 'textTransform'
];
styleProps.forEach(prop => {
if (props[prop] !== undefined) {
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
style[cssProp] = props[prop];
}
});
return style;
});
function handleClick(event: Event) {
if (props.designMode) {
event.preventDefault();
return;
}
//
if (props.data?.url) {
const target = props.data.target || '_self';
if (target === '_blank') {
window.open(props.data.url, '_blank');
} else {
window.location.href = props.data.url;
}
}
if (props.onClick) {
props.onClick();
}
emit('click', event);
}
</script>
<style scoped>
.button-component {
display: inline-block;
position: relative;
}
.button-component.design-mode {
border: 1px dashed transparent;
border-radius: 4px;
transition: border-color 0.2s;
padding: 2px;
}
.button-component.design-mode:hover {
border-color: #d9d9d9;
}
.button-content {
transition: all 0.3s ease;
}
.button-content:not(.ant-btn-loading):hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.empty-button {
min-width: 120px;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.empty-content {
text-align: center;
color: #999;
}
.empty-content .anticon {
font-size: 32px;
margin-bottom: 8px;
opacity: 0.5;
}
.empty-content p {
margin: 0;
font-size: 12px;
}
.empty-tip {
margin-top: 4px !important;
font-size: 11px !important;
}
/* 按钮尺寸自定义 */
:deep(.ant-btn-lg) {
height: 48px;
padding: 0 24px;
font-size: 16px;
}
:deep(.ant-btn-sm) {
height: 28px;
padding: 0 12px;
font-size: 12px;
}
/* 块级按钮 */
.button-component :deep(.ant-btn-block) {
width: 100%;
}
/* 圆形按钮 */
.button-component :deep(.ant-btn-circle) {
min-width: auto;
}
/* 响应式设计 */
@media (max-width: 768px) {
.button-content:not(.ant-btn-loading):hover {
transform: none;
box-shadow: none;
}
:deep(.ant-btn-lg) {
height: 44px;
font-size: 15px;
}
}
</style>

View File

@ -0,0 +1,230 @@
<template>
<div class="carousel-component" :class="{ 'design-mode': designMode }">
<a-carousel
v-bind="carouselProps"
:autoplay="autoplay"
:dots="dots"
:arrows="arrows"
:effect="effect"
:dotPosition="dotPosition"
>
<div v-for="(image, index) in images" :key="index" class="carousel-slide">
<div class="slide-content" :style="slideStyle">
<img
:src="image.url"
:alt="image.alt || `Slide ${index + 1}`"
class="slide-image"
@error="handleImageError"
/>
<div v-if="image.title || image.description" class="slide-overlay">
<h3 v-if="image.title" class="slide-title">{{ image.title }}</h3>
<p v-if="image.description" class="slide-description">{{ image.description }}</p>
</div>
</div>
</div>
</a-carousel>
<!-- 设计模式下的编辑提示 -->
<div v-if="designMode && images.length === 0" class="empty-carousel">
<div class="empty-content">
<Icon icon="ion:images-outline" />
<p>轮播图组件</p>
<p class="empty-tip">请在右侧属性面板添加图片</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { Icon } from '/@/components/Icon';
interface ImageData {
url: string;
alt?: string;
title?: string;
description?: string;
}
interface CarouselData {
images: ImageData[];
}
const props = withDefaults(defineProps<{
//
autoplay?: boolean;
dots?: boolean;
arrows?: boolean;
effect?: 'scrollx' | 'fade';
dotPosition?: 'top' | 'bottom' | 'left' | 'right';
//
data?: CarouselData;
//
designMode?: boolean;
//
[key: string]: any;
}>(), {
autoplay: true,
dots: true,
arrows: false,
effect: 'scrollx',
dotPosition: 'bottom',
designMode: false,
data: () => ({ images: [] }),
});
const images = computed(() => props.data?.images || []);
const carouselProps = computed(() => {
const { autoplay, dots, arrows, effect, dotPosition, data, designMode, ...rest } = props;
return rest;
});
const slideStyle = computed(() => ({
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
}));
function handleImageError(event: Event) {
const img = event.target as HTMLImageElement;
img.src = 'https://via.placeholder.com/800x300/f0f0f0/999?text=Image+Load+Error';
}
</script>
<style scoped>
.carousel-component {
width: 100%;
height: 100%;
min-height: 200px;
position: relative;
}
.carousel-component.design-mode {
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.carousel-slide {
height: 300px;
overflow: hidden;
}
.slide-content {
width: 100%;
height: 100%;
position: relative;
}
.slide-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.slide-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: 20px;
}
.slide-title {
margin: 0 0 8px 0;
font-size: 24px;
font-weight: bold;
}
.slide-description {
margin: 0;
font-size: 16px;
opacity: 0.9;
}
.empty-carousel {
width: 100%;
height: 300px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.empty-content {
text-align: center;
color: #999;
}
.empty-content .anticon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-content p {
margin: 0;
font-size: 14px;
}
.empty-tip {
font-size: 12px !important;
margin-top: 8px !important;
}
/* 自定义轮播图样式 */
:deep(.ant-carousel) {
height: 100%;
}
:deep(.ant-carousel .slick-slide) {
height: 300px;
}
:deep(.ant-carousel .slick-slide > div) {
height: 100%;
}
:deep(.ant-carousel .slick-dots) {
bottom: 20px;
}
:deep(.ant-carousel .slick-dots li button) {
background: rgba(255, 255, 255, 0.5);
border-radius: 50%;
}
:deep(.ant-carousel .slick-dots li.slick-active button) {
background: white;
}
:deep(.ant-carousel .slick-arrow) {
z-index: 2;
width: 40px;
height: 40px;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
}
:deep(.ant-carousel .slick-arrow:hover) {
background: rgba(0, 0, 0, 0.7);
}
:deep(.ant-carousel .slick-prev) {
left: 20px;
}
:deep(.ant-carousel .slick-next) {
right: 20px;
}
</style>

View File

@ -0,0 +1,211 @@
<template>
<div class="image-component" :class="{ 'design-mode': designMode }">
<div class="image-wrapper" :style="wrapperStyle">
<img
v-if="src"
:src="src"
:alt="alt"
class="image-content"
:style="imageStyle"
@load="handleImageLoad"
@error="handleImageError"
@click="handleClick"
/>
<!-- 设计模式下的空状态 -->
<div v-if="designMode && !src" class="empty-image">
<div class="empty-content">
<Icon icon="ion:image-outline" />
<p>图片组件</p>
<p class="empty-tip">请在右侧属性面板设置图片URL</p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { Icon } from '/@/components/Icon';
interface ImageData {
src: string;
}
const props = withDefaults(defineProps<{
//
alt?: string;
loading?: 'lazy' | 'eager';
//
data?: ImageData;
//
objectFit?: 'fill' | 'contain' | 'cover' | 'none' | 'scale-down';
borderRadius?: string;
//
designMode?: boolean;
//
onClick?: () => void;
//
[key: string]: any;
}>(), {
alt: '',
loading: 'lazy',
objectFit: 'cover',
designMode: false,
data: () => ({ src: '' }),
});
const emit = defineEmits(['click', 'load', 'error']);
const imageLoaded = ref(false);
const imageError = ref(false);
const src = computed(() => props.data?.src || '');
const wrapperStyle = computed(() => {
const style: Record<string, any> = {
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
};
if (props.borderRadius) {
style.borderRadius = props.borderRadius;
}
return style;
});
const imageStyle = computed(() => {
const style: Record<string, any> = {
width: '100%',
height: '100%',
display: 'block',
objectFit: props.objectFit,
};
// props
const styleProps = [
'border', 'boxShadow', 'filter', 'opacity', 'transform'
];
styleProps.forEach(prop => {
if (props[prop] !== undefined) {
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
style[cssProp] = props[prop];
}
});
return style;
});
function handleImageLoad(event: Event) {
imageLoaded.value = true;
imageError.value = false;
emit('load', event);
}
function handleImageError(event: Event) {
imageError.value = true;
imageLoaded.value = false;
//
const img = event.target as HTMLImageElement;
img.src = 'https://via.placeholder.com/400x200/f0f0f0/999?text=Image+Load+Error';
emit('error', event);
}
function handleClick(event: Event) {
if (props.onClick) {
props.onClick();
}
emit('click', event);
}
</script>
<style scoped>
.image-component {
width: 100%;
height: 100%;
min-height: 100px;
position: relative;
}
.image-component.design-mode {
border: 1px dashed transparent;
border-radius: 4px;
transition: border-color 0.2s;
}
.image-component.design-mode:hover {
border-color: #d9d9d9;
}
.image-wrapper {
width: 100%;
height: 100%;
position: relative;
background: #fafafa;
}
.image-content {
cursor: pointer;
transition: all 0.3s ease;
}
.image-content:hover {
transform: scale(1.02);
}
.empty-image {
width: 100%;
height: 100%;
min-height: 150px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.empty-content {
text-align: center;
color: #999;
}
.empty-content .anticon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-content p {
margin: 0;
font-size: 14px;
}
.empty-tip {
font-size: 12px !important;
margin-top: 8px !important;
}
/* 图片加载状态 */
.image-content[src=""] {
display: none;
}
/* 响应式图片 */
@media (max-width: 768px) {
.image-content:hover {
transform: none;
}
}
</style>

View File

@ -0,0 +1,223 @@
<template>
<div class="text-component" :class="{ 'design-mode': designMode }">
<component
:is="tag"
class="text-content"
:style="textStyle"
v-html="processedContent"
@click="handleClick"
/>
<!-- 设计模式下的编辑提示 -->
<div v-if="designMode && !content" class="empty-text">
<div class="empty-content">
<Icon icon="ion:text-outline" />
<p>文本组件</p>
<p class="empty-tip">请在右侧属性面板编辑文本内容</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { Icon } from '/@/components/Icon';
interface TextData {
content: string;
}
const props = withDefaults(defineProps<{
// HTML
tag?: 'p' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'span' | 'div';
//
data?: TextData;
//
designMode?: boolean;
//
onClick?: () => void;
//
[key: string]: any;
}>(), {
tag: 'p',
designMode: false,
data: () => ({ content: '' }),
});
const emit = defineEmits(['click']);
const content = computed(() => props.data?.content || '');
const processedContent = computed(() => {
if (!content.value) return '';
//
return content.value.replace(/\n/g, '<br>');
});
const textStyle = computed(() => {
const style: Record<string, any> = {};
// props
const styleProps = [
'fontSize', 'fontWeight', 'fontFamily', 'fontStyle',
'color', 'backgroundColor', 'textAlign', 'textDecoration',
'lineHeight', 'letterSpacing', 'wordSpacing',
'textShadow', 'textTransform',
'margin', 'padding', 'width', 'height',
'border', 'borderRadius', 'boxShadow'
];
styleProps.forEach(prop => {
if (props[prop] !== undefined) {
// CSS
const cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
style[cssProp] = props[prop];
}
});
return style;
});
function handleClick() {
if (props.onClick) {
props.onClick();
}
emit('click');
}
</script>
<style scoped>
.text-component {
width: 100%;
position: relative;
}
.text-component.design-mode {
min-height: 40px;
border: 1px dashed transparent;
border-radius: 4px;
transition: border-color 0.2s;
}
.text-component.design-mode:hover {
border-color: #d9d9d9;
}
.text-content {
width: 100%;
word-wrap: break-word;
word-break: break-word;
cursor: inherit;
}
.text-content:deep(br) {
line-height: 1.5;
}
.empty-text {
width: 100%;
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: #fafafa;
border: 1px dashed #d9d9d9;
border-radius: 4px;
}
.empty-content {
text-align: center;
color: #999;
}
.empty-content .anticon {
font-size: 32px;
margin-bottom: 8px;
opacity: 0.5;
}
.empty-content p {
margin: 0;
font-size: 12px;
}
.empty-tip {
margin-top: 4px !important;
font-size: 11px !important;
}
/* 预设文本样式 */
.text-content.heading-1 {
font-size: 32px;
font-weight: bold;
line-height: 1.2;
margin-bottom: 16px;
}
.text-content.heading-2 {
font-size: 28px;
font-weight: bold;
line-height: 1.3;
margin-bottom: 14px;
}
.text-content.heading-3 {
font-size: 24px;
font-weight: bold;
line-height: 1.4;
margin-bottom: 12px;
}
.text-content.heading-4 {
font-size: 20px;
font-weight: bold;
line-height: 1.4;
margin-bottom: 10px;
}
.text-content.paragraph {
font-size: 16px;
line-height: 1.6;
margin-bottom: 16px;
}
.text-content.small-text {
font-size: 14px;
line-height: 1.5;
color: #666;
}
.text-content.large-text {
font-size: 18px;
line-height: 1.6;
}
/* 响应式设计 */
@media (max-width: 768px) {
.text-content.heading-1 {
font-size: 28px;
}
.text-content.heading-2 {
font-size: 24px;
}
.text-content.heading-3 {
font-size: 20px;
}
.text-content.heading-4 {
font-size: 18px;
}
.text-content.paragraph,
.text-content.large-text {
font-size: 16px;
}
}
</style>

View File

@ -0,0 +1,143 @@
import { App } from 'vue';
import CarouselComponent from './CarouselComponent.vue';
import TextComponent from './TextComponent.vue';
import ImageComponent from './ImageComponent.vue';
import ButtonComponent from './ButtonComponent.vue';
//
export const componentMap = {
carousel: CarouselComponent,
text: TextComponent,
image: ImageComponent,
button: ButtonComponent,
};
//
export function getComponentType(type: string) {
return componentMap[type] || 'div';
}
//
export function registerDesignComponents(app: App) {
Object.entries(componentMap).forEach(([name, component]) => {
app.component(`Design${name.charAt(0).toUpperCase() + name.slice(1)}Component`, component);
});
}
//
export {
CarouselComponent,
TextComponent,
ImageComponent,
ButtonComponent,
};
//
export const componentConfigs = {
carousel: {
name: '轮播图',
icon: 'ion:images-outline',
description: '图片轮播展示',
category: 'basic',
defaultProps: {
autoplay: true,
dots: true,
arrows: false,
effect: 'scrollx',
dotPosition: 'bottom',
},
defaultData: {
images: [
{
url: 'https://via.placeholder.com/800x300/4CAF50/white?text=Slide+1',
alt: 'Slide 1'
},
{
url: 'https://via.placeholder.com/800x300/2196F3/white?text=Slide+2',
alt: 'Slide 2'
},
],
},
defaultStyle: {
width: '100%',
height: '300px',
marginBottom: '20px',
},
},
text: {
name: '文本',
icon: 'ion:text-outline',
description: '文字内容展示',
category: 'basic',
defaultProps: {
tag: 'p',
},
defaultData: {
content: '这是一段文本内容,点击右侧属性面板可以编辑。',
},
defaultStyle: {
fontSize: '16px',
color: '#333',
lineHeight: '1.6',
marginBottom: '16px',
},
},
image: {
name: '图片',
icon: 'ion:image-outline',
description: '单张图片展示',
category: 'basic',
defaultProps: {
alt: '图片',
objectFit: 'cover',
},
defaultData: {
src: 'https://via.placeholder.com/400x200/FF9800/white?text=Image',
},
defaultStyle: {
width: '400px',
height: '200px',
marginBottom: '16px',
},
},
button: {
name: '按钮',
icon: 'ion:radio-button-on-outline',
description: '交互按钮',
category: 'basic',
defaultProps: {
type: 'primary',
size: 'middle',
shape: 'default',
},
defaultData: {
text: '按钮文字',
},
defaultStyle: {
marginBottom: '16px',
},
},
};
//
export function getComponentConfig(type: string) {
return componentConfigs[type];
}
//
export function createComponentInstance(type: string) {
const config = getComponentConfig(type);
if (!config) {
throw new Error(`Unknown component type: ${type}`);
}
const id = `${type}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
return {
id,
type,
props: { ...config.defaultProps },
data: { ...config.defaultData },
style: { ...config.defaultStyle },
};
}

View File

@ -0,0 +1,519 @@
<template>
<div class="portal-design-page">
<!-- 页面头部 -->
<div class="page-header">
<div class="header-content">
<div class="header-left">
<h1 class="page-title">门户设计</h1>
<p class="page-subtitle">创建和管理您的门户页面设计</p>
</div>
<div class="header-right">
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索门户设计..."
style="width: 300px; margin-right: 16px"
@search="handleSearch"
/>
<a-button type="primary" size="large" @click="handleCreate">
<template #icon>
<Icon icon="ion:add" />
</template>
新建门户
</a-button>
</div>
</div>
</div>
<!-- 门户卡片网格 -->
<div class="portal-grid-container">
<a-spin :spinning="loading">
<!-- 新建门户卡片 -->
<div class="portal-grid">
<div class="portal-card create-card" @click="handleCreate">
<div class="create-card-content">
<div class="create-icon">
<Icon icon="ion:add-outline" />
</div>
<div class="create-text">新建门户</div>
</div>
</div>
<!-- 现有门户卡片 -->
<div
v-for="portal in filteredPortals"
:key="portal.id"
class="portal-card"
@click="handleEdit(portal)"
>
<!-- 缩略图 -->
<div class="portal-thumbnail">
<div class="thumbnail-placeholder">
<Icon icon="ion:desktop-outline" />
<span>{{ portal.name }}</span>
</div>
<!-- 悬停遮罩 -->
<div class="portal-overlay">
<div class="overlay-actions">
<a-button type="primary" @click.stop="handleEdit(portal)">
<template #icon>
<Icon icon="ion:create-outline" />
</template>
设计
</a-button>
<a-button @click.stop="handlePreview(portal)">
<template #icon>
<Icon icon="ion:eye-outline" />
</template>
预览
</a-button>
</div>
</div>
</div>
<!-- 卡片信息 -->
<div class="portal-info">
<div class="portal-title">{{ portal.name }}</div>
<div class="portal-meta">
<span class="portal-status" :class="{ 'published': portal.status === 1 }">
{{ portal.status === 1 ? '已发布' : '草稿' }}
</span>
<span class="portal-time">{{ formatTime(portal.updateTime) }}</span>
</div>
</div>
<!-- 操作菜单 -->
<div class="portal-actions">
<a-dropdown :trigger="['click']">
<a-button type="text" size="small" @click.stop>
<Icon icon="ion:ellipsis-horizontal" />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="handleEdit(portal)">
<Icon icon="ion:create-outline" />
编辑设计
</a-menu-item>
<a-menu-item @click="handlePreview(portal)">
<Icon icon="ion:eye-outline" />
预览
</a-menu-item>
<a-menu-item @click="handleCopy(portal)">
<Icon icon="ion:copy-outline" />
复制
</a-menu-item>
<a-menu-divider />
<a-menu-item @click="handleDelete(portal)" class="danger-item">
<Icon icon="ion:trash-outline" />
删除
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredPortals.length === 0 && !loading" class="empty-state">
<div class="empty-icon">
<Icon icon="ion:desktop-outline" />
</div>
<div class="empty-text">
<h3>还没有门户设计</h3>
<p>点击"新建门户"开始创建您的第一个门户页面</p>
</div>
<a-button type="primary" @click="handleCreate">
<template #icon>
<Icon icon="ion:add" />
</template>
新建门户
</a-button>
</div>
</a-spin>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
const router = useRouter();
const { createMessage } = useMessage();
const loading = ref(false);
const dataSource = ref<any[]>([]);
const searchKeyword = ref('');
//
const filteredPortals = computed(() => {
if (!searchKeyword.value) {
return dataSource.value;
}
return dataSource.value.filter(portal =>
portal.name.toLowerCase().includes(searchKeyword.value.toLowerCase()) ||
portal.description.toLowerCase().includes(searchKeyword.value.toLowerCase())
);
});
//
function handleSearch(value: string) {
searchKeyword.value = value;
}
//
function formatTime(time: string) {
if (!time) return '';
const date = new Date(time);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return '今天';
} else if (days === 1) {
return '昨天';
} else if (days < 7) {
return `${days}天前`;
} else {
return date.toLocaleDateString();
}
}
//
function handleCopy(portal: any) {
const newPortal = {
...portal,
id: Date.now(),
name: `${portal.name} - 副本`,
status: 0, // 稿
createTime: new Date().toISOString(),
updateTime: new Date().toISOString(),
};
dataSource.value.push(newPortal);
saveToLocalStorage();
createMessage.success('门户复制成功');
}
function handleCreate() {
router.push('/online-design/editor');
}
function handleEdit(record: any) {
router.push(`/online-design/editor?id=${record.id}`);
}
function handlePreview(record: any) {
router.push(`/online-design/preview?id=${record.id}`);
}
function handleDelete(record: any) {
// localStorage
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
const filteredPages = savedPages.filter((p: any) => p.id !== record.id);
localStorage.setItem('online-design-pages', JSON.stringify(filteredPages));
createMessage.success('删除成功');
//
loadData();
}
//
function saveToLocalStorage() {
localStorage.setItem('portal-designs', JSON.stringify(dataSource.value));
}
function loadData() {
loading.value = true;
// localStorage
setTimeout(() => {
const savedPages = JSON.parse(localStorage.getItem('online-design-pages') || '[]');
//
if (savedPages.length === 0) {
const exampleData = [
{
id: '1',
name: '企业官网首页',
description: '公司官方网站首页设计',
status: 1,
createTime: '2024-08-06 10:00:00',
updateTime: '2024-08-06 15:30:00',
},
{
id: '2',
name: '产品展示页',
description: '产品介绍和展示页面',
status: 0,
createTime: '2024-08-06 14:20:00',
updateTime: '2024-08-06 16:45:00',
},
];
localStorage.setItem('online-design-pages', JSON.stringify(exampleData));
dataSource.value = exampleData;
} else {
dataSource.value = savedPages;
}
loading.value = false;
}, 500);
}
onMounted(() => {
loadData();
});
</script>
<style scoped>
.portal-design-page {
min-height: 100vh;
background: #f5f5f5;
}
.page-header {
background: white;
border-bottom: 1px solid #e8e8e8;
padding: 24px 32px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1400px;
margin: 0 auto;
}
.header-left {
flex: 1;
}
.page-title {
font-size: 28px;
font-weight: 600;
color: #262626;
margin: 0 0 8px 0;
}
.page-subtitle {
font-size: 14px;
color: #8c8c8c;
margin: 0;
}
.header-right {
display: flex;
align-items: center;
}
.portal-grid-container {
max-width: 1400px;
margin: 0 auto;
padding: 32px;
}
.portal-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.portal-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
cursor: pointer;
position: relative;
}
.portal-card:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
transform: translateY(-2px);
}
/* 新建卡片样式 */
.create-card {
border: 2px dashed #d9d9d9;
background: #fafafa;
display: flex;
align-items: center;
justify-content: center;
min-height: 180px;
}
.create-card:hover {
border-color: #1890ff;
background: #f0f9ff;
}
.create-card-content {
text-align: center;
color: #8c8c8c;
}
.create-icon {
font-size: 32px;
margin-bottom: 12px;
color: #d9d9d9;
}
.create-card:hover .create-icon {
color: #1890ff;
}
.create-text {
font-size: 14px;
font-weight: 500;
}
.create-card:hover .create-text {
color: #1890ff;
}
/* 门户缩略图 */
.portal-thumbnail {
position: relative;
height: 140px;
background: #f8f9fa;
overflow: hidden;
}
.thumbnail-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #8c8c8c;
font-size: 32px;
}
.thumbnail-placeholder span {
font-size: 12px;
margin-top: 8px;
font-weight: 500;
}
/* 悬停遮罩 */
.portal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.portal-card:hover .portal-overlay {
opacity: 1;
}
.overlay-actions {
display: flex;
gap: 8px;
}
.overlay-actions .ant-btn {
font-size: 12px;
height: 28px;
padding: 0 12px;
}
/* 卡片信息 */
.portal-info {
padding: 12px 16px;
}
.portal-title {
font-size: 14px;
font-weight: 600;
color: #262626;
margin-bottom: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.portal-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
}
.portal-status {
padding: 1px 6px;
border-radius: 10px;
background: #f5f5f5;
color: #8c8c8c;
font-weight: 500;
font-size: 10px;
}
.portal-status.published {
background: #f6ffed;
color: #52c41a;
}
.portal-time {
color: #8c8c8c;
}
/* 操作菜单 */
.portal-actions {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: opacity 0.3s ease;
}
.portal-card:hover .portal-actions {
opacity: 1;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 80px 20px;
color: #8c8c8c;
}
.empty-icon {
font-size: 80px;
margin-bottom: 24px;
opacity: 0.5;
}
.empty-text h3 {
font-size: 18px;
color: #262626;
margin-bottom: 8px;
}
.empty-text p {
font-size: 14px;
margin-bottom: 24px;
}
/* 危险操作样式 */
:deep(.danger-item) {
color: #ff4d4f !important;
}
:deep(.danger-item:hover) {
background: #fff2f0 !important;
}
</style>

View File

@ -0,0 +1,135 @@
<template>
<div class="test-page">
<h1>在线设计模块测试页面</h1>
<div class="test-section">
<h2>组件测试</h2>
<!-- 轮播图组件测试 -->
<div class="component-test">
<h3>轮播图组件</h3>
<CarouselComponent
:autoplay="true"
:dots="true"
:arrows="true"
:data="{
images: [
{ url: 'https://via.placeholder.com/800x300/4CAF50/white?text=Slide+1', alt: 'Slide 1' },
{ url: 'https://via.placeholder.com/800x300/2196F3/white?text=Slide+2', alt: 'Slide 2' },
{ url: 'https://via.placeholder.com/800x300/FF9800/white?text=Slide+3', alt: 'Slide 3' }
]
}"
style="width: 100%; height: 300px; margin-bottom: 20px;"
/>
</div>
<!-- 文本组件测试 -->
<div class="component-test">
<h3>文本组件</h3>
<TextComponent
tag="h2"
:data="{ content: '这是一个标题文本组件' }"
style="color: #333; font-size: 24px; margin-bottom: 16px;"
/>
<TextComponent
tag="p"
:data="{ content: '这是一个段落文本组件,支持多行文本显示。可以通过属性面板编辑文本内容和样式。' }"
style="color: #666; font-size: 16px; line-height: 1.6; margin-bottom: 16px;"
/>
</div>
<!-- 图片组件测试 -->
<div class="component-test">
<h3>图片组件</h3>
<ImageComponent
alt="测试图片"
:data="{ src: 'https://via.placeholder.com/400x200/FF9800/white?text=Image+Component' }"
style="width: 400px; height: 200px; margin-bottom: 16px;"
/>
</div>
<!-- 按钮组件测试 -->
<div class="component-test">
<h3>按钮组件</h3>
<ButtonComponent
type="primary"
size="large"
:data="{ text: '主要按钮' }"
style="margin-right: 16px; margin-bottom: 16px;"
/>
<ButtonComponent
type="default"
size="middle"
:data="{ text: '默认按钮' }"
style="margin-right: 16px; margin-bottom: 16px;"
/>
<ButtonComponent
type="dashed"
size="small"
:data="{ text: '虚线按钮' }"
style="margin-bottom: 16px;"
/>
</div>
</div>
<div class="test-section">
<h2>功能测试</h2>
<p>请访问 <a href="/online-design/portal" target="_blank">/online-design/portal</a> 测试完整的在线设计功能</p>
</div>
</div>
</template>
<script lang="ts" setup>
import CarouselComponent from './portal/components/design-components/CarouselComponent.vue';
import TextComponent from './portal/components/design-components/TextComponent.vue';
import ImageComponent from './portal/components/design-components/ImageComponent.vue';
import ButtonComponent from './portal/components/design-components/ButtonComponent.vue';
</script>
<style scoped>
.test-page {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.test-section {
margin-bottom: 40px;
}
.component-test {
margin-bottom: 30px;
padding: 20px;
border: 1px solid #e8e8e8;
border-radius: 8px;
background: #fafafa;
}
.component-test h3 {
margin-top: 0;
margin-bottom: 16px;
color: #333;
}
h1 {
color: #333;
text-align: center;
margin-bottom: 40px;
}
h2 {
color: #666;
border-bottom: 2px solid #1890ff;
padding-bottom: 8px;
margin-bottom: 20px;
}
a {
color: #1890ff;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
</style>