hejianhao
2026-08-20 2f5f94f57693c8e056ee89c505bb5d784ecddc1d
feat0806: 提交项目介绍后管功能
11个文件已添加
2个文件已修改
1245 ■■■■■ 已修改文件
tcm-training-admin-backend/sql/20260806_admin.sql 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/DataInitializer.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/controller/ProjectIntroductionController.java 64 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/ProjectIntroductionSaveRequest.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/ProjectIntroduction.java 68 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/ProjectIntroductionMapper.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/ProjectIntroductionAppService.java 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionDetailVO.java 25 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionListItemVO.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/project-introduction/index.tsx 253 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/project-introduction/manage.tsx 256 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/project-introduction/service.ts 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/project-introduction/style.less 231 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/20260806_admin.sql
@@ -74,3 +74,27 @@
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;
-- 项目介绍菜单和权限配置
-- 添加时间: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;
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",
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/controller/ProjectIntroductionController.java
New file
@@ -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);
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/ProjectIntroductionSaveRequest.java
New file
@@ -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;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/ProjectIntroduction.java
New file
@@ -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;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/mapper/ProjectIntroductionMapper.java
New file
@@ -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> {
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/ProjectIntroductionAppService.java
New file
@@ -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;
    }
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionDetailVO.java
New file
@@ -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;
}
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/ProjectIntroductionListItemVO.java
New file
@@ -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;
}
tcm-training-admin/src/pages/live/project-introduction/index.tsx
New file
@@ -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;
tcm-training-admin/src/pages/live/project-introduction/manage.tsx
New file
@@ -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;
tcm-training-admin/src/pages/live/project-introduction/service.ts
New file
@@ -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,
  });
}
tcm-training-admin/src/pages/live/project-introduction/style.less
New file
@@ -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;
  }
}