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