From 2f5f94f57693c8e056ee89c505bb5d784ecddc1d Mon Sep 17 00:00:00 2001
From: hejianhao <15708179461@qq.com>
Date: 星期四, 20 八月 2026 12:19:08 +0800
Subject: [PATCH] feat0806: 提交项目介绍后管功能
---
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/ProjectIntroductionAppService.java | 193 +++++++++
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionDetailVO.java | 25 +
tcm-training-admin/src/pages/live/project-introduction/service.ts | 52 ++
tcm-training-admin/src/pages/live/project-introduction/style.less | 231 +++++++++++
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionListItemVO.java | 21 +
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/ProjectIntroductionSaveRequest.java | 41 +
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java | 8
tcm-training-admin-backend/sql/20260806_admin.sql | 26 +
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/controller/ProjectIntroductionController.java | 64 +++
tcm-training-admin/src/pages/live/project-introduction/manage.tsx | 256 ++++++++++++
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/ProjectIntroductionMapper.java | 9
tcm-training-admin/src/pages/live/project-introduction/index.tsx | 253 ++++++++++++
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/ProjectIntroduction.java | 68 +++
13 files changed, 1,246 insertions(+), 1 deletions(-)
diff --git a/tcm-training-admin-backend/sql/20260806_admin.sql b/tcm-training-admin-backend/sql/20260806_admin.sql
index 44219cd..e564d1c 100644
--- a/tcm-training-admin-backend/sql/20260806_admin.sql
+++ b/tcm-training-admin-backend/sql/20260806_admin.sql
@@ -73,4 +73,28 @@
INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, icon, sort, status, visible, permission_key, remark, deleted)
VALUES (6001, 6000, '编辑微赞直播间设置', 'live_room_setting_edit', 'B', NULL, NULL, NULL, 1, 1, 1, 'live:room-setting:edit', '微赞直播间设置编辑权限', 0)
-ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), name = VALUES(name), menu_code = VALUES(menu_code), type = VALUES(type), sort = VALUES(sort), status = VALUES(status), visible = VALUES(visible), permission_key = VALUES(permission_key), remark = VALUES(remark), deleted = 0;
\ No newline at end of file
+ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), name = VALUES(name), menu_code = VALUES(menu_code), type = VALUES(type), sort = VALUES(sort), status = VALUES(status), visible = VALUES(visible), permission_key = VALUES(permission_key), remark = VALUES(remark), deleted = 0;
+
+-- 项目介绍菜单和权限配置
+-- 添加时间:2026-08-20
+-- 说明:为项目介绍功能添加菜单和按钮权限
+
+-- 项目介绍菜单(id: 6010)
+INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, icon, sort, status, visible, permission_key, remark, deleted)
+VALUES (6010, 3050, '项目介绍', 'live_project_introduction_manage', 'C', '/live/course/project-introduction', 'Live/ProjectIntroductionList', NULL, 60, 1, 1, 'live:project-introduction:view', '项目介绍菜单', 0)
+ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), name = VALUES(name), menu_code = VALUES(menu_code), type = VALUES(type), path = VALUES(path), component = VALUES(component), sort = VALUES(sort), status = VALUES(status), visible = VALUES(visible), permission_key = VALUES(permission_key), remark = VALUES(remark), deleted = 0;
+
+-- 新增项目介绍按钮(id: 6011)
+INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, icon, sort, status, visible, permission_key, remark, deleted)
+VALUES (6011, 6010, '新增项目介绍', 'live_project_introduction_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'live:project-introduction:add', '项目介绍新增按钮', 0)
+ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), name = VALUES(name), menu_code = VALUES(menu_code), type = VALUES(type), sort = VALUES(sort), status = VALUES(status), visible = VALUES(visible), permission_key = VALUES(permission_key), remark = VALUES(remark), deleted = 0;
+
+-- 编辑项目介绍按钮(id: 6012)
+INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, icon, sort, status, visible, permission_key, remark, deleted)
+VALUES (6012, 6010, '编辑项目介绍', 'live_project_introduction_edit', 'B', NULL, NULL, NULL, 2, 1, 1, 'live:project-introduction:edit', '项目介绍编辑按钮', 0)
+ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), name = VALUES(name), menu_code = VALUES(menu_code), type = VALUES(type), sort = VALUES(sort), status = VALUES(status), visible = VALUES(visible), permission_key = VALUES(permission_key), remark = VALUES(remark), deleted = 0;
+
+-- 删除项目介绍按钮(id: 6013)
+INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, icon, sort, status, visible, permission_key, remark, deleted)
+VALUES (6013, 6010, '删除项目介绍', 'live_project_introduction_delete', 'B', NULL, NULL, NULL, 3, 1, 1, 'live:project-introduction:delete', '项目介绍删除按钮', 0)
+ON DUPLICATE KEY UPDATE parent_id = VALUES(parent_id), name = VALUES(name), menu_code = VALUES(menu_code), type = VALUES(type), sort = VALUES(sort), status = VALUES(status), visible = VALUES(visible), permission_key = VALUES(permission_key), remark = VALUES(remark), deleted = 0;
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
index 54101d5..9068a69 100644
--- a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java
@@ -324,6 +324,14 @@
null, null, null, 2, "live:health-popularization:edit", "健康科普编辑按钮"));
menuIds.add(saveMenu(3123L, 3120L, "删除健康科普", "live_health_popularization_delete", "B",
null, null, null, 3, "live:health-popularization:delete", "健康科普删除按钮"));
+ menuIds.add(saveMenu(6010L, 3050L, "项目介绍", "live_project_introduction_manage", "C",
+ "/live/course/project-introduction", "Live/ProjectIntroductionList", null, 60, "live:project-introduction:view", "项目介绍菜单"));
+ menuIds.add(saveMenu(6011L, 6010L, "新增项目介绍", "live_project_introduction_add", "B",
+ null, null, null, 1, "live:project-introduction:add", "项目介绍新增按钮"));
+ menuIds.add(saveMenu(6012L, 6010L, "编辑项目介绍", "live_project_introduction_edit", "B",
+ null, null, null, 2, "live:project-introduction:edit", "项目介绍编辑按钮"));
+ menuIds.add(saveMenu(6013L, 6010L, "删除项目介绍", "live_project_introduction_delete", "B",
+ null, null, null, 3, "live:project-introduction:delete", "项目介绍删除按钮"));
menuIds.add(saveMenu(4000L, 0L, "交易与财务", "trade_root", "M",
"/trade", null, "moneyCollect", 400, null, "交易与财务模块"));
menuIds.add(saveMenu(4100L, 4000L, "订单", "trade_order_group", "M",
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/controller/ProjectIntroductionController.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/controller/ProjectIntroductionController.java
new file mode 100644
index 0000000..5be309f
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/controller/ProjectIntroductionController.java
@@ -0,0 +1,64 @@
+package com.tcm.training.admin.live.controller;
+
+import com.tcm.training.admin.common.ApiResponse;
+import com.tcm.training.admin.common.PageResponse;
+import com.tcm.training.admin.live.dto.ProjectIntroductionSaveRequest;
+import com.tcm.training.admin.live.service.ProjectIntroductionAppService;
+import com.tcm.training.admin.live.vo.ProjectIntroductionDetailVO;
+import com.tcm.training.admin.live.vo.ProjectIntroductionListItemVO;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.validation.Valid;
+
+@Validated
+@RestController
+@RequestMapping({"/api/v1", "/api"})
+public class ProjectIntroductionController {
+
+ private final ProjectIntroductionAppService projectIntroductionAppService;
+
+ public ProjectIntroductionController(ProjectIntroductionAppService projectIntroductionAppService) {
+ this.projectIntroductionAppService = projectIntroductionAppService;
+ }
+
+ @GetMapping("/project-introductions")
+ public ApiResponse<PageResponse<ProjectIntroductionListItemVO>> page(@RequestParam(required = false, defaultValue = "1") int page,
+ @RequestParam(required = false, defaultValue = "10") int pageSize,
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) String category,
+ @RequestParam(required = false) Integer contentType,
+ @RequestParam(required = false) Integer status) {
+ return ApiResponse.ok(projectIntroductionAppService.page(page, pageSize, keyword, category, contentType, status));
+ }
+
+ @GetMapping("/project-introductions/{id}")
+ public ApiResponse<ProjectIntroductionDetailVO> detail(@PathVariable("id") Long id) {
+ return ApiResponse.ok(projectIntroductionAppService.detail(id));
+ }
+
+ @PostMapping("/project-introductions")
+ public ApiResponse<ProjectIntroductionDetailVO> create(@Valid @RequestBody ProjectIntroductionSaveRequest req) {
+ return ApiResponse.ok(projectIntroductionAppService.create(req));
+ }
+
+ @PutMapping("/project-introductions/{id}")
+ public ApiResponse<ProjectIntroductionDetailVO> update(@PathVariable("id") Long id,
+ @Valid @RequestBody ProjectIntroductionSaveRequest req) {
+ return ApiResponse.ok(projectIntroductionAppService.update(id, req));
+ }
+
+ @DeleteMapping("/project-introductions/{id}")
+ public ApiResponse<Void> delete(@PathVariable("id") Long id) {
+ projectIntroductionAppService.delete(id);
+ return ApiResponse.ok(null);
+ }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/ProjectIntroductionSaveRequest.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/ProjectIntroductionSaveRequest.java
new file mode 100644
index 0000000..2955ca0
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/ProjectIntroductionSaveRequest.java
@@ -0,0 +1,41 @@
+package com.tcm.training.admin.live.dto;
+
+import com.tcm.training.admin.common.xss.XssSafeHtml;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Size;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class ProjectIntroductionSaveRequest {
+
+ @NotBlank(message = "请输入标题")
+ @Size(max = 128, message = "标题长度不能超过128个字符")
+ private String title;
+
+ @NotBlank(message = "请上传封面图")
+ private String coverImg;
+
+ @NotBlank(message = "请选择类别")
+ private String category;
+
+ @NotNull(message = "请选择类型")
+ private Integer contentType;
+
+ @XssSafeHtml
+ private String content;
+
+ @Size(max = 512, message = "视频地址长度不能超过512个字符")
+ private String videoUrl;
+
+ @Size(max = 255, message = "视频文件名长度不能超过255个字符")
+ private String videoFileName;
+
+ @NotNull(message = "请选择是否启用")
+ private Integer status;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/ProjectIntroduction.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/ProjectIntroduction.java
new file mode 100644
index 0000000..b6261f4
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/ProjectIntroduction.java
@@ -0,0 +1,68 @@
+package com.tcm.training.admin.live.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+@TableName("tcm_project_introduction")
+@Getter
+@Setter
+@NoArgsConstructor
+public class ProjectIntroduction {
+
+ public static final String CATEGORY_PLATFORM = "平台介绍";
+ public static final String CATEGORY_MARKETING_MODEL = "营销模式";
+ public static final String CATEGORY_MARKETING_ACTION = "营销动作";
+
+ public static final int CONTENT_TYPE_ARTICLE = 1;
+ public static final int CONTENT_TYPE_VIDEO = 2;
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private String title;
+
+ @TableField("cover_img")
+ private String coverImg;
+
+ private String category;
+
+ @TableField("content_type")
+ private Integer contentType;
+
+ private String content;
+
+ @TableField("video_url")
+ private String videoUrl;
+
+ @TableField("video_file_name")
+ private String videoFileName;
+
+ @TableField("click_count")
+ private Integer clickCount;
+
+ private Integer status;
+
+ @TableLogic
+ private Integer deleted;
+
+ @TableField(value = "created_by", fill = FieldFill.INSERT)
+ private String createdBy;
+
+ @TableField(value = "updated_by", fill = FieldFill.INSERT_UPDATE)
+ private String updatedBy;
+
+ @TableField("created_at")
+ private LocalDateTime createdAt;
+
+ @TableField("updated_at")
+ private LocalDateTime updatedAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/ProjectIntroductionMapper.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/ProjectIntroductionMapper.java
new file mode 100644
index 0000000..4ebec39
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/ProjectIntroductionMapper.java
@@ -0,0 +1,9 @@
+package com.tcm.training.admin.live.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.tcm.training.admin.live.entity.ProjectIntroduction;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ProjectIntroductionMapper extends BaseMapper<ProjectIntroduction> {
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/ProjectIntroductionAppService.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/ProjectIntroductionAppService.java
new file mode 100644
index 0000000..dae7abe
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/ProjectIntroductionAppService.java
@@ -0,0 +1,193 @@
+package com.tcm.training.admin.live.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.tcm.training.admin.common.PageResponse;
+import com.tcm.training.admin.live.dto.ProjectIntroductionSaveRequest;
+import com.tcm.training.admin.live.entity.ProjectIntroduction;
+import com.tcm.training.admin.live.mapper.ProjectIntroductionMapper;
+import com.tcm.training.admin.live.vo.ProjectIntroductionDetailVO;
+import com.tcm.training.admin.live.vo.ProjectIntroductionListItemVO;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+public class ProjectIntroductionAppService {
+
+ private final ProjectIntroductionMapper projectIntroductionMapper;
+
+ public ProjectIntroductionAppService(ProjectIntroductionMapper projectIntroductionMapper) {
+ this.projectIntroductionMapper = projectIntroductionMapper;
+ }
+
+ public PageResponse<ProjectIntroductionListItemVO> page(int page,
+ int pageSize,
+ String keyword,
+ String category,
+ Integer contentType,
+ Integer status) {
+ LambdaQueryWrapper<ProjectIntroduction> qw = new LambdaQueryWrapper<ProjectIntroduction>()
+ .eq(ProjectIntroduction::getDeleted, 0)
+ .orderByDesc(ProjectIntroduction::getId);
+
+ String trimmedKeyword = trimToNull(keyword);
+ if (trimmedKeyword != null) {
+ qw.like(ProjectIntroduction::getTitle, trimmedKeyword);
+ }
+ if (category != null && !category.trim().isEmpty()) {
+ qw.eq(ProjectIntroduction::getCategory, category.trim());
+ }
+ if (contentType != null) {
+ qw.eq(ProjectIntroduction::getContentType, contentType);
+ }
+ if (status != null) {
+ qw.eq(ProjectIntroduction::getStatus, status);
+ }
+
+ IPage<ProjectIntroduction> result = projectIntroductionMapper.selectPage(new Page<ProjectIntroduction>(page, pageSize), qw);
+ List<ProjectIntroductionListItemVO> list = new ArrayList<ProjectIntroductionListItemVO>();
+ for (ProjectIntroduction item : result.getRecords()) {
+ list.add(toListItem(item));
+ }
+ return PageResponse.of(list, result.getTotal(), result.getCurrent(), result.getSize());
+ }
+
+ public ProjectIntroductionDetailVO detail(Long id) {
+ return toDetailVO(requireItem(id));
+ }
+
+ @Transactional
+ public ProjectIntroductionDetailVO create(ProjectIntroductionSaveRequest req) {
+ ProjectIntroduction item = new ProjectIntroduction();
+ fillEntity(item, req);
+ item.setClickCount(0);
+ item.setDeleted(0);
+ projectIntroductionMapper.insert(item);
+ return detail(item.getId());
+ }
+
+ @Transactional
+ public ProjectIntroductionDetailVO update(Long id, ProjectIntroductionSaveRequest req) {
+ ProjectIntroduction item = requireItem(id);
+ fillEntity(item, req);
+ projectIntroductionMapper.updateById(item);
+ return detail(id);
+ }
+
+ @Transactional
+ public void delete(Long id) {
+ requireItem(id);
+ projectIntroductionMapper.deleteById(id);
+ }
+
+ private void fillEntity(ProjectIntroduction item, ProjectIntroductionSaveRequest req) {
+ int contentType = validateContentType(req.getContentType());
+ String category = validateCategory(req.getCategory());
+
+ item.setTitle(req.getTitle().trim());
+ item.setCoverImg(req.getCoverImg().trim());
+ item.setCategory(category);
+ item.setContentType(contentType);
+ item.setStatus(req.getStatus());
+
+ if (contentType == ProjectIntroduction.CONTENT_TYPE_ARTICLE) {
+ String content = trimToNull(req.getContent());
+ if (content == null) {
+ throw new IllegalArgumentException("请输入内容");
+ }
+ item.setContent(content);
+ item.setVideoUrl(null);
+ item.setVideoFileName(null);
+ return;
+ }
+
+ String videoUrl = trimToNull(req.getVideoUrl());
+ if (videoUrl == null) {
+ throw new IllegalArgumentException("请填写视频地址或上传本地视频");
+ }
+ item.setContent(null);
+ item.setVideoUrl(videoUrl);
+ item.setVideoFileName(trimToNull(req.getVideoFileName()));
+ }
+
+ private int validateContentType(Integer contentType) {
+ if (contentType == null) {
+ throw new IllegalArgumentException("请选择类型");
+ }
+ if (contentType != ProjectIntroduction.CONTENT_TYPE_ARTICLE
+ && contentType != ProjectIntroduction.CONTENT_TYPE_VIDEO) {
+ throw new IllegalArgumentException("类型不合法");
+ }
+ return contentType;
+ }
+
+ private String validateCategory(String category) {
+ if (category == null || category.trim().isEmpty()) {
+ throw new IllegalArgumentException("请选择类别");
+ }
+ String trimmed = category.trim();
+ if (!ProjectIntroduction.CATEGORY_PLATFORM.equals(trimmed)
+ && !ProjectIntroduction.CATEGORY_MARKETING_MODEL.equals(trimmed)
+ && !ProjectIntroduction.CATEGORY_MARKETING_ACTION.equals(trimmed)) {
+ throw new IllegalArgumentException("类别不合法");
+ }
+ return trimmed;
+ }
+
+ private ProjectIntroduction requireItem(Long id) {
+ ProjectIntroduction item = projectIntroductionMapper.selectOne(new LambdaQueryWrapper<ProjectIntroduction>()
+ .eq(ProjectIntroduction::getId, id)
+ .eq(ProjectIntroduction::getDeleted, 0)
+ .last("limit 1"));
+ if (item == null) {
+ throw new IllegalArgumentException("项目介绍不存在");
+ }
+ return item;
+ }
+
+ private ProjectIntroductionListItemVO toListItem(ProjectIntroduction item) {
+ ProjectIntroductionListItemVO vo = new ProjectIntroductionListItemVO();
+ vo.setId(item.getId());
+ vo.setTitle(item.getTitle());
+ vo.setCoverImg(item.getCoverImg());
+ vo.setCategory(item.getCategory());
+ vo.setContentType(item.getContentType());
+ vo.setStatus(item.getStatus());
+ vo.setClickCount(defaultNumber(item.getClickCount()));
+ vo.setCreatedAt(item.getCreatedAt());
+ return vo;
+ }
+
+ private ProjectIntroductionDetailVO toDetailVO(ProjectIntroduction item) {
+ ProjectIntroductionDetailVO vo = new ProjectIntroductionDetailVO();
+ vo.setId(item.getId());
+ vo.setTitle(item.getTitle());
+ vo.setCoverImg(item.getCoverImg());
+ vo.setCategory(item.getCategory());
+ vo.setContentType(item.getContentType());
+ vo.setContent(item.getContent());
+ vo.setVideoUrl(item.getVideoUrl());
+ vo.setVideoFileName(item.getVideoFileName());
+ vo.setStatus(item.getStatus());
+ vo.setClickCount(defaultNumber(item.getClickCount()));
+ vo.setCreatedAt(item.getCreatedAt());
+ vo.setUpdatedAt(item.getUpdatedAt());
+ return vo;
+ }
+
+ private Integer defaultNumber(Integer value) {
+ return value == null ? 0 : value;
+ }
+
+ private String trimToNull(String value) {
+ if (value == null) {
+ return null;
+ }
+ String trimmed = value.trim();
+ return trimmed.isEmpty() ? null : trimmed;
+ }
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionDetailVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionDetailVO.java
new file mode 100644
index 0000000..c9299e2
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionDetailVO.java
@@ -0,0 +1,25 @@
+package com.tcm.training.admin.live.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class ProjectIntroductionDetailVO {
+ private Long id;
+ private String title;
+ private String coverImg;
+ private String category;
+ private Integer contentType;
+ private String content;
+ private String videoUrl;
+ private String videoFileName;
+ private Integer status;
+ private Integer clickCount;
+ private LocalDateTime createdAt;
+ private LocalDateTime updatedAt;
+}
diff --git a/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionListItemVO.java b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionListItemVO.java
new file mode 100644
index 0000000..4bd8c9a
--- /dev/null
+++ b/tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionListItemVO.java
@@ -0,0 +1,21 @@
+package com.tcm.training.admin.live.vo;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.LocalDateTime;
+
+@Getter
+@Setter
+@NoArgsConstructor
+public class ProjectIntroductionListItemVO {
+ private Long id;
+ private String title;
+ private String coverImg;
+ private String category;
+ private Integer contentType;
+ private Integer status;
+ private Integer clickCount;
+ private LocalDateTime createdAt;
+}
diff --git a/tcm-training-admin/src/pages/live/project-introduction/index.tsx b/tcm-training-admin/src/pages/live/project-introduction/index.tsx
new file mode 100644
index 0000000..b7bab07
--- /dev/null
+++ b/tcm-training-admin/src/pages/live/project-introduction/index.tsx
@@ -0,0 +1,253 @@
+import {
+ deleteProjectIntroduction,
+ getProjectIntroductionPage,
+} from './service';
+import { history, useAccess } from '@umijs/max';
+import { Button, Form, Image, Input, Modal, Pagination, Select, Space, Table, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import React, { useEffect, useState } from 'react';
+import './style.less';
+
+type SearchValues = {
+ keyword?: string;
+ category?: string;
+ contentType?: number;
+ status?: number;
+};
+
+type ProjectIntroductionItem = {
+ id: number;
+ title: string;
+ coverImg?: string;
+ category: string;
+ contentType: number;
+ status: number;
+ clickCount?: number;
+ createdAt: string;
+};
+
+const categoryOptions = [
+ { label: '平台介绍', value: '平台介绍' },
+ { label: '营销模式', value: '营销模式' },
+ { label: '营销动作', value: '营销动作' },
+];
+
+const contentTypeOptions = [
+ { label: '图文', value: 1 },
+ { label: '视频', value: 2 },
+];
+
+const statusOptions = [
+ { label: '启用', value: 1 },
+ { label: '禁用', value: 0 },
+];
+
+const trimSearchValues = (values: SearchValues) => ({
+ keyword: values.keyword?.trim() || undefined,
+ category: values.category || undefined,
+ contentType: values.contentType,
+ status: values.status,
+});
+
+const contentTypeText = (value?: number) =>
+ contentTypeOptions.find((item) => item.value === value)?.label || '-';
+
+const statusText = (value?: number) => (value === 1 ? '启用' : '禁用');
+
+const ProjectIntroductionPage: React.FC = () => {
+ const access = useAccess();
+ const [searchForm] = Form.useForm<SearchValues>();
+ const [list, setList] = useState<ProjectIntroductionItem[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
+
+ const canView = access['live:project-introduction:view'];
+ const canAdd = access['live:project-introduction:add'];
+ const canEdit = access['live:project-introduction:edit'];
+ const canDelete = access['live:project-introduction:delete'];
+ const refreshKey = history.location.search;
+
+ const loadList = async (page = pagination.page, pageSize = pagination.pageSize) => {
+ setLoading(true);
+ try {
+ const res = await getProjectIntroductionPage({
+ page,
+ pageSize,
+ ...trimSearchValues(searchForm.getFieldsValue()),
+ });
+ setList(res.data.list || []);
+ setPagination({
+ page: Number(res.data.page || page),
+ pageSize: Number(res.data.pageSize || pageSize),
+ total: Number(res.data.total || 0),
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (!canView) {
+ history.replace('/Welcome');
+ return;
+ }
+ loadList(1, 10);
+ }, [canView, refreshKey]);
+
+ const handleDelete = (record: ProjectIntroductionItem) => {
+ Modal.confirm({
+ title: '确定要删除该项目介绍吗?',
+ okText: '删除',
+ cancelText: '取消',
+ onOk: async () => {
+ await deleteProjectIntroduction(record.id);
+ message.success('删除成功');
+ const nextPage = list.length === 1 && pagination.page > 1 ? pagination.page - 1 : pagination.page;
+ await loadList(nextPage, pagination.pageSize);
+ },
+ });
+ };
+
+ const columns: ColumnsType<ProjectIntroductionItem> = [
+ {
+ title: '序号',
+ width: 72,
+ render: (_, __, index) => (pagination.page - 1) * pagination.pageSize + index + 1,
+ },
+ {
+ title: '标题',
+ dataIndex: 'title',
+ width: 320,
+ render: (_, record) => (
+ <div className="project-introduction-table__title">
+ {record.coverImg ? (
+ <Image src={record.coverImg} width={64} height={40} preview={false} />
+ ) : (
+ <div className="project-introduction-table__cover-placeholder">
+ {record.title?.slice(0, 2) || '-'}
+ </div>
+ )}
+ <span>{record.title || '-'}</span>
+ </div>
+ ),
+ },
+ {
+ title: '类别',
+ dataIndex: 'category',
+ width: 120,
+ },
+ {
+ title: '类型',
+ dataIndex: 'contentType',
+ width: 100,
+ render: (value) => contentTypeText(value),
+ },
+ {
+ title: '点击量',
+ dataIndex: 'clickCount',
+ width: 120,
+ render: (value) => value || 0,
+ },
+ {
+ title: '状态',
+ dataIndex: 'status',
+ width: 100,
+ render: (value) => statusText(value),
+ },
+ {
+ title: '创建时间',
+ dataIndex: 'createdAt',
+ width: 180,
+ },
+ {
+ title: '操作',
+ width: 180,
+ render: (_, record) => (
+ <Space size={16} className="project-introduction-table__actions">
+ {canEdit ? (
+ <button
+ type="button"
+ onClick={() => history.push(`/live/course/project-introduction/${record.id}/edit`)}
+ >
+ 编辑
+ </button>
+ ) : null}
+ {canDelete ? (
+ <button type="button" onClick={() => handleDelete(record)}>
+ 删除
+ </button>
+ ) : null}
+ </Space>
+ ),
+ },
+ ];
+
+ return (
+ <div className="project-introduction-page">
+ <div className="project-introduction-page__panel">
+ <div className="project-introduction-page__title">项目介绍</div>
+ <div className="project-introduction-search-card">
+ <Form form={searchForm} layout="horizontal" className="project-introduction-search-form">
+ <Form.Item label="标题" name="keyword">
+ <Input placeholder="请输入" allowClear />
+ </Form.Item>
+ <Form.Item label="类别" name="category">
+ <Select placeholder="全部" allowClear options={categoryOptions} />
+ </Form.Item>
+ <Form.Item label="类型" name="contentType">
+ <Select placeholder="全部" allowClear options={contentTypeOptions} />
+ </Form.Item>
+ <Form.Item label="状态" name="status">
+ <Select placeholder="全部" allowClear options={statusOptions} />
+ </Form.Item>
+ <div className="project-introduction-search-form__actions">
+ <Button
+ onClick={async () => {
+ searchForm.resetFields();
+ await loadList(1, pagination.pageSize);
+ }}
+ >
+ 重置
+ </Button>
+ <Button type="primary" onClick={() => loadList(1, pagination.pageSize)}>
+ 搜索
+ </Button>
+ </div>
+ </Form>
+ </div>
+
+ <div className="project-introduction-toolbar">
+ {canAdd ? (
+ <Button type="primary" onClick={() => history.push('/live/course/project-introduction/create')}>
+ 添加
+ </Button>
+ ) : null}
+ </div>
+
+ <div className="project-introduction-table-card">
+ <Table<ProjectIntroductionItem>
+ rowKey="id"
+ columns={columns}
+ dataSource={list}
+ loading={loading}
+ pagination={false}
+ scroll={{ x: 1100 }}
+ />
+ <div className="project-introduction-pagination">
+ <Pagination
+ current={pagination.page}
+ pageSize={pagination.pageSize}
+ total={pagination.total}
+ showSizeChanger
+ showQuickJumper
+ showTotal={(total) => `共 ${total} 条`}
+ onChange={(page, pageSize) => loadList(page, pageSize)}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default ProjectIntroductionPage;
diff --git a/tcm-training-admin/src/pages/live/project-introduction/manage.tsx b/tcm-training-admin/src/pages/live/project-introduction/manage.tsx
new file mode 100644
index 0000000..62be956
--- /dev/null
+++ b/tcm-training-admin/src/pages/live/project-introduction/manage.tsx
@@ -0,0 +1,256 @@
+import {
+ createProjectIntroduction,
+ getProjectIntroductionDetail,
+ updateProjectIntroduction,
+ uploadProjectIntroductionImage,
+ uploadProjectIntroductionVideo,
+} from './service';
+import BraftEditorCom from '@/components/BraftEditor';
+import { PlusOutlined, UploadOutlined } from '@ant-design/icons';
+import { history, useAccess } from '@umijs/max';
+import { Button, Form, Image, Input, Radio, Select, Switch, Upload, message } from 'antd';
+import React, { useEffect, useMemo, useState } from 'react';
+import './style.less';
+
+const categoryOptions = [
+ { label: '平台介绍', value: '平台介绍' },
+ { label: '营销模式', value: '营销模式' },
+ { label: '营销动作', value: '营销动作' },
+];
+
+const contentTypeOptions = [
+ { label: '图文', value: 1 },
+ { label: '视频', value: 2 },
+];
+
+const ProjectIntroductionManagePage: React.FC = () => {
+ const access = useAccess();
+ const [form] = Form.useForm();
+ const [loading, setLoading] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [uploadingCover, setUploadingCover] = useState(false);
+ const [uploadingVideo, setUploadingVideo] = useState(false);
+ const [coverImg, setCoverImg] = useState('');
+ const pathname = history.location.pathname;
+ const isCreate = pathname.endsWith('/create');
+ const listPagePath = '/live/course/project-introduction';
+ const recordId = useMemo(() => {
+ if (isCreate) {
+ return '';
+ }
+ const segments = pathname.split('/');
+ return segments[segments.length - 2] || '';
+ }, [isCreate, pathname]);
+ const canAccess = isCreate ? access['live:project-introduction:add'] : access['live:project-introduction:edit'];
+ const contentType = Form.useWatch('contentType', form) || 1;
+ const isVideoType = contentType === 2;
+
+ useEffect(() => {
+ if (!canAccess) {
+ history.replace('/Welcome');
+ return;
+ }
+ const bootstrap = async () => {
+ setLoading(true);
+ try {
+ if (isCreate) {
+ setCoverImg('');
+ form.setFieldsValue({
+ title: '',
+ category: undefined,
+ contentType: 1,
+ content: '',
+ videoUrl: '',
+ videoFileName: '',
+ status: true,
+ });
+ return;
+ }
+ const res = await getProjectIntroductionDetail(recordId);
+ const detail = res.data;
+ setCoverImg(detail.coverImg || '');
+ form.setFieldsValue({
+ title: detail.title,
+ coverImg: detail.coverImg,
+ category: detail.category,
+ contentType: detail.contentType || 1,
+ content: detail.content || '',
+ videoUrl: detail.videoUrl || '',
+ videoFileName: detail.videoFileName || '',
+ status: detail.status === 1,
+ });
+ } finally {
+ setLoading(false);
+ }
+ };
+ bootstrap();
+ }, [canAccess, form, isCreate, recordId]);
+
+ const handleSubmit = async () => {
+ const values = await form.validateFields();
+ const payload = {
+ title: values.title?.trim(),
+ coverImg,
+ category: values.category,
+ contentType: values.contentType,
+ content: values.contentType === 1 ? values.content : undefined,
+ videoUrl: values.contentType === 2 ? values.videoUrl?.trim() : undefined,
+ videoFileName: values.contentType === 2 ? values.videoFileName?.trim() : undefined,
+ status: values.status ? 1 : 0,
+ };
+ setSubmitting(true);
+ try {
+ if (isCreate) {
+ await createProjectIntroduction(payload);
+ message.success('新增成功');
+ } else {
+ await updateProjectIntroduction(recordId, payload);
+ message.success('保存成功');
+ }
+ history.replace(`${listPagePath}?refresh=${Date.now()}`);
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+ <div className="project-introduction-page">
+ <div className="project-introduction-page__panel">
+ <div className="project-introduction-page__title">{isCreate ? '添加项目介绍' : '编辑项目介绍'}</div>
+ <Form form={form} layout="vertical" className="project-introduction-edit-form">
+ <div className="project-introduction-edit-form__section-title">信息</div>
+ <div className="project-introduction-edit-form__grid">
+ <Form.Item label="标题" name="title" rules={[{ required: true, message: '请输入标题' }]}>
+ <Input placeholder="请输入" maxLength={128} disabled={loading} />
+ </Form.Item>
+ <Form.Item label="类别" name="category" rules={[{ required: true, message: '请选择类别' }]}>
+ <Select placeholder="请选择" options={categoryOptions} disabled={loading} />
+ </Form.Item>
+ <Form.Item label="封面图" className="project-introduction-edit-form__upload" required>
+ <Form.Item name="coverImg" rules={[{ required: true, message: '请上传封面图' }]} hidden>
+ <Input />
+ </Form.Item>
+ <Upload
+ accept="image/*"
+ showUploadList={false}
+ customRequest={async (options) => {
+ setUploadingCover(true);
+ try {
+ const res = await uploadProjectIntroductionImage(options.file as File);
+ const url = res.data?.url;
+ if (!url) {
+ throw new Error('upload failed');
+ }
+ setCoverImg(url);
+ form.setFieldsValue({ coverImg: url });
+ options.onSuccess?.(res);
+ } catch (error) {
+ message.error('封面图上传失败');
+ options.onError?.(error as Error);
+ } finally {
+ setUploadingCover(false);
+ }
+ }}
+ >
+ <div className="project-introduction-edit-form__cover-box">
+ {coverImg ? (
+ <Image src={coverImg} width={96} height={96} preview={false} />
+ ) : uploadingCover ? (
+ '上传中...'
+ ) : (
+ <PlusOutlined />
+ )}
+ </div>
+ </Upload>
+ </Form.Item>
+ <div className="project-introduction-edit-form__switch-group">
+ <div className="project-introduction-edit-form__switch-item">
+ <span className="project-introduction-edit-form__switch-label">是否启用</span>
+ <Form.Item name="status" valuePropName="checked" rules={[{ required: true, message: '请选择是否启用' }]}>
+ <Switch />
+ </Form.Item>
+ </div>
+ </div>
+ <Form.Item label="内容类型" name="contentType" rules={[{ required: true, message: '请选择内容类型' }]}>
+ <Radio.Group options={contentTypeOptions} disabled={loading} />
+ </Form.Item>
+ {isVideoType ? (
+ <>
+ <Form.Item
+ label="视频地址"
+ name="videoUrl"
+ extra="本地视频和视频地址必须填一个"
+ rules={[{ required: true, message: '请填写视频地址或上传本地视频' }]}
+ >
+ <Input placeholder="请输入" disabled={loading} />
+ </Form.Item>
+ <Form.Item label="本地视频" extra="本地视频和视频地址必须填一个">
+ <div className="project-introduction-edit-form__video-upload">
+ <Upload
+ accept="video/*"
+ showUploadList={false}
+ customRequest={async (options) => {
+ setUploadingVideo(true);
+ try {
+ const res = await uploadProjectIntroductionVideo(options.file as File);
+ const url = res.data?.url;
+ if (!url) {
+ throw new Error('upload failed');
+ }
+ form.setFieldsValue({
+ videoUrl: url,
+ videoFileName: res.data?.fileName || (options.file as File).name,
+ });
+ message.success('视频上传成功');
+ options.onSuccess?.(res);
+ } catch (error) {
+ message.error('视频上传失败');
+ options.onError?.(error as Error);
+ } finally {
+ setUploadingVideo(false);
+ }
+ }}
+ >
+ <Button icon={<UploadOutlined />} loading={uploadingVideo}>
+ 上传
+ </Button>
+ </Upload>
+ <span>
+ {form.getFieldValue('videoFileName') ||
+ (form.getFieldValue('videoUrl') ? '已填写视频地址' : '未上传视频文件')}
+ </span>
+ </div>
+ <Form.Item name="videoFileName" hidden>
+ <Input />
+ </Form.Item>
+ </Form.Item>
+ </>
+ ) : (
+ <Form.Item
+ label="内容详情"
+ name="content"
+ className="project-introduction-edit-form__editor-item"
+ rules={[{ required: true, message: '请输入内容' }]}
+ >
+ <div className="project-introduction-edit-form__editor">
+ <BraftEditorCom
+ data={form.getFieldValue('content') || ''}
+ resultCallback={(html: string) => form.setFieldsValue({ content: html })}
+ />
+ </div>
+ </Form.Item>
+ )}
+ </div>
+ <div className="project-introduction-edit-form__footer">
+ <Button onClick={() => history.push(listPagePath)}>取消</Button>
+ <Button type="primary" loading={submitting} onClick={handleSubmit}>
+ 保存
+ </Button>
+ </div>
+ </Form>
+ </div>
+ </div>
+ );
+};
+
+export default ProjectIntroductionManagePage;
diff --git a/tcm-training-admin/src/pages/live/project-introduction/service.ts b/tcm-training-admin/src/pages/live/project-introduction/service.ts
new file mode 100644
index 0000000..d69ebcf
--- /dev/null
+++ b/tcm-training-admin/src/pages/live/project-introduction/service.ts
@@ -0,0 +1,52 @@
+import { request } from '@umijs/max';
+
+export async function getProjectIntroductionPage(params?: Record<string, any>) {
+ return request('/project-introductions', {
+ method: 'GET',
+ params,
+ });
+}
+
+export async function getProjectIntroductionDetail(id: number | string) {
+ return request(`/project-introductions/${id}`, {
+ method: 'GET',
+ });
+}
+
+export async function createProjectIntroduction(data: Record<string, any>) {
+ return request('/project-introductions', {
+ method: 'POST',
+ data,
+ });
+}
+
+export async function updateProjectIntroduction(id: number | string, data: Record<string, any>) {
+ return request(`/project-introductions/${id}`, {
+ method: 'PUT',
+ data,
+ });
+}
+
+export async function deleteProjectIntroduction(id: number | string) {
+ return request(`/project-introductions/${id}`, {
+ method: 'DELETE',
+ });
+}
+
+export async function uploadProjectIntroductionImage(file: File) {
+ const formData = new FormData();
+ formData.append('file', file);
+ return request('/upload/image', {
+ method: 'POST',
+ data: formData,
+ });
+}
+
+export async function uploadProjectIntroductionVideo(file: File) {
+ const formData = new FormData();
+ formData.append('file', file);
+ return request('/upload/video', {
+ method: 'POST',
+ data: formData,
+ });
+}
diff --git a/tcm-training-admin/src/pages/live/project-introduction/style.less b/tcm-training-admin/src/pages/live/project-introduction/style.less
new file mode 100644
index 0000000..c53ef01
--- /dev/null
+++ b/tcm-training-admin/src/pages/live/project-introduction/style.less
@@ -0,0 +1,231 @@
+.project-introduction-page {
+ min-height: calc(100vh - 48px);
+ padding: 18px 20px 24px;
+ background: #f2f5f9;
+}
+
+.project-introduction-page__panel {
+ padding: 18px 18px 24px;
+ border-radius: 10px;
+ background: #fff;
+ box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
+}
+
+.project-introduction-page__title {
+ margin-bottom: 16px;
+ color: #303133;
+ font-size: 20px;
+ font-weight: 500;
+}
+
+.project-introduction-search-card {
+ padding: 16px 18px 10px;
+ border: 1px solid #e7edf4;
+ border-radius: 6px;
+}
+
+.project-introduction-search-form {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(200px, 1fr)) auto;
+ gap: 10px 18px;
+ align-items: start;
+}
+
+.project-introduction-search-form .ant-form-item {
+ margin-bottom: 0;
+}
+
+.project-introduction-search-form .ant-form-item-row {
+ display: grid;
+ grid-template-columns: 64px minmax(0, 1fr);
+ align-items: center;
+ column-gap: 12px;
+}
+
+.project-introduction-search-form .ant-form-item-label {
+ padding: 0;
+}
+
+.project-introduction-search-form__actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 12px;
+ min-width: 160px;
+}
+
+.project-introduction-toolbar {
+ display: flex;
+ justify-content: flex-end;
+ margin: 16px 0 10px;
+}
+
+.project-introduction-table-card {
+ overflow: hidden;
+ border: 1px solid #e7edf4;
+ border-radius: 6px;
+}
+
+.project-introduction-table-card .ant-table-thead > tr > th {
+ background: #f8fafc;
+ color: #303133;
+ font-weight: 500;
+}
+
+.project-introduction-table__title {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.project-introduction-table__cover-placeholder {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 64px;
+ height: 40px;
+ border-radius: 6px;
+ background: #edf2f7;
+ color: #64748b;
+ font-size: 14px;
+ font-weight: 600;
+}
+
+.project-introduction-table__actions {
+ button {
+ padding: 0;
+ border: none;
+ background: none;
+ color: #1677ff;
+ cursor: pointer;
+ }
+}
+
+.project-introduction-pagination {
+ display: flex;
+ justify-content: flex-end;
+ padding: 16px 18px;
+}
+
+.project-introduction-edit-form__section-title {
+ margin-bottom: 16px;
+ padding-left: 8px;
+ border-left: 3px solid #1677ff;
+ color: #303133;
+ font-size: 15px;
+ font-weight: 500;
+}
+
+.project-introduction-edit-form__grid {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0 36px;
+ align-items: start;
+}
+
+.project-introduction-edit-form .ant-form-item {
+ margin-bottom: 18px;
+}
+
+.project-introduction-edit-form__upload {
+ grid-column: span 1;
+}
+
+.project-introduction-edit-form__cover-box {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 96px;
+ height: 96px;
+ overflow: hidden;
+ border: 1px dashed #cbd5e1;
+ border-radius: 8px;
+ background: #f8fafc;
+ color: #94a3b8;
+ font-size: 28px;
+ cursor: pointer;
+}
+
+.project-introduction-edit-form__switch-group {
+ display: flex;
+ align-items: center;
+ gap: 28px;
+ min-height: 72px;
+}
+
+.project-introduction-edit-form__switch-item {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.project-introduction-edit-form__switch-item .ant-form-item {
+ margin-bottom: 0;
+}
+
+.project-introduction-edit-form__switch-label {
+ color: #606266;
+ white-space: nowrap;
+}
+
+.project-introduction-edit-form__video-upload {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.project-introduction-edit-form__video-upload span {
+ color: #606266;
+ word-break: break-all;
+}
+
+.project-introduction-edit-form__editor-item {
+ grid-column: 1 / -1;
+}
+
+.project-introduction-edit-form__editor {
+ border: 1px solid #dcdfe6;
+}
+
+.project-introduction-edit-form__footer {
+ display: flex;
+ gap: 12px;
+ margin-top: 12px;
+}
+
+@media (max-width: 1200px) {
+ .project-introduction-search-form {
+ grid-template-columns: repeat(2, minmax(200px, 1fr));
+ }
+
+ .project-introduction-edit-form__grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (max-width: 768px) {
+ .project-introduction-page {
+ padding: 12px;
+ }
+
+ .project-introduction-page__panel {
+ padding: 16px;
+ }
+
+ .project-introduction-search-form {
+ grid-template-columns: 1fr;
+ }
+
+ .project-introduction-search-form__actions {
+ justify-content: flex-start;
+ }
+
+ .project-introduction-edit-form__grid {
+ grid-template-columns: 1fr;
+ }
+
+ .project-introduction-edit-form__switch-group,
+ .project-introduction-edit-form__video-upload {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+}
--
Gitblit v1.7.1