完善团课相关管理
This commit is contained in:
+95
@@ -1,5 +1,6 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.dao;
|
package cn.novalon.gym.manage.groupcourse.dao;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
import cn.novalon.gym.manage.groupcourse.dto.GroupCourseQueryDto;
|
||||||
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
import cn.novalon.gym.manage.groupcourse.entity.GroupCourseEntity;
|
||||||
import org.springframework.data.domain.Sort;
|
import org.springframework.data.domain.Sort;
|
||||||
@@ -226,4 +227,98 @@ public interface GroupCourseDao extends R2dbcRepository<GroupCourseEntity, Long>
|
|||||||
|
|
||||||
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询团课(支持 keyword 和 status 过滤)
|
||||||
|
*/
|
||||||
|
default Flux<GroupCourseEntity> findByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT * FROM group_course WHERE deleted_at IS NULL");
|
||||||
|
List<String> conditions = new ArrayList<>();
|
||||||
|
|
||||||
|
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||||
|
conditions.add("course_name ILIKE :keyword");
|
||||||
|
}
|
||||||
|
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||||
|
conditions.add("status = :status");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conditions.isEmpty()) {
|
||||||
|
sql.append(" AND ").append(String.join(" AND ", conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
String sort = pageRequest.getSort() != null ? pageRequest.getSort() : "id";
|
||||||
|
String order = "desc".equalsIgnoreCase(pageRequest.getOrder()) ? "DESC" : "ASC";
|
||||||
|
sql.append(" ORDER BY ").append(sort).append(" ").append(order);
|
||||||
|
|
||||||
|
int size = pageRequest.getSize();
|
||||||
|
if (size < 1) size = 10;
|
||||||
|
if (size > 100) size = 100;
|
||||||
|
int offset = pageRequest.getPage() * size;
|
||||||
|
sql.append(" LIMIT :limit OFFSET :offset");
|
||||||
|
|
||||||
|
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
|
||||||
|
|
||||||
|
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||||
|
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
|
||||||
|
}
|
||||||
|
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||||
|
spec = spec.bind("status", pageRequest.getStatus());
|
||||||
|
}
|
||||||
|
spec = spec.bind("limit", size);
|
||||||
|
spec = spec.bind("offset", offset);
|
||||||
|
|
||||||
|
return spec.map((row, meta) -> {
|
||||||
|
GroupCourseEntity entity = new GroupCourseEntity();
|
||||||
|
entity.setId(row.get("id", Long.class));
|
||||||
|
entity.setCourseName(row.get("course_name", String.class));
|
||||||
|
entity.setCoachId(row.get("coach_id", Long.class));
|
||||||
|
entity.setCourseType(row.get("course_type", Long.class));
|
||||||
|
entity.setStartTime(row.get("start_time", LocalDateTime.class));
|
||||||
|
entity.setEndTime(row.get("end_time", LocalDateTime.class));
|
||||||
|
entity.setMaxMembers(row.get("max_members", Integer.class));
|
||||||
|
entity.setCurrentMembers(row.get("current_members", Integer.class));
|
||||||
|
String statusStr = row.get("status", String.class);
|
||||||
|
entity.setStatus(statusStr != null ? Long.parseLong(statusStr) : null);
|
||||||
|
entity.setLocation(row.get("location", String.class));
|
||||||
|
entity.setCoverImage(row.get("cover_image", String.class));
|
||||||
|
entity.setDescription(row.get("description", String.class));
|
||||||
|
entity.setStoredValueAmount(row.get("stored_value_amount", java.math.BigDecimal.class));
|
||||||
|
entity.setCreateBy(row.get("create_by", String.class));
|
||||||
|
entity.setUpdateBy(row.get("update_by", String.class));
|
||||||
|
entity.setCreatedAt(row.get("created_at", LocalDateTime.class));
|
||||||
|
entity.setUpdatedAt(row.get("updated_at", LocalDateTime.class));
|
||||||
|
entity.setDeletedAt(row.get("deleted_at", LocalDateTime.class));
|
||||||
|
return entity;
|
||||||
|
}).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询团课总数(支持 keyword 和 status 过滤)
|
||||||
|
*/
|
||||||
|
default Mono<Long> countByPageFiltered(DatabaseClient databaseClient, PageRequest pageRequest) {
|
||||||
|
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM group_course WHERE deleted_at IS NULL");
|
||||||
|
List<String> conditions = new ArrayList<>();
|
||||||
|
|
||||||
|
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||||
|
conditions.add("course_name ILIKE :keyword");
|
||||||
|
}
|
||||||
|
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||||
|
conditions.add("status = :status");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conditions.isEmpty()) {
|
||||||
|
sql.append(" AND ").append(String.join(" AND ", conditions));
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseClient.GenericExecuteSpec spec = databaseClient.sql(sql.toString());
|
||||||
|
|
||||||
|
if (pageRequest.getKeyword() != null && !pageRequest.getKeyword().isEmpty()) {
|
||||||
|
spec = spec.bind("keyword", "%" + pageRequest.getKeyword() + "%");
|
||||||
|
}
|
||||||
|
if (pageRequest.getStatus() != null && !pageRequest.getStatus().isEmpty()) {
|
||||||
|
spec = spec.bind("status", pageRequest.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
return spec.map((row, meta) -> row.get(0, Long.class)).one();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+14
-6
@@ -1,5 +1,6 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.handler;
|
package cn.novalon.gym.manage.groupcourse.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
|
import cn.novalon.gym.manage.groupcourse.service.ICourseLabelService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -29,6 +30,13 @@ public class CourseLabelHandler {
|
|||||||
.body(courseLabelService.findAll(), CourseLabel.class);
|
.body(courseLabelService.findAll(), CourseLabel.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "分页查询标签", description = "支持按关键词筛选并分页查询标签")
|
||||||
|
public Mono<ServerResponse> getLabelsByPage(ServerRequest request) {
|
||||||
|
return request.bodyToMono(PageRequest.class)
|
||||||
|
.flatMap(pageRequest -> courseLabelService.findByPage(pageRequest)
|
||||||
|
.flatMap(response -> ServerResponse.ok().bodyValue(response)));
|
||||||
|
}
|
||||||
|
|
||||||
@Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
|
@Operation(summary = "根据ID获取标签", description = "根据ID获取标签详情")
|
||||||
public Mono<ServerResponse> getLabelById(ServerRequest request) {
|
public Mono<ServerResponse> getLabelById(ServerRequest request) {
|
||||||
Long id = Long.valueOf(request.pathVariable("id"));
|
Long id = Long.valueOf(request.pathVariable("id"));
|
||||||
@@ -147,17 +155,17 @@ public class CourseLabelHandler {
|
|||||||
return request.bodyToMono(Map.class)
|
return request.bodyToMono(Map.class)
|
||||||
.flatMap(body -> {
|
.flatMap(body -> {
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<Integer> labelIdsInt = (List<Integer>) body.get("labelIds");
|
List<?> rawIds = (List<?>) body.get("labelIds");
|
||||||
|
|
||||||
if (labelIdsInt == null || labelIdsInt.isEmpty()) {
|
if (rawIds == null || rawIds.isEmpty()) {
|
||||||
Map<String, Object> error = new HashMap<>();
|
Map<String, Object> error = new HashMap<>();
|
||||||
error.put("success", false);
|
error.put("success", false);
|
||||||
error.put("message", "labelIds不能为空");
|
error.put("message", "labelIds不能为空");
|
||||||
return ServerResponse.badRequest().bodyValue(error);
|
return ServerResponse.badRequest().bodyValue(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Long> labelIds = labelIdsInt.stream()
|
List<Long> labelIds = rawIds.stream()
|
||||||
.map(Integer::longValue)
|
.map(id -> Long.valueOf(String.valueOf(id)))
|
||||||
.collect(java.util.stream.Collectors.toList());
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
|
||||||
return courseLabelService.addLabelsToType(typeId, labelIds)
|
return courseLabelService.addLabelsToType(typeId, labelIds)
|
||||||
|
|||||||
+9
@@ -1,5 +1,6 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.handler;
|
package cn.novalon.gym.manage.groupcourse.handler;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
@@ -134,4 +135,12 @@ public class GroupCourseTypeHandler {
|
|||||||
return ServerResponse.badRequest().bodyValue(response);
|
return ServerResponse.badRequest().bodyValue(response);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "分页查询团课类型", description = "支持按关键词、分类筛选并分页查询团课类型")
|
||||||
|
public Mono<ServerResponse> getGroupCourseTypesByPage(ServerRequest request) {
|
||||||
|
return request.bodyToMono(PageRequest.class)
|
||||||
|
.flatMap(pageRequest -> groupCourseTypeService.findByPage(pageRequest)
|
||||||
|
.flatMap(response -> ServerResponse.ok().bodyValue(response))
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+4
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.repository;
|
package cn.novalon.gym.manage.groupcourse.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -29,4 +31,6 @@ public interface ICourseLabelRepository {
|
|||||||
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
|
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
|
||||||
|
|
||||||
Mono<Void> clearLabelsFromType(Long typeId);
|
Mono<Void> clearLabelsFromType(Long typeId);
|
||||||
|
|
||||||
|
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
|
||||||
}
|
}
|
||||||
+4
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.repository;
|
package cn.novalon.gym.manage.groupcourse.repository;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -25,4 +27,6 @@ public interface IGroupCourseTypeRepository {
|
|||||||
Mono<GroupCourseType> update(GroupCourseType groupCourseType);
|
Mono<GroupCourseType> update(GroupCourseType groupCourseType);
|
||||||
|
|
||||||
Mono<Void> deleteById(Long id);
|
Mono<Void> deleteById(Long id);
|
||||||
|
|
||||||
|
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
|
||||||
}
|
}
|
||||||
+47
-1
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
||||||
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
|
import cn.novalon.gym.manage.groupcourse.dao.CourseLabelDao;
|
||||||
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao;
|
import cn.novalon.gym.manage.groupcourse.dao.CourseTypeLabelDao;
|
||||||
@@ -9,12 +11,17 @@ import cn.novalon.gym.manage.groupcourse.entity.CourseTypeLabelEntity;
|
|||||||
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
|
import cn.novalon.gym.manage.groupcourse.repository.ICourseLabelRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
||||||
|
import org.springframework.data.relational.core.query.Criteria;
|
||||||
|
import org.springframework.data.relational.core.query.Query;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@@ -26,12 +33,14 @@ public class CourseLabelRepository implements ICourseLabelRepository {
|
|||||||
private final CourseLabelDao courseLabelDao;
|
private final CourseLabelDao courseLabelDao;
|
||||||
private final CourseTypeLabelDao courseTypeLabelDao;
|
private final CourseTypeLabelDao courseTypeLabelDao;
|
||||||
private final GroupCourseConverter converter;
|
private final GroupCourseConverter converter;
|
||||||
|
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||||
|
|
||||||
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
|
public CourseLabelRepository(CourseLabelDao courseLabelDao, CourseTypeLabelDao courseTypeLabelDao,
|
||||||
GroupCourseConverter converter) {
|
GroupCourseConverter converter, R2dbcEntityTemplate r2dbcEntityTemplate) {
|
||||||
this.courseLabelDao = courseLabelDao;
|
this.courseLabelDao = courseLabelDao;
|
||||||
this.courseTypeLabelDao = courseTypeLabelDao;
|
this.courseTypeLabelDao = courseTypeLabelDao;
|
||||||
this.converter = converter;
|
this.converter = converter;
|
||||||
|
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -158,4 +167,41 @@ public class CourseLabelRepository implements ICourseLabelRepository {
|
|||||||
}
|
}
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
|
||||||
|
int page = pageRequest.getPage();
|
||||||
|
int size = pageRequest.getSize();
|
||||||
|
String sort = pageRequest.getSort();
|
||||||
|
String order = pageRequest.getOrder();
|
||||||
|
String keyword = pageRequest.getKeyword();
|
||||||
|
|
||||||
|
Sort sortObj = Sort.unsorted();
|
||||||
|
if (sort != null && !sort.isEmpty()) {
|
||||||
|
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Criteria> criteriaList = new ArrayList<>();
|
||||||
|
criteriaList.add(Criteria.where("deleted_at").isNull());
|
||||||
|
if (keyword != null && !keyword.isEmpty()) {
|
||||||
|
criteriaList.add(Criteria.where("label_name").like("%" + keyword + "%").ignoreCase(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
|
||||||
|
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
|
||||||
|
|
||||||
|
return r2dbcEntityTemplate.select(CourseLabelEntity.class)
|
||||||
|
.matching(query)
|
||||||
|
.all()
|
||||||
|
.collectList()
|
||||||
|
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), CourseLabelEntity.class))
|
||||||
|
.map(tuple -> {
|
||||||
|
long total = tuple.getT2();
|
||||||
|
int totalPages = (int) Math.ceil((double) total / size);
|
||||||
|
List<CourseLabel> list = tuple.getT1().stream()
|
||||||
|
.map(this::toCourseLabel)
|
||||||
|
.toList();
|
||||||
|
return new PageResponse<>(list, totalPages, total, page, size);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+11
-29
@@ -97,37 +97,19 @@ public class GroupCourseRepository implements IGroupCourseRepository {
|
|||||||
public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) {
|
public Mono<PageResponse<GroupCourse>> findByPageAndNotDeleted(PageRequest pageRequest) {
|
||||||
int page = pageRequest.getPage();
|
int page = pageRequest.getPage();
|
||||||
int size = pageRequest.getSize();
|
int size = pageRequest.getSize();
|
||||||
String sort = pageRequest.getSort();
|
|
||||||
String order = pageRequest.getOrder();
|
|
||||||
|
|
||||||
Sort sortObj = Sort.unsorted();
|
return groupCourseDao.countByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
|
||||||
if (sort != null && !sort.isEmpty()) {
|
.flatMap(total -> {
|
||||||
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
if (total == 0) {
|
||||||
}
|
return Mono.just(new PageResponse<>(List.of(), 0, 0L, page, size));
|
||||||
|
|
||||||
org.springframework.data.domain.PageRequest pageable = org.springframework.data.domain.PageRequest.of(page, size, sortObj);
|
|
||||||
|
|
||||||
return groupCourseDao.findAllByDeletedAtIsNull(sortObj)
|
|
||||||
.collectList()
|
|
||||||
.zipWith(groupCourseDao.findAllByDeletedAtIsNull().count())
|
|
||||||
.map(tuple -> {
|
|
||||||
List<GroupCourseEntity> allEntities = tuple.getT1();
|
|
||||||
long total = tuple.getT2();
|
|
||||||
|
|
||||||
int fromIndex = page * size;
|
|
||||||
int toIndex = Math.min(fromIndex + size, allEntities.size());
|
|
||||||
|
|
||||||
List<GroupCourse> courseList;
|
|
||||||
if (fromIndex < allEntities.size()) {
|
|
||||||
courseList = allEntities.subList(fromIndex, toIndex).stream()
|
|
||||||
.map(groupCourseConverter::toDomain)
|
|
||||||
.toList();
|
|
||||||
} else {
|
|
||||||
courseList = List.of();
|
|
||||||
}
|
}
|
||||||
|
return groupCourseDao.findByPageFiltered(r2dbcEntityTemplate.getDatabaseClient(), pageRequest)
|
||||||
int totalPages = (int) Math.ceil((double) total / size);
|
.map(groupCourseConverter::toDomain)
|
||||||
return new PageResponse<>(courseList, totalPages, total, page, size);
|
.collectList()
|
||||||
|
.map(courseList -> {
|
||||||
|
int totalPages = (int) Math.ceil((double) total / size);
|
||||||
|
return new PageResponse<>(courseList, totalPages, total, page, size);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+54
-1
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
package cn.novalon.gym.manage.groupcourse.repository.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
import cn.novalon.gym.manage.groupcourse.converter.GroupCourseConverter;
|
||||||
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
|
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseTypeDao;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
@@ -7,12 +9,18 @@ import cn.novalon.gym.manage.groupcourse.entity.GroupCourseTypeEntity;
|
|||||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.data.domain.Sort;
|
||||||
|
import org.springframework.data.r2dbc.core.R2dbcEntityTemplate;
|
||||||
|
import org.springframework.data.relational.core.query.Criteria;
|
||||||
|
import org.springframework.data.relational.core.query.Query;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -22,10 +30,13 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
|
|||||||
|
|
||||||
private final GroupCourseTypeDao groupCourseTypeDao;
|
private final GroupCourseTypeDao groupCourseTypeDao;
|
||||||
private final GroupCourseConverter converter;
|
private final GroupCourseConverter converter;
|
||||||
|
private final R2dbcEntityTemplate r2dbcEntityTemplate;
|
||||||
|
|
||||||
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter) {
|
public GroupCourseTypeRepository(GroupCourseTypeDao groupCourseTypeDao, GroupCourseConverter converter,
|
||||||
|
R2dbcEntityTemplate r2dbcEntityTemplate) {
|
||||||
this.groupCourseTypeDao = groupCourseTypeDao;
|
this.groupCourseTypeDao = groupCourseTypeDao;
|
||||||
this.converter = converter;
|
this.converter = converter;
|
||||||
|
this.r2dbcEntityTemplate = r2dbcEntityTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -129,4 +140,46 @@ public class GroupCourseTypeRepository implements IGroupCourseTypeRepository {
|
|||||||
return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
|
return groupCourseTypeDao.softDelete(id, LocalDateTime.now())
|
||||||
.then();
|
.then();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
|
||||||
|
int page = pageRequest.getPage();
|
||||||
|
int size = pageRequest.getSize();
|
||||||
|
String order = pageRequest.getOrder();
|
||||||
|
String sort = pageRequest.getSort();
|
||||||
|
String keyword = pageRequest.getKeyword();
|
||||||
|
String category = pageRequest.getCategory();
|
||||||
|
|
||||||
|
Sort sortObj = Sort.unsorted();
|
||||||
|
if (sort != null && !sort.isEmpty()) {
|
||||||
|
sortObj = Sort.by(Sort.Direction.fromString(order), sort);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build dynamic criteria
|
||||||
|
List<Criteria> criteriaList = new ArrayList<>();
|
||||||
|
criteriaList.add(Criteria.where("deleted_at").isNull());
|
||||||
|
if (keyword != null && !keyword.isEmpty()) {
|
||||||
|
criteriaList.add(Criteria.where("type_name").like("%" + keyword + "%").ignoreCase(true));
|
||||||
|
}
|
||||||
|
if (category != null && !category.isEmpty()) {
|
||||||
|
criteriaList.add(Criteria.where("category").is(category));
|
||||||
|
}
|
||||||
|
|
||||||
|
Criteria criteria = criteriaList.isEmpty() ? Criteria.empty() : Criteria.from(criteriaList);
|
||||||
|
Query query = Query.query(criteria).with(org.springframework.data.domain.PageRequest.of(page, size, sortObj));
|
||||||
|
|
||||||
|
return r2dbcEntityTemplate.select(GroupCourseTypeEntity.class)
|
||||||
|
.matching(query)
|
||||||
|
.all()
|
||||||
|
.collectList()
|
||||||
|
.zipWith(r2dbcEntityTemplate.count(Query.query(criteria), GroupCourseTypeEntity.class))
|
||||||
|
.map(tuple -> {
|
||||||
|
long total = tuple.getT2();
|
||||||
|
int totalPages = (int) Math.ceil((double) total / size);
|
||||||
|
List<GroupCourseType> list = tuple.getT1().stream()
|
||||||
|
.map(converter::toGroupCourseType)
|
||||||
|
.toList();
|
||||||
|
return new PageResponse<>(list, totalPages, total, page, size);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+4
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.service;
|
package cn.novalon.gym.manage.groupcourse.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -27,4 +29,6 @@ public interface ICourseLabelService {
|
|||||||
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
|
Mono<Void> removeLabelFromType(Long typeId, Long labelId);
|
||||||
|
|
||||||
Mono<Void> clearLabelsFromType(Long typeId);
|
Mono<Void> clearLabelsFromType(Long typeId);
|
||||||
|
|
||||||
|
Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest);
|
||||||
}
|
}
|
||||||
+4
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.service;
|
package cn.novalon.gym.manage.groupcourse.service;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
@@ -29,4 +31,6 @@ public interface IGroupCourseTypeService {
|
|||||||
* @return 分类名称列表
|
* @return 分类名称列表
|
||||||
*/
|
*/
|
||||||
Flux<String> findCategories();
|
Flux<String> findCategories();
|
||||||
|
|
||||||
|
Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest);
|
||||||
}
|
}
|
||||||
+10
@@ -1,5 +1,7 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.service.impl;
|
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
import cn.novalon.gym.manage.groupcourse.domain.CourseLabel;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourse;
|
||||||
@@ -109,4 +111,12 @@ public class CourseLabelService implements ICourseLabelService {
|
|||||||
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
|
.doOnSuccess(v -> logger.info("清空类型标签成功 - typeId={}", typeId))
|
||||||
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
|
.doOnError(error -> logger.error("清空类型标签失败 - typeId={}, error: {}", typeId, error.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<CourseLabel>> findByPage(PageRequest pageRequest) {
|
||||||
|
return courseLabelRepository.findByPage(pageRequest)
|
||||||
|
.doOnSuccess(result -> logger.info("分页查询标签成功 - page={}, size={}, total={}",
|
||||||
|
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
|
||||||
|
.doOnError(error -> logger.error("分页查询标签失败 - error: {}", error.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+8
-6
@@ -224,8 +224,9 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
String sort = pageRequest.getSort();
|
String sort = pageRequest.getSort();
|
||||||
String order = pageRequest.getOrder();
|
String order = pageRequest.getOrder();
|
||||||
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
|
String keyword = pageRequest.getKeyword() != null ? pageRequest.getKeyword() : "";
|
||||||
|
String status = pageRequest.getStatus() != null ? pageRequest.getStatus() : "";
|
||||||
|
|
||||||
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword;
|
String cacheKey = CACHE_KEY_PREFIX + page + ":" + size + ":" + includeDeleted + ":" + sort + ":" + order + ":" + keyword + ":" + status;
|
||||||
|
|
||||||
return redisUtil.get(cacheKey, String.class)
|
return redisUtil.get(cacheKey, String.class)
|
||||||
.flatMap(cachedJson -> {
|
.flatMap(cachedJson -> {
|
||||||
@@ -536,14 +537,15 @@ public class GroupCourseService implements IGroupCourseService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> delete(Long id) {
|
public Mono<Void> delete(Long id) {
|
||||||
// 先查询课程状态,只有已取消的课程才能删除
|
// 只有已取消或已结束的课程才能删除
|
||||||
return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
return groupCourseRepository.findByIdAndDeletedAtIsNull(id)
|
||||||
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
.switchIfEmpty(Mono.error(new RuntimeException("团课不存在")))
|
||||||
.flatMap(course -> {
|
.flatMap(course -> {
|
||||||
// 检查课程状态是否为已取消(状态码1)
|
// 检查课程状态是否为已取消(状态码1)或已结束(状态码2)
|
||||||
if (course.getStatus() == null || !course.getStatus().equals(CourseStatus.CANCELLED.getValue())) {
|
Long status = course.getStatus();
|
||||||
return Mono.error(new RuntimeException("只有已取消的课程才能删除,当前状态: " +
|
if (status == null || (!status.equals(CourseStatus.CANCELLED.getValue()) && !status.equals(CourseStatus.ENDED.getValue()))) {
|
||||||
(course.getStatus() != null ? course.getStatus() : "未知")));
|
return Mono.error(new RuntimeException("只有已取消或已结束的课程才能删除,当前状态: " +
|
||||||
|
(status != null ? status : "未知")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除课程
|
// 删除课程
|
||||||
|
|||||||
+26
-7
@@ -1,6 +1,9 @@
|
|||||||
package cn.novalon.gym.manage.groupcourse.service.impl;
|
package cn.novalon.gym.manage.groupcourse.service.impl;
|
||||||
|
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageRequest;
|
||||||
|
import cn.novalon.gym.manage.common.dto.PageResponse;
|
||||||
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
import cn.novalon.gym.manage.groupcourse.domain.GroupCourseType;
|
||||||
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseRepository;
|
||||||
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
import cn.novalon.gym.manage.groupcourse.repository.IGroupCourseTypeRepository;
|
||||||
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
import cn.novalon.gym.manage.groupcourse.service.IGroupCourseTypeService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -9,18 +12,18 @@ import org.springframework.stereotype.Service;
|
|||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class GroupCourseTypeService implements IGroupCourseTypeService {
|
public class GroupCourseTypeService implements IGroupCourseTypeService {
|
||||||
|
|
||||||
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
|
private static final Logger logger = LoggerFactory.getLogger(GroupCourseTypeService.class);
|
||||||
|
|
||||||
private final IGroupCourseTypeRepository groupCourseTypeRepository;
|
private final IGroupCourseTypeRepository groupCourseTypeRepository;
|
||||||
|
private final IGroupCourseRepository groupCourseRepository;
|
||||||
|
|
||||||
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository) {
|
public GroupCourseTypeService(IGroupCourseTypeRepository groupCourseTypeRepository,
|
||||||
|
IGroupCourseRepository groupCourseRepository) {
|
||||||
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
this.groupCourseTypeRepository = groupCourseTypeRepository;
|
||||||
|
this.groupCourseRepository = groupCourseRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -71,9 +74,17 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Mono<Void> delete(Long id) {
|
public Mono<Void> delete(Long id) {
|
||||||
return groupCourseTypeRepository.deleteById(id)
|
// 检查是否有团课依赖该类型
|
||||||
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
|
return groupCourseRepository.findByCourseType(id)
|
||||||
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
|
.hasElements()
|
||||||
|
.flatMap(hasDependents -> {
|
||||||
|
if (hasDependents) {
|
||||||
|
return Mono.<Void>error(new RuntimeException("该类型下存在团课,无法删除"));
|
||||||
|
}
|
||||||
|
return groupCourseTypeRepository.deleteById(id)
|
||||||
|
.doOnSuccess(v -> logger.info("团课类型删除成功 - id={}", id))
|
||||||
|
.doOnError(error -> logger.error("团课类型删除失败 - id={}, error: {}", id, error.getMessage()));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -83,4 +94,12 @@ public class GroupCourseTypeService implements IGroupCourseTypeService {
|
|||||||
.filter(category -> category != null && !category.isEmpty())
|
.filter(category -> category != null && !category.isEmpty())
|
||||||
.distinct();
|
.distinct();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Mono<PageResponse<GroupCourseType>> findByPage(PageRequest pageRequest) {
|
||||||
|
return groupCourseTypeRepository.findByPage(pageRequest)
|
||||||
|
.doOnSuccess(result -> logger.info("分页查询团课类型成功 - page={}, size={}, total={}",
|
||||||
|
pageRequest.getPage(), pageRequest.getSize(), result.getTotalElements()))
|
||||||
|
.doOnError(error -> logger.error("分页查询团课类型失败 - error: {}", error.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
@@ -305,6 +305,7 @@ public class SystemRouter {
|
|||||||
.GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory)
|
.GET("/api/groupCourse/types/category/{category}", groupCourseTypeHandler::getGroupCourseTypesByCategory)
|
||||||
.GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById)
|
.GET("/api/groupCourse/types/{id}", groupCourseTypeHandler::getGroupCourseTypeById)
|
||||||
.POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType)
|
.POST("/api/groupCourse/types", groupCourseTypeHandler::createGroupCourseType)
|
||||||
|
.POST("/api/groupCourse/types/page", groupCourseTypeHandler::getGroupCourseTypesByPage)
|
||||||
.PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType)
|
.PUT("/api/groupCourse/types/{id}", groupCourseTypeHandler::updateGroupCourseType)
|
||||||
.DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType)
|
.DELETE("/api/groupCourse/types/{id}", groupCourseTypeHandler::deleteGroupCourseType)
|
||||||
|
|
||||||
@@ -313,6 +314,7 @@ public class SystemRouter {
|
|||||||
.GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels)
|
.GET("/api/groupCourse/labels/search", courseLabelHandler::searchLabels)
|
||||||
.GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById)
|
.GET("/api/groupCourse/labels/{id}", courseLabelHandler::getLabelById)
|
||||||
.GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId)
|
.GET("/api/groupCourse/types/{typeId}/labels", courseLabelHandler::getLabelsByTypeId)
|
||||||
|
.POST("/api/groupCourse/labels/page", courseLabelHandler::getLabelsByPage)
|
||||||
.POST("/api/groupCourse/labels", courseLabelHandler::createLabel)
|
.POST("/api/groupCourse/labels", courseLabelHandler::createLabel)
|
||||||
.PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel)
|
.PUT("/api/groupCourse/labels/{id}", courseLabelHandler::updateLabel)
|
||||||
.DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel)
|
.DELETE("/api/groupCourse/labels/{id}", courseLabelHandler::deleteLabel)
|
||||||
|
|||||||
+18
@@ -12,6 +12,8 @@ public class PageRequest {
|
|||||||
private String sort = "id";
|
private String sort = "id";
|
||||||
private String order = "asc";
|
private String order = "asc";
|
||||||
private String keyword;
|
private String keyword;
|
||||||
|
private String status;
|
||||||
|
private String category;
|
||||||
|
|
||||||
public int getPage() {
|
public int getPage() {
|
||||||
return page;
|
return page;
|
||||||
@@ -52,4 +54,20 @@ public class PageRequest {
|
|||||||
public void setKeyword(String keyword) {
|
public void setKeyword(String keyword) {
|
||||||
this.keyword = keyword;
|
this.keyword = keyword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setStatus(String status) {
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCategory() {
|
||||||
|
return category;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCategory(String category) {
|
||||||
|
this.category = category;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- ============================================
|
||||||
|
-- V28: 新增业务模块菜单
|
||||||
|
-- 描述: 为团课管理、团课类型、团课标签、团课推荐创建菜单项
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 团课管理一级菜单(目录)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(400, '团课管理', 0, 4, 'M', NULL, NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 团课管理子菜单(页面)
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(401, '团课管理', 400, 1, 'C', 'business:groupCourse:list', 'gymCourse/course/index', 1, NOW(), NOW()),
|
||||||
|
(402, '团课类型', 400, 2, 'C', 'business:groupCourseType:list', 'gymCourse/type/index', 1, NOW(), NOW()),
|
||||||
|
(403, '类型标签', 400, 3, 'C', 'business:groupCourseLabel:list', 'gymCourse/label/index', 1, NOW(), NOW()),
|
||||||
|
(404, '团课推荐', 400, 4, 'C', 'business:groupCourseRecommend:list', 'gymCourse/recommend/index', 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 团课管理按钮权限
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(4011, '团课查询', 401, 1, 'F', 'business:groupCourse:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(4012, '团课新增', 401, 2, 'F', 'business:groupCourse:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(4013, '团课修改', 401, 3, 'F', 'business:groupCourse:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(4014, '团课删除', 401, 4, 'F', 'business:groupCourse:remove', NULL, 1, NOW(), NOW()),
|
||||||
|
(4015, '团课取消', 401, 5, 'F', 'business:groupCourse:cancel', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 团课类型按钮权限
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(4021, '类型查询', 402, 1, 'F', 'business:groupCourseType:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(4022, '类型新增', 402, 2, 'F', 'business:groupCourseType:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(4023, '类型修改', 402, 3, 'F', 'business:groupCourseType:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(4024, '类型删除', 402, 4, 'F', 'business:groupCourseType:remove', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 团课标签按钮权限
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(4031, '标签查询', 403, 1, 'F', 'business:groupCourseLabel:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(4032, '标签新增', 403, 2, 'F', 'business:groupCourseLabel:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(4033, '标签修改', 403, 3, 'F', 'business:groupCourseLabel:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(4034, '标签删除', 403, 4, 'F', 'business:groupCourseLabel:remove', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 团课推荐按钮权限
|
||||||
|
INSERT INTO sys_menu (id, menu_name, parent_id, order_num, menu_type, perms, component, status, created_at, updated_at) VALUES
|
||||||
|
(4041, '推荐查询', 404, 1, 'F', 'business:groupCourseRecommend:query', NULL, 1, NOW(), NOW()),
|
||||||
|
(4042, '推荐新增', 404, 2, 'F', 'business:groupCourseRecommend:add', NULL, 1, NOW(), NOW()),
|
||||||
|
(4043, '推荐修改', 404, 3, 'F', 'business:groupCourseRecommend:edit', NULL, 1, NOW(), NOW()),
|
||||||
|
(4044, '推荐删除', 404, 4, 'F', 'business:groupCourseRecommend:remove', NULL, 1, NOW(), NOW());
|
||||||
|
|
||||||
|
-- 重置菜单序列
|
||||||
|
SELECT setval('sys_menu_id_seq', (SELECT COALESCE(MAX(id), 1) FROM sys_menu));
|
||||||
@@ -91,6 +91,23 @@ export interface GroupCourseType {
|
|||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GroupCourseTypePageRequest {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
keyword?: string
|
||||||
|
category?: string
|
||||||
|
sort?: string
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseLabelPageRequest {
|
||||||
|
page: number
|
||||||
|
size: number
|
||||||
|
keyword?: string
|
||||||
|
sort?: string
|
||||||
|
order?: 'asc' | 'desc'
|
||||||
|
}
|
||||||
|
|
||||||
export const groupCourseTypeApi = {
|
export const groupCourseTypeApi = {
|
||||||
getAll: () =>
|
getAll: () =>
|
||||||
request.get<GroupCourseType[]>('/groupCourse/types'),
|
request.get<GroupCourseType[]>('/groupCourse/types'),
|
||||||
@@ -102,11 +119,14 @@ export const groupCourseTypeApi = {
|
|||||||
request.get<string[]>('/groupCourse/types/categories'),
|
request.get<string[]>('/groupCourse/types/categories'),
|
||||||
|
|
||||||
getByCategory: (category: string) =>
|
getByCategory: (category: string) =>
|
||||||
request.get<GroupCourseType[]>(`/groupCourse/types/category/${category}`),
|
request.get<GroupCourseType[]>(`/groupCourse/types/category/${encodeURIComponent(category)}`),
|
||||||
|
|
||||||
getById: (id: number) =>
|
getById: (id: number) =>
|
||||||
request.get<GroupCourseType>(`/groupCourse/types/${id}`),
|
request.get<GroupCourseType>(`/groupCourse/types/${id}`),
|
||||||
|
|
||||||
|
getPage: (params: GroupCourseTypePageRequest) =>
|
||||||
|
request.post<PageResponse<GroupCourseType>>('/groupCourse/types/page', params),
|
||||||
|
|
||||||
create: (data: GroupCourseType) =>
|
create: (data: GroupCourseType) =>
|
||||||
request.post<GroupCourseType>('/groupCourse/types', data),
|
request.post<GroupCourseType>('/groupCourse/types', data),
|
||||||
|
|
||||||
@@ -143,6 +163,9 @@ export const courseLabelApi = {
|
|||||||
getByTypeId: (typeId: number) =>
|
getByTypeId: (typeId: number) =>
|
||||||
request.get<CourseLabel[]>(`/groupCourse/types/${typeId}/labels`),
|
request.get<CourseLabel[]>(`/groupCourse/types/${typeId}/labels`),
|
||||||
|
|
||||||
|
getPage: (params: CourseLabelPageRequest) =>
|
||||||
|
request.post<PageResponse<CourseLabel>>('/groupCourse/labels/page', params),
|
||||||
|
|
||||||
create: (data: CourseLabel) =>
|
create: (data: CourseLabel) =>
|
||||||
request.post<CourseLabel>('/groupCourse/labels', data),
|
request.post<CourseLabel>('/groupCourse/labels', data),
|
||||||
|
|
||||||
@@ -153,7 +176,7 @@ export const courseLabelApi = {
|
|||||||
request.delete<void>(`/groupCourse/labels/${id}`),
|
request.delete<void>(`/groupCourse/labels/${id}`),
|
||||||
|
|
||||||
addLabelsToType: (typeId: number, labelIds: number[]) =>
|
addLabelsToType: (typeId: number, labelIds: number[]) =>
|
||||||
request.post<void>(`/groupCourse/types/${typeId}/labels`, labelIds),
|
request.post<void>(`/groupCourse/types/${typeId}/labels`, { labelIds }),
|
||||||
|
|
||||||
removeLabelFromType: (typeId: number, labelId: number) =>
|
removeLabelFromType: (typeId: number, labelId: number) =>
|
||||||
request.delete<void>(`/groupCourse/types/${typeId}/labels/${labelId}`),
|
request.delete<void>(`/groupCourse/types/${typeId}/labels/${labelId}`),
|
||||||
|
|||||||
@@ -97,15 +97,15 @@ onMounted(async () => {
|
|||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
} else if (!permissionStore.loaded) {
|
} else {
|
||||||
permissionStore.initFromStorage()
|
// 先尝试从 localStorage 快速恢复,再异步获取最新菜单
|
||||||
|
if (!permissionStore.loaded) {
|
||||||
if (!permissionStore.loaded || permissionStore.menus.length === 0) {
|
permissionStore.initFromStorage()
|
||||||
try {
|
}
|
||||||
await permissionStore.fetchUserMenus()
|
try {
|
||||||
} catch (error) {
|
await permissionStore.fetchUserMenus()
|
||||||
console.error('获取用户菜单失败:', error)
|
} catch (error) {
|
||||||
}
|
console.error('获取用户菜单失败:', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ function transformMenuData(backendMenus: BackendMenuItem[]): MenuItem[] {
|
|||||||
'audit/operation/index': '/oplog',
|
'audit/operation/index': '/oplog',
|
||||||
'audit/login/index': '/loginlog',
|
'audit/login/index': '/loginlog',
|
||||||
'audit/exception/index': '/exceptionlog',
|
'audit/exception/index': '/exceptionlog',
|
||||||
|
'gymCourse/course/index': '/courses',
|
||||||
|
'gymCourse/type/index': '/courses/types',
|
||||||
|
'gymCourse/label/index': '/courses/labels',
|
||||||
|
'gymCourse/recommend/index': '/courses/recommend',
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
|
const filteredMenus = backendMenus.filter(menu => menu.menuType !== 'F')
|
||||||
@@ -92,7 +96,11 @@ function getMenuIcon(menuName: string): string {
|
|||||||
'文件管理': 'Folder',
|
'文件管理': 'Folder',
|
||||||
'操作日志': 'Document',
|
'操作日志': 'Document',
|
||||||
'登录日志': 'Document',
|
'登录日志': 'Document',
|
||||||
'异常日志': 'Warning'
|
'异常日志': 'Warning',
|
||||||
|
'团课管理': 'TrophyBase',
|
||||||
|
'团课类型': 'Grid',
|
||||||
|
'团课标签': 'Discount',
|
||||||
|
'团课推荐': 'Star'
|
||||||
}
|
}
|
||||||
return iconMap[menuName] || 'Document'
|
return iconMap[menuName] || 'Document'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,17 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
style="margin-top: 16px; justify-content: flex-end"
|
||||||
|
@size-change="onSizeChange"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -112,12 +123,16 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { courseLabelApi, type CourseLabel } from '@/api/groupCourse.api'
|
import { courseLabelApi, type CourseLabel, type CourseLabelPageRequest } from '@/api/groupCourse.api'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dataSource = ref<CourseLabel[]>([])
|
const dataSource = ref<CourseLabel[]>([])
|
||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const modalTitle = ref('')
|
const modalTitle = ref('')
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
@@ -138,12 +153,17 @@ const formRules = {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
dataSource.value = await courseLabelApi.getAll()
|
const params: CourseLabelPageRequest = {
|
||||||
if (searchKeyword.value) {
|
page: currentPage.value - 1,
|
||||||
dataSource.value = dataSource.value.filter((item) =>
|
size: pageSize.value,
|
||||||
item.labelName.toLowerCase().includes(searchKeyword.value.toLowerCase())
|
sort: 'id',
|
||||||
)
|
order: 'asc',
|
||||||
}
|
}
|
||||||
|
if (searchKeyword.value) params.keyword = searchKeyword.value
|
||||||
|
|
||||||
|
const res = await courseLabelApi.getPage(params)
|
||||||
|
dataSource.value = res.content
|
||||||
|
total.value = Number(res.totalElements)
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('加载数据失败')
|
ElMessage.error('加载数据失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -151,7 +171,16 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = () => fetchData()
|
const onPageChange = () => fetchData()
|
||||||
|
const onSizeChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
modalTitle.value = '新增标签'
|
modalTitle.value = '新增标签'
|
||||||
@@ -191,10 +220,15 @@ const handleModalOk = async () => {
|
|||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
try {
|
try {
|
||||||
await formRef.value.validate()
|
await formRef.value.validate()
|
||||||
|
// Convert RGBA to hex before submitting
|
||||||
|
const data = { ...formState }
|
||||||
|
if (data.color?.startsWith('rgba')) {
|
||||||
|
data.color = rgbaToHex(data.color)
|
||||||
|
}
|
||||||
if (formState.id) {
|
if (formState.id) {
|
||||||
await courseLabelApi.update(formState.id, formState)
|
await courseLabelApi.update(formState.id, data)
|
||||||
} else {
|
} else {
|
||||||
await courseLabelApi.create(formState)
|
await courseLabelApi.create(data)
|
||||||
}
|
}
|
||||||
ElMessage.success('操作成功')
|
ElMessage.success('操作成功')
|
||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
@@ -206,6 +240,19 @@ const handleModalOk = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Convert rgba(r, g, b, a) to #rrggbb or #rrggbbaa hex */
|
||||||
|
const rgbaToHex = (rgba: string): string => {
|
||||||
|
const match = rgba.match(/[\d.]+/g)
|
||||||
|
if (!match || match.length < 3) return rgba
|
||||||
|
const r = parseInt(match[0])
|
||||||
|
const g = parseInt(match[1])
|
||||||
|
const b = parseInt(match[2])
|
||||||
|
const a = match.length >= 4 ? Math.round(parseFloat(match[3]) * 255) : 255
|
||||||
|
const toHex = (n: number) => n.toString(16).padStart(2, '0')
|
||||||
|
const hex = `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||||
|
return a < 255 ? `${hex}${toHex(a)}` : hex
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => fetchData())
|
onMounted(() => fetchData())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,20 +4,28 @@
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<span>团课推荐管理</span>
|
<span>团课推荐管理</span>
|
||||||
<el-button type="primary" @click="handleAdd">
|
<div class="header-actions">
|
||||||
新增推荐
|
<el-button size="small" @click="resetSort">恢复默认排序</el-button>
|
||||||
</el-button>
|
<el-button type="primary" @click="handleAdd">新增推荐</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-table
|
<el-table
|
||||||
|
ref="tableRef"
|
||||||
v-loading="loading"
|
v-loading="loading"
|
||||||
:data="dataSource"
|
:data="sortedData"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
|
@sort-change="onSortChange"
|
||||||
>
|
>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="100" sortable="custom" />
|
||||||
<el-table-column prop="courseId" label="团课ID" width="100" />
|
<el-table-column prop="courseName" label="团课名称" min-width="160" show-overflow-tooltip sortable="custom">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ courseMap[row.courseId] || `课程 #${row.courseId}` }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="recommendTitle" label="推荐标题" min-width="160" show-overflow-tooltip />
|
<el-table-column prop="recommendTitle" label="推荐标题" min-width="160" show-overflow-tooltip />
|
||||||
<el-table-column label="优先级" width="100" sortable="custom">
|
<el-table-column prop="priority" label="优先级" width="100" sortable="custom">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<el-tag
|
||||||
:type="row.priority >= 5 ? 'danger' : row.priority >= 3 ? 'warning' : 'info'"
|
:type="row.priority >= 5 ? 'danger' : row.priority >= 3 ? 'warning' : 'info'"
|
||||||
@@ -40,6 +48,11 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="recommendReason" label="推荐理由" min-width="180" show-overflow-tooltip />
|
<el-table-column prop="recommendReason" label="推荐理由" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" width="170" sortable="custom">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatTime(row.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="280" fixed="right">
|
<el-table-column label="操作" width="280" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||||
@@ -82,8 +95,20 @@
|
|||||||
:rules="formRules"
|
:rules="formRules"
|
||||||
label-width="100px"
|
label-width="100px"
|
||||||
>
|
>
|
||||||
<el-form-item label="关联团课ID" prop="courseId">
|
<el-form-item label="关联团课" prop="courseId">
|
||||||
<el-input-number v-model="formState.courseId" :min="1" style="width: 100%" placeholder="请输入团课ID" />
|
<el-select
|
||||||
|
v-model="formState.courseId"
|
||||||
|
filterable
|
||||||
|
placeholder="请选择团课"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="course in courseList"
|
||||||
|
:key="course.id"
|
||||||
|
:value="course.id!"
|
||||||
|
:label="course.courseName"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="推荐标题" prop="recommendTitle">
|
<el-form-item label="推荐标题" prop="recommendTitle">
|
||||||
<el-input v-model="formState.recommendTitle" placeholder="请输入推荐标题" />
|
<el-input v-model="formState.recommendTitle" placeholder="请输入推荐标题" />
|
||||||
@@ -105,7 +130,7 @@
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="优先级" prop="priority">
|
<el-form-item label="优先级" prop="priority">
|
||||||
<el-input-number v-model="formState.priority" :min="0" :max="999" style="width: 100%" />
|
<el-input-number v-model="formState.priority" :min="0" :max="20" style="width: 100%" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
@@ -117,12 +142,55 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, computed, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { groupCourseRecommendApi, type GroupCourseRecommend } from '@/api/groupCourse.api'
|
import { groupCourseRecommendApi, groupCourseApi, type GroupCourseRecommend, type GroupCourse } from '@/api/groupCourse.api'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dataSource = ref<GroupCourseRecommend[]>([])
|
const dataSource = ref<GroupCourseRecommend[]>([])
|
||||||
|
const courseMap = ref<Record<number, string>>({})
|
||||||
|
const courseList = ref<GroupCourse[]>([])
|
||||||
|
const sortState = ref<{ prop: string; order: 'ascending' | 'descending' | null }>({ prop: 'priority', order: 'descending' })
|
||||||
|
|
||||||
|
const sortedData = computed(() => {
|
||||||
|
const list = [...dataSource.value]
|
||||||
|
const { prop, order } = sortState.value
|
||||||
|
if (!order) return list
|
||||||
|
const dir = order === 'ascending' ? 1 : -1
|
||||||
|
const getName = (courseId: number) => courseMap.value[courseId] || ''
|
||||||
|
switch (prop) {
|
||||||
|
case 'id':
|
||||||
|
return list.sort((a, b) => ((a.id || 0) - (b.id || 0)) * dir)
|
||||||
|
case 'priority':
|
||||||
|
return list.sort((a, b) => ((a.priority || 0) - (b.priority || 0)) * dir)
|
||||||
|
case 'courseName':
|
||||||
|
return list.sort((a, b) => getName(a.courseId).localeCompare(getName(b.courseId), 'zh') * dir)
|
||||||
|
case 'createdAt':
|
||||||
|
return list.sort((a, b) => (a.createdAt || '').localeCompare(b.createdAt || '') * dir)
|
||||||
|
default:
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSortChange = ({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) => {
|
||||||
|
sortState.value = { prop, order }
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableRef = ref()
|
||||||
|
const resetSort = () => {
|
||||||
|
tableRef.value?.clearSort()
|
||||||
|
sortState.value = { prop: 'priority', order: 'descending' }
|
||||||
|
// Re-apply default sort arrows on the table
|
||||||
|
tableRef.value?.sort('priority', 'descending')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (time?: string) => {
|
||||||
|
if (!time) return '-'
|
||||||
|
return new Date(time).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const modalTitle = ref('')
|
const modalTitle = ref('')
|
||||||
@@ -137,7 +205,7 @@ const formState = reactive<GroupCourseRecommend>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
courseId: [{ required: true, message: '请输入团课ID', trigger: 'blur' }],
|
courseId: [{ required: true, message: '请选择团课', trigger: 'change' }],
|
||||||
recommendTitle: [
|
recommendTitle: [
|
||||||
{ required: true, message: '请输入推荐标题', trigger: 'blur' },
|
{ required: true, message: '请输入推荐标题', trigger: 'blur' },
|
||||||
{ max: 200, message: '标题长度不能超过200个字符', trigger: 'blur' },
|
{ max: 200, message: '标题长度不能超过200个字符', trigger: 'blur' },
|
||||||
@@ -148,7 +216,18 @@ const formRules = {
|
|||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
dataSource.value = await groupCourseRecommendApi.getAll()
|
const [recommends, courses] = await Promise.all([
|
||||||
|
groupCourseRecommendApi.getAll(),
|
||||||
|
groupCourseApi.getAll(),
|
||||||
|
])
|
||||||
|
dataSource.value = recommends
|
||||||
|
courseList.value = courses
|
||||||
|
// Build courseId -> courseName map
|
||||||
|
const map: Record<number, string> = {}
|
||||||
|
courses.forEach((c) => {
|
||||||
|
if (c.id != null) map[c.id] = c.courseName
|
||||||
|
})
|
||||||
|
courseMap.value = map
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('加载数据失败')
|
ElMessage.error('加载数据失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -243,4 +322,10 @@ onMounted(() => fetchData())
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.course-recommend-management .header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,17 +44,11 @@
|
|||||||
:data="dataSource"
|
:data="dataSource"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
|
||||||
<el-table-column prop="typeName" label="类型名称" min-width="180" />
|
<el-table-column prop="typeName" label="类型名称" min-width="180" />
|
||||||
<el-table-column label="基础难度" width="100">
|
<el-table-column label="基础难度" width="100">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-rate
|
{{ difficultyLabel(row.baseDifficulty) }}
|
||||||
v-model="row.baseDifficulty"
|
|
||||||
disabled
|
|
||||||
show-score
|
|
||||||
:max="10"
|
|
||||||
text-color="#ff9900"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="category" label="分类" width="140">
|
<el-table-column prop="category" label="分类" width="140">
|
||||||
@@ -64,6 +58,23 @@
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="标签" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<template v-if="typeLabelsMap[row.id!]?.length">
|
||||||
|
<el-tag
|
||||||
|
v-for="label in typeLabelsMap[row.id!]"
|
||||||
|
:key="label.id"
|
||||||
|
:color="label.color"
|
||||||
|
effect="dark"
|
||||||
|
size="small"
|
||||||
|
style="margin-right: 4px; border-color: transparent;"
|
||||||
|
>
|
||||||
|
{{ label.labelName }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
<span v-else style="color: #999;">-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
|
||||||
<el-table-column label="操作" width="150" fixed="right">
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -76,12 +87,23 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
:total="total"
|
||||||
|
:page-sizes="[10, 20, 50]"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
background
|
||||||
|
style="margin-top: 16px; justify-content: flex-end"
|
||||||
|
@size-change="onSizeChange"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="modalVisible"
|
v-model="modalVisible"
|
||||||
:title="modalTitle"
|
:title="modalTitle"
|
||||||
width="500px"
|
width="650px"
|
||||||
>
|
>
|
||||||
<el-form
|
<el-form
|
||||||
ref="formRef"
|
ref="formRef"
|
||||||
@@ -119,6 +141,50 @@
|
|||||||
placeholder="请输入类型描述"
|
placeholder="请输入类型描述"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="formState.id" label="类型标签">
|
||||||
|
<div class="label-select-area">
|
||||||
|
<div class="label-section-title">
|
||||||
|
已选标签({{ selectedLabelIds.length }}/5)
|
||||||
|
</div>
|
||||||
|
<div class="label-tags-row">
|
||||||
|
<el-tag
|
||||||
|
v-for="id in selectedLabelIds"
|
||||||
|
:key="id"
|
||||||
|
:color="getLabelById(id)?.color"
|
||||||
|
effect="dark"
|
||||||
|
size="default"
|
||||||
|
closable
|
||||||
|
style="border-color: transparent; margin: 0 6px 6px 0;"
|
||||||
|
@close="toggleLabel(id)"
|
||||||
|
>
|
||||||
|
{{ getLabelById(id)?.labelName }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="selectedLabelIds.length === 0" style="color: #999;">点击下方标签选择</span>
|
||||||
|
</div>
|
||||||
|
<div class="label-section-title">
|
||||||
|
可选标签
|
||||||
|
</div>
|
||||||
|
<div class="label-tags-row">
|
||||||
|
<el-tag
|
||||||
|
v-for="label in allLabels"
|
||||||
|
:key="label.id"
|
||||||
|
:color="label.color"
|
||||||
|
:effect="selectedLabelIds.includes(label.id!) ? 'dark' : 'plain'"
|
||||||
|
:class="{
|
||||||
|
'label-tag': true,
|
||||||
|
'label-tag--selected': selectedLabelIds.includes(label.id!),
|
||||||
|
'label-tag--disabled': !selectedLabelIds.includes(label.id!) && selectedLabelIds.length >= 5,
|
||||||
|
}"
|
||||||
|
size="default"
|
||||||
|
style="cursor: pointer; margin: 0 6px 6px 0; user-select: none;"
|
||||||
|
@click="toggleLabel(label.id!)"
|
||||||
|
>
|
||||||
|
{{ label.labelName }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-if="allLabels.length === 0" style="color: #999;">暂无可用标签</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="modalVisible = false">取消</el-button>
|
<el-button @click="modalVisible = false">取消</el-button>
|
||||||
@@ -132,7 +198,7 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search } from '@element-plus/icons-vue'
|
||||||
import { groupCourseTypeApi, type GroupCourseType } from '@/api/groupCourse.api'
|
import { groupCourseTypeApi, courseLabelApi, type GroupCourseType, type CourseLabel, type GroupCourseTypePageRequest } from '@/api/groupCourse.api'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const dataSource = ref<GroupCourseType[]>([])
|
const dataSource = ref<GroupCourseType[]>([])
|
||||||
@@ -140,6 +206,14 @@ const searchKeyword = ref('')
|
|||||||
const searchCategory = ref('')
|
const searchCategory = ref('')
|
||||||
const categories = ref<string[]>([])
|
const categories = ref<string[]>([])
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(10)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const allLabels = ref<CourseLabel[]>([])
|
||||||
|
const selectedLabelIds = ref<number[]>([])
|
||||||
|
const typeLabelsMap = ref<Record<number, CourseLabel[]>>({})
|
||||||
|
|
||||||
const modalVisible = ref(false)
|
const modalVisible = ref(false)
|
||||||
const modalTitle = ref('')
|
const modalTitle = ref('')
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
@@ -160,6 +234,15 @@ const formRules = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const difficultyLabel = (val?: number) => {
|
||||||
|
if (!val) return '-'
|
||||||
|
if (val <= 2) return '入门'
|
||||||
|
if (val <= 4) return '初级'
|
||||||
|
if (val <= 6) return '中级'
|
||||||
|
if (val <= 8) return '进阶'
|
||||||
|
return '高级'
|
||||||
|
}
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = async () => {
|
||||||
try {
|
try {
|
||||||
categories.value = await groupCourseTypeApi.getCategories()
|
categories.value = await groupCourseTypeApi.getCategories()
|
||||||
@@ -168,19 +251,51 @@ const fetchCategories = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchAllLabels = async () => {
|
||||||
|
try {
|
||||||
|
allLabels.value = await courseLabelApi.getAll()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleLabel = (id: number) => {
|
||||||
|
const idx = selectedLabelIds.value.indexOf(id)
|
||||||
|
if (idx >= 0) {
|
||||||
|
selectedLabelIds.value.splice(idx, 1)
|
||||||
|
} else if (selectedLabelIds.value.length < 5) {
|
||||||
|
selectedLabelIds.value.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLabelById = (id: number): CourseLabel | undefined =>
|
||||||
|
allLabels.value.find(l => l.id === id)
|
||||||
|
|
||||||
|
const fetchTypeLabels = async (typeId: number) => {
|
||||||
|
try {
|
||||||
|
const labels = await courseLabelApi.getByTypeId(typeId)
|
||||||
|
selectedLabelIds.value = (labels || []).map(l => l.id!).filter(Boolean) as number[]
|
||||||
|
} catch {
|
||||||
|
selectedLabelIds.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (searchCategory.value) {
|
const params: GroupCourseTypePageRequest = {
|
||||||
dataSource.value = await groupCourseTypeApi.getByCategory(searchCategory.value)
|
page: currentPage.value - 1,
|
||||||
} else {
|
size: pageSize.value,
|
||||||
dataSource.value = await groupCourseTypeApi.getAll()
|
sort: 'id',
|
||||||
}
|
order: 'asc',
|
||||||
if (searchKeyword.value) {
|
|
||||||
dataSource.value = dataSource.value.filter((item) =>
|
|
||||||
item.typeName.toLowerCase().includes(searchKeyword.value.toLowerCase())
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
if (searchKeyword.value) params.keyword = searchKeyword.value
|
||||||
|
if (searchCategory.value) params.category = searchCategory.value
|
||||||
|
|
||||||
|
const res = await groupCourseTypeApi.getPage(params)
|
||||||
|
dataSource.value = res.content
|
||||||
|
total.value = Number(res.totalElements)
|
||||||
|
loadTypeLabels()
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('加载数据失败')
|
ElMessage.error('加载数据失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -188,7 +303,31 @@ const fetchData = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSearch = () => fetchData()
|
const onPageChange = () => fetchData()
|
||||||
|
const onSizeChange = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadTypeLabels = async () => {
|
||||||
|
const map: Record<number, CourseLabel[]> = {}
|
||||||
|
const promises = dataSource.value.map(async (type) => {
|
||||||
|
if (!type.id) return
|
||||||
|
try {
|
||||||
|
const labels = await courseLabelApi.getByTypeId(type.id)
|
||||||
|
map[type.id] = labels || []
|
||||||
|
} catch {
|
||||||
|
map[type.id] = []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await Promise.all(promises)
|
||||||
|
typeLabelsMap.value = map
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
modalTitle.value = '新增类型'
|
modalTitle.value = '新增类型'
|
||||||
@@ -199,6 +338,7 @@ const handleAdd = () => {
|
|||||||
description: '',
|
description: '',
|
||||||
category: '',
|
category: '',
|
||||||
})
|
})
|
||||||
|
selectedLabelIds.value = []
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,6 +346,9 @@ const handleEdit = (row: GroupCourseType) => {
|
|||||||
modalTitle.value = '编辑类型'
|
modalTitle.value = '编辑类型'
|
||||||
Object.assign(formState, { ...row })
|
Object.assign(formState, { ...row })
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
|
// Load labels after dialog opens
|
||||||
|
fetchAllLabels()
|
||||||
|
if (row.id) fetchTypeLabels(row.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDelete = async (row: GroupCourseType) => {
|
const handleDelete = async (row: GroupCourseType) => {
|
||||||
@@ -218,9 +361,10 @@ const handleDelete = async (row: GroupCourseType) => {
|
|||||||
await groupCourseTypeApi.delete(row.id!)
|
await groupCourseTypeApi.delete(row.id!)
|
||||||
ElMessage.success('删除成功')
|
ElMessage.success('删除成功')
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error !== 'cancel') {
|
if (error !== 'cancel') {
|
||||||
ElMessage.error('删除失败')
|
const msg = error?.response?.data?.message || error?.message || '删除失败'
|
||||||
|
ElMessage.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -231,6 +375,11 @@ const handleModalOk = async () => {
|
|||||||
await formRef.value.validate()
|
await formRef.value.validate()
|
||||||
if (formState.id) {
|
if (formState.id) {
|
||||||
await groupCourseTypeApi.update(formState.id, formState)
|
await groupCourseTypeApi.update(formState.id, formState)
|
||||||
|
// Sync labels: clear all then add selected ones
|
||||||
|
await courseLabelApi.clearLabelsFromType(formState.id)
|
||||||
|
if (selectedLabelIds.value.length > 0) {
|
||||||
|
await courseLabelApi.addLabelsToType(formState.id, selectedLabelIds.value)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await groupCourseTypeApi.create(formState)
|
await groupCourseTypeApi.create(formState)
|
||||||
}
|
}
|
||||||
@@ -238,9 +387,10 @@ const handleModalOk = async () => {
|
|||||||
modalVisible.value = false
|
modalVisible.value = false
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
fetchData()
|
fetchData()
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
if (error !== 'cancel') {
|
if (error !== 'cancel') {
|
||||||
ElMessage.error('操作失败')
|
const msg = error?.response?.data?.message || error?.message || '操作失败'
|
||||||
|
ElMessage.error(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -265,5 +415,41 @@ onMounted(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.label-select-area {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-section-title {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-tags-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-tag {
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-tag--selected {
|
||||||
|
border-color: #fff !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.15);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-tag--disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed !important;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -45,8 +45,25 @@
|
|||||||
@sort-change="handleSortChange"
|
@sort-change="handleSortChange"
|
||||||
>
|
>
|
||||||
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
|
<el-table-column prop="id" label="ID" width="80" sortable="custom" />
|
||||||
|
<el-table-column label="封面" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="cover-cell">
|
||||||
|
<img
|
||||||
|
v-if="coverCache[row.coverImage]"
|
||||||
|
:src="coverCache[row.coverImage]"
|
||||||
|
class="cover-thumb"
|
||||||
|
@error="onCoverError(row)"
|
||||||
|
/>
|
||||||
|
<span v-else class="cover-no-data">No data</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column prop="courseName" label="课程名称" min-width="140" />
|
<el-table-column prop="courseName" label="课程名称" min-width="140" />
|
||||||
<el-table-column prop="courseType" label="课程类型" width="100" />
|
<el-table-column label="课程类型" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ getCourseTypeName(row.courseType) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="状态" width="90">
|
<el-table-column label="状态" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag
|
<el-tag
|
||||||
@@ -115,7 +132,7 @@
|
|||||||
ref="formRef"
|
ref="formRef"
|
||||||
:model="formState"
|
:model="formState"
|
||||||
:rules="formRules"
|
:rules="formRules"
|
||||||
label-width="100px"
|
label-width="120px"
|
||||||
>
|
>
|
||||||
<el-form-item label="课程名称" prop="courseName">
|
<el-form-item label="课程名称" prop="courseName">
|
||||||
<el-input v-model="formState.courseName" placeholder="请输入课程名称" />
|
<el-input v-model="formState.courseName" placeholder="请输入课程名称" />
|
||||||
@@ -135,16 +152,20 @@
|
|||||||
v-model="formState.startTime"
|
v-model="formState.startTime"
|
||||||
type="datetime"
|
type="datetime"
|
||||||
placeholder="选择开始时间"
|
placeholder="选择开始时间"
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
value-format="YYYY-MM-DDTHH:mm:ss"
|
||||||
|
:disabled-date="notBeforeToday"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="结束时间" prop="endTime">
|
<el-form-item label="结束时间" prop="endTime" v-if="false">
|
||||||
<el-date-picker
|
<span />
|
||||||
v-model="formState.endTime"
|
</el-form-item>
|
||||||
type="datetime"
|
<el-form-item label="持续时间(分钟)" prop="duration">
|
||||||
placeholder="选择结束时间"
|
<el-input-number
|
||||||
value-format="YYYY-MM-DD HH:mm:ss"
|
v-model="formState.duration"
|
||||||
|
:min="1"
|
||||||
|
:max="1440"
|
||||||
|
placeholder="请输入课程持续时间"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -155,7 +176,22 @@
|
|||||||
<el-input v-model="formState.location" placeholder="请输入上课地点" />
|
<el-input v-model="formState.location" placeholder="请输入上课地点" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="封面图片">
|
<el-form-item label="封面图片">
|
||||||
<el-input v-model="formState.coverImage" placeholder="请输入封面图片URL" />
|
<el-upload
|
||||||
|
class="cover-upload"
|
||||||
|
:http-request="uploadCover"
|
||||||
|
:before-upload="beforeCoverUpload"
|
||||||
|
:show-file-list="false"
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
<img v-if="coverPreviewSrc" :src="coverPreviewSrc" class="cover-preview" />
|
||||||
|
<el-icon v-else class="cover-upload-icon"><Plus /></el-icon>
|
||||||
|
</el-upload>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="formState.id" label="课程状态" prop="status">
|
||||||
|
<el-select v-model="formState.status" placeholder="请选择状态" style="width: 100%">
|
||||||
|
<el-option value="0" label="正常" />
|
||||||
|
<el-option value="1" label="已取消" />
|
||||||
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="储值卡额度">
|
<el-form-item label="储值卡额度">
|
||||||
<el-input-number v-model="formState.storedValueAmount" :min="0" :precision="2" style="width: 100%" />
|
<el-input-number v-model="formState.storedValueAmount" :min="0" :precision="2" style="width: 100%" />
|
||||||
@@ -178,10 +214,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, watch, onMounted } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { Search } from '@element-plus/icons-vue'
|
import { Search, Plus } from '@element-plus/icons-vue'
|
||||||
import { groupCourseApi, groupCourseTypeApi, type GroupCourse, type GroupCourseType } from '@/api/groupCourse.api'
|
import { groupCourseApi, groupCourseTypeApi, type GroupCourse, type GroupCourseType } from '@/api/groupCourse.api'
|
||||||
|
import request from '@/utils/request'
|
||||||
import { formatDateTime } from '@/utils/dateFormat'
|
import { formatDateTime } from '@/utils/dateFormat'
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
@@ -222,18 +259,49 @@ const formState = reactive<GroupCourse>({
|
|||||||
coverImage: '',
|
coverImage: '',
|
||||||
description: '',
|
description: '',
|
||||||
storedValueAmount: 0,
|
storedValueAmount: 0,
|
||||||
|
status: '0',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const duration = ref(60)
|
||||||
|
// @ts-expect-error duration is a frontend-only field
|
||||||
|
formState.duration = duration
|
||||||
|
|
||||||
const formRules = {
|
const formRules = {
|
||||||
courseName: [
|
courseName: [
|
||||||
{ required: true, message: '请输入课程名称', trigger: 'blur' },
|
{ required: true, message: '请输入课程名称', trigger: 'blur' },
|
||||||
{ min: 2, max: 100, message: '课程名称长度在 2 到 100 个字符', trigger: 'blur' },
|
{ min: 2, max: 100, message: '课程名称长度在 2 到 100 个字符', trigger: 'blur' },
|
||||||
],
|
],
|
||||||
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
startTime: [{ required: true, message: '请选择开始时间', trigger: 'change' }],
|
||||||
endTime: [{ required: true, message: '请选择结束时间', trigger: 'change' }],
|
duration: [{ required: true, message: '请输入课程持续时间', trigger: 'blur' }],
|
||||||
maxMembers: [{ required: true, message: '请输入最大人数', trigger: 'blur' }],
|
maxMembers: [{ required: true, message: '请输入最大人数', trigger: 'blur' }],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const coverCache = reactive<Record<string, string>>({})
|
||||||
|
|
||||||
|
const loadRowCover = async (coverImage: string | undefined) => {
|
||||||
|
if (!coverImage) return
|
||||||
|
// already loaded
|
||||||
|
if (coverCache[coverImage]) return
|
||||||
|
// if it's a numeric file ID, fetch via preview API
|
||||||
|
if (/^\d+$/.test(coverImage)) {
|
||||||
|
try {
|
||||||
|
const blob: any = await request.get(`/files/${coverImage}/preview`, { responseType: 'blob' })
|
||||||
|
coverCache[coverImage] = URL.createObjectURL(blob)
|
||||||
|
} catch {
|
||||||
|
// leave empty, template will show "No data"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// legacy URL — use directly, but may fail at load time
|
||||||
|
coverCache[coverImage] = coverImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onCoverError = (row: GroupCourse) => {
|
||||||
|
if (row.coverImage) {
|
||||||
|
delete coverCache[row.coverImage]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fetchCourseTypes = async () => {
|
const fetchCourseTypes = async () => {
|
||||||
try {
|
try {
|
||||||
courseTypes.value = await groupCourseTypeApi.getAll()
|
courseTypes.value = await groupCourseTypeApi.getAll()
|
||||||
@@ -242,6 +310,12 @@ const fetchCourseTypes = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getCourseTypeName = (typeId: number | undefined): string => {
|
||||||
|
if (typeId === undefined || typeId === null) return '-'
|
||||||
|
const type = courseTypes.value.find(t => t.id === typeId)
|
||||||
|
return type?.typeName || String(typeId)
|
||||||
|
}
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -255,6 +329,8 @@ const fetchData = async () => {
|
|||||||
})
|
})
|
||||||
dataSource.value = res.content
|
dataSource.value = res.content
|
||||||
pagination.total = Number(res.totalElements) || 0
|
pagination.total = Number(res.totalElements) || 0
|
||||||
|
// load cover images for all rows
|
||||||
|
res.content.forEach((row: GroupCourse) => loadRowCover(row.coverImage))
|
||||||
} catch {
|
} catch {
|
||||||
ElMessage.error('加载数据失败')
|
ElMessage.error('加载数据失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -278,6 +354,71 @@ const handleSortChange = ({ prop, order }: any) => {
|
|||||||
fetchData()
|
fetchData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const beforeCoverUpload = (file: File) => {
|
||||||
|
const isImage = file.type.startsWith('image/')
|
||||||
|
if (!isImage) {
|
||||||
|
ElMessage.error('只能上传图片文件')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const isLt5M = file.size / 1024 / 1024 < 5
|
||||||
|
if (!isLt5M) {
|
||||||
|
ElMessage.error('图片大小不能超过 5MB')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const notBeforeToday = (date: Date) => {
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
return date.getTime() < today.getTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadCover = async (options: any) => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', options.file)
|
||||||
|
try {
|
||||||
|
const res: any = await request.post('/files/upload', formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
})
|
||||||
|
// res is the SysFile object (axios interceptor unwrapped response.data)
|
||||||
|
if (res && res.id) {
|
||||||
|
formState.coverImage = String(res.id)
|
||||||
|
// immediately fetch preview for the newly uploaded file
|
||||||
|
loadCoverPreview(String(res.id))
|
||||||
|
}
|
||||||
|
ElMessage.success('封面上传成功')
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('封面上传失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverPreviewSrc = ref('')
|
||||||
|
|
||||||
|
const loadCoverPreview = async (val: string) => {
|
||||||
|
if (!val) {
|
||||||
|
coverPreviewSrc.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// If it looks like a numeric file ID, fetch via preview API
|
||||||
|
if (/^\d+$/.test(val)) {
|
||||||
|
try {
|
||||||
|
const blob: any = await request.get(`/files/${val}/preview`, { responseType: 'blob' })
|
||||||
|
const oldUrl = coverPreviewSrc.value
|
||||||
|
if (oldUrl) URL.revokeObjectURL(oldUrl)
|
||||||
|
coverPreviewSrc.value = URL.createObjectURL(blob)
|
||||||
|
} catch {
|
||||||
|
coverPreviewSrc.value = ''
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy: direct URL
|
||||||
|
coverPreviewSrc.value = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch coverImage changes to auto-load preview (e.g. when editing an existing course)
|
||||||
|
watch(() => formState.coverImage, (val) => loadCoverPreview(val || ''), { immediate: true })
|
||||||
|
|
||||||
const handleAdd = () => {
|
const handleAdd = () => {
|
||||||
modalTitle.value = '新增团课'
|
modalTitle.value = '新增团课'
|
||||||
Object.assign(formState, {
|
Object.assign(formState, {
|
||||||
@@ -292,12 +433,19 @@ const handleAdd = () => {
|
|||||||
description: '',
|
description: '',
|
||||||
storedValueAmount: 0,
|
storedValueAmount: 0,
|
||||||
})
|
})
|
||||||
|
formState.duration = 60
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEdit = (row: GroupCourse) => {
|
const handleEdit = (row: GroupCourse) => {
|
||||||
modalTitle.value = '编辑团课'
|
modalTitle.value = '编辑团课'
|
||||||
Object.assign(formState, { ...row })
|
Object.assign(formState, { ...row })
|
||||||
|
// Calculate duration from startTime and endTime
|
||||||
|
if (row.startTime && row.endTime) {
|
||||||
|
const start = new Date(row.startTime)
|
||||||
|
const end = new Date(row.endTime)
|
||||||
|
formState.duration = Math.max(1, Math.round((end.getTime() - start.getTime()) / 60000))
|
||||||
|
}
|
||||||
modalVisible.value = true
|
modalVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +487,13 @@ const handleModalOk = async () => {
|
|||||||
if (!formRef.value) return
|
if (!formRef.value) return
|
||||||
try {
|
try {
|
||||||
await formRef.value.validate()
|
await formRef.value.validate()
|
||||||
|
// Auto-calculate endTime from startTime + duration
|
||||||
|
if (formState.startTime && formState.duration) {
|
||||||
|
const start = new Date(formState.startTime)
|
||||||
|
start.setMinutes(start.getMinutes() + formState.duration)
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
formState.endTime = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}:${pad(start.getSeconds())}`
|
||||||
|
}
|
||||||
if (formState.id) {
|
if (formState.id) {
|
||||||
await groupCourseApi.update(formState.id, formState)
|
await groupCourseApi.update(formState.id, formState)
|
||||||
} else {
|
} else {
|
||||||
@@ -375,4 +530,50 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cover-upload :deep(.el-upload) {
|
||||||
|
border: 1px dashed #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 180px;
|
||||||
|
height: 120px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload :deep(.el-upload:hover) {
|
||||||
|
border-color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-preview {
|
||||||
|
width: 180px;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-upload-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #8c939d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-thumb {
|
||||||
|
width: 64px;
|
||||||
|
height: 48px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-no-data {
|
||||||
|
color: #c0c4cc;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user