| | |
| | | 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; |
| | |
| | | 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", |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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> { |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| | | } |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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; |
| New file |
| | |
| | | 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, |
| | | }); |
| | | } |
| New file |
| | |
| | | .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; |
| | | } |
| | | } |