doc/nginx-tcm-training.conf
New file @@ -0,0 +1,49 @@ # tcm-training 项目线上 Nginx 配置片段 # 使用方式: # 1. 保持现有 doc/nginx.conf 不变。 # 2. 将下面的 location 配置按需合并到线上 443 的 server 块中。 # 3. 管理后台构建产物部署到 /usr/local/project/tcm-training/admin/。 # 4. 后端 Spring Boot 服务启动在 127.0.0.1:48080。 # 5. 如需统一项目部署目录,推荐使用 /usr/local/project/tcm-training/ 作为项目根目录。 server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name haizhentong.com; # 管理后台访问 /admin 时补全尾部斜杠,确保静态资源路径与 publicPath 对齐。 location = /admin { return 301 /admin/; } # 如需兼容旧入口,可将 /manage 平滑跳转到新的 /admin/。 location = /manage { return 301 /admin/; } # 管理后台为 Umi 构建产物,直接由 Nginx 托管静态文件。 location /admin/ { alias /usr/local/project/tcm-training/admin/; index index.html; try_files $uri $uri/ /admin/index.html; } # 后端接口统一走 /api/ 前缀。 location /api/ { proxy_pass http://127.0.0.1:48080; proxy_read_timeout 60s; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } # 管理后台部署补充说明: # 1. 生产构建命令:cd tcm-training-admin && npm run build:prod # 2. 构建产物目录:tcm-training-admin/admin # 3. 线上部署目录建议:/usr/local/project/tcm-training/admin/ # 4. 线上访问地址:https://haizhentong.com/admin/ # 5. 后端接口地址:https://haizhentong.com/api/ # 6. 管理后台生产环境接口基地址需要配置为 /api,避免页面继续请求本地开发地址。 doc/nginx.conf
New file @@ -0,0 +1,126 @@ # For more information on configuration, see: # * Official English Documentation: http://nginx.org/en/docs/ # * Official Russian Documentation: http://nginx.org/ru/docs/ user nginx; worker_processes auto; error_log /var/log/nginx/error.log; pid /run/nginx.pid; # Load dynamic modules. See /usr/share/doc/nginx/README.dynamic. include /usr/share/nginx/modules/*.conf; events { worker_connections 1024; } http { log_format main '$remote_addr - $remote_user [$time_local] "$request" ' '$status $body_bytes_sent "$http_referer" ' '"$http_user_agent" "$http_x_forwarded_for"'; access_log /var/log/nginx/access.log main; sendfile on; tcp_nopush on; tcp_nodelay on; keepalive_timeout 65; types_hash_max_size 4096; include /etc/nginx/mime.types; default_type application/octet-stream; # Load modular configuration files from the /etc/nginx/conf.d directory. # See http://nginx.org/en/docs/ngx_core_module.html#include # for more information. include /etc/nginx/conf.d/*.conf; server { listen 80; listen [::]:80; server_name _; root /usr/share/nginx/html; # Load configuration files for the default server block. include /etc/nginx/default.d/*.conf; error_page 404 /404.html; location = /404.html { } error_page 500 502 503 504 /50x.html; location = /50x.html { } } # Settings for a TLS enabled server. # server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name haizhentong.com; root /usr/share/nginx/html; ssl_certificate "/etc/pki/nginx/scs1756353593438_haizhentong.com_server.crt"; ssl_certificate_key "/etc/pki/nginx/scs1756353593438_haizhentong.com_server.key"; ssl_session_cache shared:SSL:1m; ssl_session_timeout 10m; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; client_max_body_size 100M; # Load configuration files for the default server block. include /etc/nginx/default.d/*.conf; location /manage { proxy_pass http://127.0.0.1:8089; # 代理到上面的地址去 proxy_read_timeout 60s; proxy_set_header Host $host; proxy_set_header X-Real_IP $remote_addr; proxy_set_header X-Forwarded-for $remote_addr; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; } location /applet { proxy_pass http://127.0.0.1:8088; # 代理到上面的地址去 proxy_read_timeout 60s; proxy_set_header Host $host; proxy_set_header X-Real_IP $remote_addr; proxy_set_header X-Forwarded-for $remote_addr; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'Upgrade'; } location / { root /usr/local/project/web/; index index.html index.htm; } location /MP_verify_7FfidZ6E3EWJhVDd.txt { alias /usr/local/project/MP_verify_7FfidZ6E3EWJhVDd.txt; } # location /hweb { # alias /usr/local/project/hweb/; # index index.html index.htm; # } location /hweb { alias /usr/local/project/hweb; index index.php shareIndex.html shareIndex.htm yszc.html yszc.htm default.php default.htm default.html index.html index.htm; try_files $uri $uri/ /hweb/index.html; } error_page 404 /404.html; location = /40x.html { } error_page 500 502 503 504 /50x.html; location = /50x.html { } } } tcm-training-admin-backend/sql/tcm_training_admin.sql
New file Diff too large tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/controller/AppHealthCourseController.java
@@ -1,13 +1,18 @@ package com.tcm.training.admin.app.course.controller; import com.tcm.training.admin.app.course.service.AppHealthCourseService; import com.tcm.training.admin.app.course.vo.AppHealthCourseCategoryOptionVO; import com.tcm.training.admin.app.course.vo.AppHealthCourseDetailVO; import com.tcm.training.admin.app.course.vo.AppHealthCourseListItemVO; import com.tcm.training.admin.common.ApiResponse; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.List; /** * APP 养生课程详情接口。 @@ -27,10 +32,35 @@ } /** * 获取 APP 养生课程列表。 */ @GetMapping("/health-courses") public ApiResponse<List<AppHealthCourseListItemVO>> list(@RequestParam(value = "levelCategoryId", required = false) Long levelCategoryId, @RequestParam(value = "difficultyCategoryId", required = false) Long difficultyCategoryId) { return ApiResponse.ok(appHealthCourseService.list(levelCategoryId, difficultyCategoryId)); } /** * 获取 APP 养生课程详情。 */ @GetMapping("/health-courses/{courseId}") public ApiResponse<AppHealthCourseDetailVO> detail(@PathVariable("courseId") Long courseId) { return ApiResponse.ok(appHealthCourseService.detail(courseId)); } /** * 获取 APP 养生课程主题筛选项。 */ @GetMapping("/health-course-themes") public ApiResponse<List<AppHealthCourseCategoryOptionVO>> themes() { return ApiResponse.ok(appHealthCourseService.listThemes()); } /** * 获取 APP 养生课程难度筛选项。 */ @GetMapping("/health-course-difficulties") public ApiResponse<List<AppHealthCourseCategoryOptionVO>> difficulties() { return ApiResponse.ok(appHealthCourseService.listDifficulties()); } } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/service/AppHealthCourseService.java
@@ -1,15 +1,22 @@ package com.tcm.training.admin.app.course.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.tcm.training.admin.app.course.vo.AppHealthCourseCategoryOptionVO; import com.tcm.training.admin.app.course.vo.AppHealthCourseDetailVO; import com.tcm.training.admin.app.course.vo.AppHealthCourseExpertVO; import com.tcm.training.admin.app.course.vo.AppHealthCourseListItemVO; import com.tcm.training.admin.app.course.vo.AppHealthCourseVideoVO; import com.tcm.training.admin.live.entity.Expert; import com.tcm.training.admin.live.entity.HealthCourseVideo; import com.tcm.training.admin.live.entity.LiveCourseCategory; import com.tcm.training.admin.live.mapper.ExpertMapper; import com.tcm.training.admin.live.mapper.HealthCourseMapper; import com.tcm.training.admin.live.mapper.HealthCourseVideoMapper; import com.tcm.training.admin.live.mapper.LiveCourseCategoryMapper; import com.tcm.training.admin.live.service.HealthCourseAppService; import com.tcm.training.admin.live.service.LiveCourseCategoryAppService; import com.tcm.training.admin.live.vo.HealthCourseDetailVO; import com.tcm.training.admin.live.vo.HealthCourseListRow; import org.springframework.stereotype.Service; import java.math.BigDecimal; @@ -27,18 +34,52 @@ private static final int VIDEO_STATUS_ENABLED = 1; private final HealthCourseAppService healthCourseAppService; private final HealthCourseMapper healthCourseMapper; private final HealthCourseVideoMapper healthCourseVideoMapper; private final LiveCourseCategoryMapper liveCourseCategoryMapper; private final ExpertMapper expertMapper; /** * 注入 APP 养生课程详情所需依赖。 */ public AppHealthCourseService(HealthCourseAppService healthCourseAppService, HealthCourseMapper healthCourseMapper, HealthCourseVideoMapper healthCourseVideoMapper, LiveCourseCategoryMapper liveCourseCategoryMapper, ExpertMapper expertMapper) { this.healthCourseAppService = healthCourseAppService; this.healthCourseMapper = healthCourseMapper; this.healthCourseVideoMapper = healthCourseVideoMapper; this.liveCourseCategoryMapper = liveCourseCategoryMapper; this.expertMapper = expertMapper; } /** * 获取 APP 养生课程列表页数据。 */ public List<AppHealthCourseListItemVO> list(Long levelCategoryId, Long difficultyCategoryId) { List<HealthCourseListRow> rows = healthCourseMapper.selectCourseList( null, levelCategoryId, difficultyCategoryId, COURSE_STATUS_ENABLED ); if (rows == null || rows.isEmpty()) { return Collections.emptyList(); } List<AppHealthCourseListItemVO> result = new ArrayList<AppHealthCourseListItemVO>(rows.size()); for (HealthCourseListRow row : rows) { AppHealthCourseListItemVO itemVO = new AppHealthCourseListItemVO(); itemVO.setId(row.getId()); itemVO.setTitle(trimToEmpty(row.getTitle())); itemVO.setCoverImage(trimToEmpty(row.getCoverImg())); itemVO.setLevelName(trimToEmpty(row.getLevelName())); itemVO.setDifficultyName(trimToEmpty(row.getDifficultyName())); itemVO.setSalePrice(defaultPrice(row.getSalePrice())); itemVO.setLearnUserCount(defaultNumber(row.getLearnUserCount())); result.add(itemVO); } return result; } /** @@ -75,6 +116,43 @@ } /** * 获取 APP 养生课程主题筛选项。 */ public List<AppHealthCourseCategoryOptionVO> listThemes() { return listCategoriesByType(LiveCourseCategoryAppService.TYPE_LEVEL); } /** * 获取 APP 养生课程难度筛选项。 */ public List<AppHealthCourseCategoryOptionVO> listDifficulties() { return listCategoriesByType(LiveCourseCategoryAppService.TYPE_DIFFICULTY); } /** * 按分类类型读取 APP 养生课程筛选项。 */ private List<AppHealthCourseCategoryOptionVO> listCategoriesByType(String categoryType) { List<LiveCourseCategory> categories = liveCourseCategoryMapper.selectList(new LambdaQueryWrapper<LiveCourseCategory>() .eq(LiveCourseCategory::getType, categoryType) .eq(LiveCourseCategory::getDeleted, 0) .orderByAsc(LiveCourseCategory::getSort) .orderByAsc(LiveCourseCategory::getId)); if (categories == null || categories.isEmpty()) { return Collections.emptyList(); } List<AppHealthCourseCategoryOptionVO> result = new ArrayList<AppHealthCourseCategoryOptionVO>(categories.size()); for (LiveCourseCategory category : categories) { AppHealthCourseCategoryOptionVO optionVO = new AppHealthCourseCategoryOptionVO(); optionVO.setId(category.getId()); optionVO.setName(trimToEmpty(category.getName())); result.add(optionVO); } return result; } /** * 查询课程下启用的视频章节。 */ private List<HealthCourseVideo> listEnabledVideos(Long courseId) { tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseCategoryOptionVO.java
New file @@ -0,0 +1,16 @@ package com.tcm.training.admin.app.course.vo; import lombok.Getter; import lombok.Setter; /** * APP 养生课程分类筛选项响应。 */ @Getter @Setter public class AppHealthCourseCategoryOptionVO { private Long id; private String name; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/app/course/vo/AppHealthCourseListItemVO.java
New file @@ -0,0 +1,28 @@ package com.tcm.training.admin.app.course.vo; import lombok.Getter; import lombok.Setter; import java.math.BigDecimal; /** * APP 养生课程列表卡片响应。 */ @Getter @Setter public class AppHealthCourseListItemVO { private Long id; private String title; private String coverImage; private String levelName; private String difficultyName; private BigDecimal salePrice; private Integer learnUserCount; } tcm-training-admin-backend/src/main/java/com/tcm/training/admin/bootstrap/HaizhentongDataSourceStartupValidator.java
@@ -22,6 +22,8 @@ private static final Logger log = LoggerFactory.getLogger(HaizhentongDataSourceStartupValidator.class); private static final String HAIZHENTONG_DATASOURCE_NAME = "haizhentong"; private static final String MISSING_DATASOURCE_MESSAGE = "海诊通数据源 haizhentong 未配置,请检查当前激活环境的配置文件"; private final DynamicDataSourceProperties dynamicDataSourceProperties; private final ObjectProvider<DynamicRoutingDataSource> dynamicRoutingDataSourceProvider; @@ -77,7 +79,7 @@ if (configuredDataSourceNames.contains(HAIZHENTONG_DATASOURCE_NAME)) { return; } throw new IllegalStateException("海诊通数据源 haizhentong 未配置,请检查 application-local.yml"); throw new IllegalStateException(MISSING_DATASOURCE_MESSAGE); } /** tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthCourseSaveRequest.java
@@ -21,7 +21,7 @@ @Size(max = 128, message = "课程名称长度不能超过128个字符") private String title; @NotNull(message = "请选择养生等级") @NotNull(message = "请选择养生主题") private Long levelCategoryId; @NotNull(message = "请选择难度") tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/excel/HealthCourseExportRow.java
@@ -15,7 +15,7 @@ @ExcelProperty("课程名称") private String title; @ExcelProperty("养生等级") @ExcelProperty("养生主题") private String levelName; @ExcelProperty("难度") tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthCourseAppService.java
@@ -417,7 +417,7 @@ } private void validateCategories(Long levelCategoryId, Long difficultyCategoryId) { LiveCourseCategory level = requireCategory(levelCategoryId, LiveCourseCategoryAppService.TYPE_LEVEL, "养生等级"); LiveCourseCategory level = requireCategory(levelCategoryId, LiveCourseCategoryAppService.TYPE_LEVEL, "养生主题"); LiveCourseCategory difficulty = requireCategory(difficultyCategoryId, LiveCourseCategoryAppService.TYPE_DIFFICULTY, "难度"); if (level == null || difficulty == null) { throw new IllegalArgumentException("分类不存在"); tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/LiveCourseCategoryAppService.java
@@ -133,7 +133,7 @@ } private String resolveLabel(String type) { return TYPE_LEVEL.equals(type) ? "养生等级" : "难度"; return TYPE_LEVEL.equals(type) ? "养生主题" : "难度"; } private LiveCourseCategoryDetailVO toDetailVO(LiveCourseCategory entity) { tcm-training-admin-backend/src/main/java/com/tcm/training/admin/upload/ObsUploadService.java
@@ -38,7 +38,8 @@ String originalName = file.getOriginalFilename(); String objectKey = buildObjectKey(bizPath, originalName); File tempFile = createTempFile(file, originalName); uploadProgressService.init(taskId); long totalBytes = file.getSize(); uploadProgressService.init(taskId, totalBytes); try (ObsClient obsClient = new ObsClient( properties.getAccessKeyId(), properties.getSecretAccessKey(), @@ -52,7 +53,7 @@ request.setProgressInterval(1024 * 100L); obsClient.putObject(request); UploadFileResult result = new UploadFileResult(buildFileUrl(objectKey), originalName, objectKey); uploadProgressService.markSuccess(taskId, result); uploadProgressService.markSuccess(taskId, result, totalBytes); return result; } catch (ObsException e) { log.error("OBS文件上传失败: bucket={}, objectKey={}", properties.getBucketName(), objectKey, e); tcm-training-admin-backend/src/main/java/com/tcm/training/admin/upload/UploadProgressService.java
@@ -12,7 +12,7 @@ private final ConcurrentMap<String, UploadProgressInfo> progressMap = new ConcurrentHashMap<>(); public void init(String taskId) { public void init(String taskId, long totalBytes) { if (!StringUtils.hasText(taskId)) { return; } @@ -21,7 +21,7 @@ info.setStatus(UploadProgressInfo.STATUS_UPLOADING); info.setPercent(0); info.setTransferredBytes(0L); info.setTotalBytes(0L); info.setTotalBytes(Math.max(totalBytes, 0L)); progressMap.put(taskId, info); } @@ -33,16 +33,20 @@ info.setStatus(UploadProgressInfo.STATUS_UPLOADING); info.setPercent(status.getTransferPercentage()); info.setTransferredBytes(status.getTransferredBytes()); if (status.getTotalBytes() > 0) { info.setTotalBytes(status.getTotalBytes()); } } public void markSuccess(String taskId, UploadFileResult result) { public void markSuccess(String taskId, UploadFileResult result, long totalBytes) { if (!StringUtils.hasText(taskId)) { return; } UploadProgressInfo info = progressMap.computeIfAbsent(taskId, this::createUploadingInfo); info.setStatus(UploadProgressInfo.STATUS_SUCCESS); info.setPercent(100); info.setTotalBytes(Math.max(totalBytes, 0L)); info.setTransferredBytes(Math.max(totalBytes, 0L)); info.setUrl(result.getUrl()); info.setFileName(result.getFileName()); } tcm-training-admin-backend/src/main/resources/application-prod.yml
New file @@ -0,0 +1,37 @@ # 生产环境配置 spring: datasource: dynamic: datasource: master: url: jdbc:mysql://localhost:10633/tcm_training_admin?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: Haizhentong@2025Mysql! haizhentong: type: com.alibaba.druid.pool.DruidDataSource driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:10633/haizhentong?autoReconnect=true&useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: Haizhentong@2025Mysql! redis: host: localhost port: 10644 database: 0 # 连接超时时间 timeout: 5s password: Haizhentong@2025Redis! lettuce: pool: # 连接池中的最小空闲连接 min-idle: 0 # 连接池中的最大空闲连接 max-idle: 8 # 连接池的最大数据库连接数 max-active: 8 # #连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms app: auth: sms: debug-enabled: false debug-code: "" tcm-training-admin-backend/src/main/resources/application.yml
@@ -4,8 +4,8 @@ spring: application: name: tcm-training-admin-backend config: import: optional:application-local.yml profiles: active: local servlet: multipart: max-file-size: 200MB @@ -27,6 +27,16 @@ database: 0 timeout: 5s password: 123456 lettuce: pool: # 连接池中的最小空闲连接 min-idle: 0 # 连接池中的最大空闲连接 max-idle: 8 # 连接池的最大数据库连接数 max-active: 8 # #连接池最大阻塞等待时间(使用负值表示没有限制) max-wait: -1ms mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml tcm-training-admin-backend/src/test/java/com/tcm/training/admin/app/live/service/AppLiveCourseServiceTest.java
@@ -14,6 +14,11 @@ import com.tcm.training.admin.live.mapper.LiveCourseMapper; import com.tcm.training.admin.live.mapper.LiveReserveMapper; import com.tcm.training.admin.live.mapper.LiveSessionMapper; import com.tcm.training.admin.live.mapper.LiveSessionQuestionMapper; import com.tcm.training.admin.live.mapper.LiveSessionQuizRecordMapper; import com.tcm.training.admin.live.mapper.LiveWatchRecordMapper; import com.tcm.training.admin.points.mapper.UserPointsAccountMapper; import com.tcm.training.admin.points.service.PointsLedgerAppService; import org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -32,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -63,6 +69,21 @@ private ExpertMapper expertMapper; @Mock private LiveSessionQuestionMapper liveSessionQuestionMapper; @Mock private LiveSessionQuizRecordMapper liveSessionQuizRecordMapper; @Mock private LiveWatchRecordMapper liveWatchRecordMapper; @Mock private UserPointsAccountMapper userPointsAccountMapper; @Mock private PointsLedgerAppService pointsLedgerAppService; @Mock private AppAuthSessionService appAuthSessionService; private AppLiveCourseService appLiveCourseService; @@ -76,11 +97,20 @@ @BeforeEach void setUp() { AppLiveExpertService appLiveExpertService = new AppLiveExpertService(expertMapper); // 详情与预约用例不关心答题和观看明细,统一返回空列表避免空指针干扰主断言。 lenient().when(liveSessionQuestionMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(liveSessionQuizRecordMapper.selectList(any())).thenReturn(Collections.emptyList()); lenient().when(liveWatchRecordMapper.selectList(any())).thenReturn(Collections.emptyList()); appLiveCourseService = new AppLiveCourseService( liveCourseMapper, liveSessionMapper, liveReserveMapper, expertMapper, liveSessionQuestionMapper, liveSessionQuizRecordMapper, liveWatchRecordMapper, userPointsAccountMapper, pointsLedgerAppService, appAuthSessionService, new AppLiveCourseDisplaySupport(), appLiveExpertService tcm-training-admin-backend/src/test/java/com/tcm/training/admin/bootstrap/HaizhentongDataSourceStartupValidatorTest.java
@@ -35,7 +35,7 @@ IllegalStateException exception = assertThrows(IllegalStateException.class, () -> validator.run(new DefaultApplicationArguments(new String[0]))); assertEquals("海诊通数据源 haizhentong 未配置,请检查 application-local.yml", exception.getMessage()); assertEquals("海诊通数据源 haizhentong 未配置,请检查当前激活环境的配置文件", exception.getMessage()); } /** tcm-training-admin/admin.zipBinary files differ
tcm-training-admin/config/env.ts
@@ -3,9 +3,9 @@ SERVER_URL: 'http://192.168.110.188:48080/api', }, test: { SERVER_URL: '', SERVER_URL: '/api', }, prod: { SERVER_URL: '', SERVER_URL: '/api', }, }; tcm-training-admin/src/pages/Login/index.tsx
@@ -20,6 +20,15 @@ captchaCode: string; }; const loginInitialValues: Partial<LoginValues> = { ...(REACT_APP_ENV === 'prod' ? {} : { username: 'admin', password: 'Admin@123456', }), }; const Login: React.FC = () => { const [form] = Form.useForm<LoginValues>(); const [submitting, setSubmitting] = useState(false); @@ -98,10 +107,7 @@ layout="vertical" onFinish={handleSubmit} autoComplete="off" initialValues={{ username: 'admin', password: 'Admin@123456', }} initialValues={loginInitialValues} > <Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}> <Input size="large" prefix={<UserOutlined />} placeholder="请输入用户名" /> tcm-training-admin/src/pages/live/category/index.tsx
@@ -28,7 +28,7 @@ }; const TAB_OPTIONS: Array<{ key: CategoryType; label: string }> = [ { key: 'LEVEL', label: '养生等级' }, { key: 'LEVEL', label: '养生主题' }, { key: 'DIFFICULTY', label: '难度' }, ]; tcm-training-admin/src/pages/live/health/index.tsx
@@ -162,7 +162,7 @@ ), }, { title: '养生等级', title: '养生主题', dataIndex: 'levelName', width: 110, render: (value) => value || '-', @@ -254,7 +254,7 @@ <Form.Item label="课程名称" name="keyword"> <Input placeholder="请输入" allowClear /> </Form.Item> <Form.Item label="养生等级" name="levelCategoryId"> <Form.Item label="养生主题" name="levelCategoryId"> <Select placeholder="全部" allowClear tcm-training-admin/src/pages/live/health/manage.tsx
@@ -140,7 +140,7 @@ <Form.Item label="课程名称" name="title" rules={[{ required: true, message: '请输入课程名称' }]}> <Input placeholder="请输入" maxLength={128} disabled={loading} /> </Form.Item> <Form.Item label="养生等级" name="levelCategoryId" rules={[{ required: true, message: '请选择养生等级' }]}> <Form.Item label="养生主题" name="levelCategoryId" rules={[{ required: true, message: '请选择养生主题' }]}> <Select placeholder="请选择" options={levelOptions.map((item) => ({ label: item.name, value: item.id }))} tcm-training-admin/src/pages/live/health/service.ts
@@ -1,6 +1,16 @@ import { decryptData } from '@/utils/utils'; import { request } from '@umijs/max'; /** * 去掉接口基础地址末尾的斜杠,避免手工拼接上传路径时出现双斜杠。 */ const normalizeBaseUrl = (baseUrl?: string) => { if (!baseUrl) { return ''; } return baseUrl.replace(/\/+$/, ''); }; export async function getHealthCoursePage(params?: Record<string, any>) { return request('/health-courses', { method: 'GET', @@ -205,8 +215,10 @@ const xhr = new XMLHttpRequest(); const authorization = decryptData(localStorage.getItem('token')) || ''; let pollingTimer: number | undefined; const uploadBaseUrl = normalizeBaseUrl(BASE_URL); let lastReportedPercent = 0; xhr.open('POST', `${BASE_URL}/upload/video`, true); xhr.open('POST', `${uploadBaseUrl}/upload/video`, true); if (authorization) { xhr.setRequestHeader('Authorization', authorization); } @@ -225,13 +237,35 @@ method: 'GET', params: { taskId: uploadTaskId }, }); const uploadStatus = response?.data?.status; const percent = response?.data?.percent; if (typeof percent === 'number') { onProgress?.(percent); if (uploadStatus === 'success') { lastReportedPercent = 100; onProgress?.(100); return; } if (typeof percent === 'number' && percent > 0) { // 浏览器上传进度用于展示客户端到服务端的传输,服务端轮询补足落盘/转存阶段的尾部进度。 const mergedPercent = Math.min(99, Math.max(lastReportedPercent, 95 + Math.round(percent * 0.05))); lastReportedPercent = mergedPercent; onProgress?.(mergedPercent); } } catch (error) { // Ignore transient polling errors while upload request is still in-flight. } }; xhr.upload.onprogress = (event) => { if (!event.lengthComputable || event.total <= 0) { return; } // 客户端上传阶段最多展示到 95%,剩余进度交给服务端完成转存后再收口到 100%。 const nextPercent = Math.min(95, Math.round((event.loaded / event.total) * 95)); if (nextPercent <= lastReportedPercent) { return; } lastReportedPercent = nextPercent; onProgress?.(nextPercent); }; pollingTimer = window.setInterval(() => { @@ -248,6 +282,7 @@ try { const response = JSON.parse(xhr.responseText || '{}'); if (response?.code === 0) { lastReportedPercent = 100; onProgress?.(100); resolve(response); return; tcm-training-admin/src/typings.d.ts
@@ -27,4 +27,4 @@ declare module 'react-fittext'; declare module 'bizcharts-plugin-slider'; declare const REACT_APP_ENV: 'test' | 'dev' | 'pre' | false; declare const REACT_APP_ENV: 'test' | 'dev' | 'pre' | 'prod' | false; traditionalApp/pages/healthCourses/healthCourses.vue
@@ -29,18 +29,18 @@ <view :class="['dropdown-item', { 'is-selected': selectedTheme === item }]" v-for="(item, index) in themeOptions" :key="index" :key="`theme-${index}`" @tap="selectTheme(item)" > <image v-if="selectedTheme === item" class="check-icon" src="/static/image/healthCourses/dui.png" mode="aspectFit"></image> <text :class="['item-text', { active: selectedTheme === item }]">{{ item }}</text> </view> </template> <template v-if="activeFilter === 'difficulty'"> <template v-else-if="activeFilter === 'difficulty'"> <view :class="['dropdown-item', { 'is-selected': selectedDifficulty === item }]" v-for="(item, index) in difficultyOptions" :key="index" :key="`difficulty-${index}`" @tap="selectDifficulty(item)" > <image v-if="selectedDifficulty === item" class="check-icon" src="/static/image/healthCourses/dui.png" mode="aspectFit"></image> @@ -52,94 +52,265 @@ <!-- 课程列表 --> <scroll-view scroll-y class="course-list-area"> <view class="course-list"> <view class="course-item" v-for="i in 4" :key="'course'+i" @tap="goToCourseDetail"> <image class="course-img" src="/static/image/home/class.png" mode="aspectFill"></image> <view v-if="courseList.length" class="course-list"> <view class="course-item" v-for="item in courseList" :key="item.id" @tap="goToCourseDetail(item.id)"> <image class="course-img" :src="item.coverImage || defaultCourseCover" mode="aspectFill"></image> <view class="course-info"> <view class="course-title-box"> <text class="tag">养生</text> <text class="tag" v-if="i > 1">入门</text> <text class="title">拍八虚新手入门版</text> <text v-if="item.levelName" class="tag">{{ item.levelName }}</text> <text v-else-if="item.difficultyName" class="tag">{{ item.difficultyName }}</text> <text class="title">{{ item.title }}</text> </view> <view class="course-bottom"> <view class="price-box"> <text class="price-symbol">¥</text> <text class="price-integer">8888</text> <text class="price-decimal">.25</text> <text class="price-integer">{{ resolvePriceParts(item.salePrice).integer }}</text> <text class="price-decimal">.{{ resolvePriceParts(item.salePrice).decimal }}</text> </view> <view class="people-box"> <image class="person-icon" src="/static/image/home/person.png" mode="aspectFit"></image> <text class="people-text">8888人正在学</text> <text class="people-text">{{ formatLearningCount(item.learnUserCount) }}人正在学</text> </view> </view> </view> </view> </view> <view class="no-more">没有更多了</view> <view v-else-if="!loading" class="empty-text">暂无课程内容</view> <view v-if="loading" class="loading-text">加载中...</view> <view v-else-if="courseList.length" class="no-more">没有更多了</view> </scroll-view> </view> </template> <script setup lang="ts"> import { ref, onMounted, computed } from 'vue'; import { computed, onMounted, ref } from 'vue' import { getHealthCourseDifficultyList, getHealthCourseList, getHealthCourseThemeList } from './service' const statusBarHeight = ref(0); interface HealthCourseListItem { id: number | string title: string coverImage?: string levelName?: string difficultyName?: string salePrice?: number | string learnUserCount?: number } const activeFilter = ref(''); const selectedTheme = ref('全部'); const selectedDifficulty = ref('全部'); interface DifficultyOptionItem { id?: number | string | null name: string } const themeOptions = ['全部', '养生', '养心', '文本内容']; const difficultyOptions = ['全部', '入门', '进阶', '高级']; interface ThemeOptionItem { id?: number | string | null name: string } const statusBarHeight = ref(0) const activeFilter = ref('') const selectedTheme = ref('全部') const selectedDifficulty = ref('全部') const loading = ref(false) const selectedThemeId = ref<number | string | null>(null) const selectedDifficultyId = ref<number | string | null>(null) const courseList = ref<HealthCourseListItem[]>([]) const themeList = ref<ThemeOptionItem[]>([]) const difficultyList = ref<DifficultyOptionItem[]>([]) const defaultCourseCover = '/static/image/home/class.png' /** * 页面初始化时加载状态栏高度、课程列表和筛选项。 */ onMounted(() => { uni.getSystemInfo({ success: (res) => { statusBarHeight.value = res.statusBarHeight || 0; statusBarHeight.value = res.statusBarHeight || 0 } }); }); }) loadThemeList() loadDifficultyList() loadCourseList() }) const goSearch = () => { const themeOptions = computed(() => { const options = ['全部'] for (const item of themeList.value) { if (item.name) { options.push(item.name) } } return options }) const difficultyOptions = computed(() => { const options = ['全部'] for (const item of difficultyList.value) { if (item.name) { options.push(item.name) } } return options }) /** * 跳转搜索页。 */ function goSearch() { uni.navigateTo({ url: '/pages/search/search' }); }; const goToCourseDetail = () => { uni.navigateTo({ url: '/pages/courseDetail/courseDetail' }); }; const toggleFilter = (type: string) => { if (activeFilter.value === type) { activeFilter.value = ''; } else { activeFilter.value = type; }) } }; const selectTheme = (theme: string) => { selectedTheme.value = theme; activeFilter.value = ''; }; /** * 跳转养生课程详情页。 */ function goToCourseDetail(courseId?: number | string | null) { uni.navigateTo({ url: courseId ? `/pages/courseDetail/courseDetail?id=${courseId}` : '/pages/courseDetail/courseDetail' }) } const selectDifficulty = (diff: string) => { selectedDifficulty.value = diff; activeFilter.value = ''; }; /** * 切换筛选下拉面板。 */ function toggleFilter(type: string) { if (activeFilter.value === type) { activeFilter.value = '' } else { activeFilter.value = type } } const closeFilter = () => { activeFilter.value = ''; }; /** * 选择主题筛选项并刷新课程列表。 */ function selectTheme(theme: string) { selectedTheme.value = theme if (theme === '全部') { selectedThemeId.value = null } else { const matchedItem = themeList.value.find((item) => item.name === theme) selectedThemeId.value = matchedItem?.id || null } activeFilter.value = '' loadCourseList() } /** * 选择难度筛选项并刷新课程列表。 */ function selectDifficulty(diff: string) { selectedDifficulty.value = diff if (diff === '全部') { selectedDifficultyId.value = null } else { const matchedItem = difficultyList.value.find((item) => item.name === diff) selectedDifficultyId.value = matchedItem?.id || null } activeFilter.value = '' loadCourseList() } /** * 关闭筛选下拉层。 */ function closeFilter() { activeFilter.value = '' } /** * 拉取养生课程列表。 */ async function loadCourseList() { loading.value = true try { const response = await getHealthCourseList({ levelCategoryId: selectedThemeId.value || undefined, difficultyCategoryId: selectedDifficultyId.value || undefined, }) if (response?.code !== 0 || !Array.isArray(response.data)) { courseList.value = [] return } courseList.value = response.data.filter((item: HealthCourseListItem) => !!item?.id) } catch { courseList.value = [] } finally { loading.value = false } } /** * 拉取主题筛选项。 */ async function loadThemeList() { try { const response = await getHealthCourseThemeList() if (response?.code !== 0 || !Array.isArray(response.data)) { themeList.value = [] return } themeList.value = response.data.filter((item: ThemeOptionItem) => !!item?.id) } catch { themeList.value = [] } } /** * 拉取难度筛选项。 */ async function loadDifficultyList() { try { const response = await getHealthCourseDifficultyList() if (response?.code !== 0 || !Array.isArray(response.data)) { difficultyList.value = [] return } difficultyList.value = response.data.filter((item: DifficultyOptionItem) => !!item?.id) } catch { difficultyList.value = [] } } /** * 将课程价格格式化为两位小数。 */ function formatPrice(price?: number | string) { const numericPrice = Number(price || 0) if (!Number.isFinite(numericPrice)) { return '0.00' } return numericPrice.toFixed(2) } /** * 解析价格区展示所需的整数和小数部分。 */ function resolvePriceParts(price?: number | string) { const [integer, decimal] = formatPrice(price).split('.') return { integer: integer || '0', decimal: decimal || '00', } } /** * 将学习人数格式化为页面展示文案。 */ function formatLearningCount(count?: number) { const safeCount = typeof count === 'number' && count > 0 ? count : 0 if (safeCount >= 10000) { return `${(Math.round(safeCount / 100) / 100).toFixed(2)}W` } return `${safeCount}` } /** * 计算筛选下拉面板的顶部偏移。 */ const dropdownTop = computed(() => { // statusBarHeight + navBar(88rpx) + filterBar(88rpx) // uni-app parses rpx differently in styles vs script, but we can compute it using uni.upx2px // Alternatively, just use CSS calc in the style, and pass statusBarHeight as a CSS variable or inline style. return `calc(${statusBarHeight.value}px + 176rpx)`; }); return `calc(${statusBarHeight.value}px + 176rpx)` }) </script> <style> @@ -385,4 +556,12 @@ color: #C0C4CC; padding: 40rpx 0; } .loading-text, .empty-text { text-align: center; font-size: 24rpx; color: #C0C4CC; padding: 40rpx 0; } </style> traditionalApp/pages/healthCourses/service.js
New file @@ -0,0 +1,22 @@ import request from '@/utils/request.js' /** * 获取 APP 养生课程列表。 */ export const getHealthCourseList = (params) => { return request.get('/api/app/health-courses', params, false, false) } /** * 获取 APP 养生课程主题筛选项。 */ export const getHealthCourseThemeList = () => { return request.get('/api/app/health-course-themes', {}, false, false) } /** * 获取 APP 养生课程难度筛选项。 */ export const getHealthCourseDifficultyList = () => { return request.get('/api/app/health-course-difficulties', {}, false, false) }