quling
2026-08-22 7a134f6658a4510e202cd7d6ded7b02066129009
feat0806: 实现养生科普功能
4个文件已添加
8个文件已修改
824 ■■■■■ 已修改文件
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/dto/HealthPopularizationSaveRequest.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/HealthPopularization.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthPopularizationAppService.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthPopularizationDetailVO.java 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/config/routes.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/access.js 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health-promotion/index.tsx 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health-promotion/manage.tsx 240 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health-promotion/service.ts 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin/src/pages/live/health-promotion/style.less 223 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
tcm-training-admin-backend/sql/20260806_admin.sql
@@ -186,4 +186,26 @@
-- 子按钮随主菜单改名(parent_id 仍为 3120,不变)
UPDATE sys_menu SET name = '新增健康知识', remark = '健康知识新增按钮' WHERE id = 3121;
UPDATE sys_menu SET name = '编辑健康知识', remark = '健康知识编辑按钮' WHERE id = 3122;
UPDATE sys_menu SET name = '删除健康知识', remark = '健康知识删除按钮' WHERE id = 3123;
UPDATE sys_menu SET name = '删除健康知识', remark = '健康知识删除按钮' WHERE id = 3123;
-- ----------------------------
-- 新增「养生科普」菜单及按钮权限(宣传知识 6100 下,与健康知识平级)
-- 养生科普与健康知识共用 tcm_health_popularization 表,contentType=2(仅视频)
-- 主菜单 id: 6110,子按钮 6111/6112/6113
-- 添加时间:2026-08-22
-- ----------------------------
INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, icon, sort, status, visible, permission_key, remark, deleted)
VALUES (6110, 6100, '养生科普', 'live_health_promotion_manage', 'C', '/live/promotion/health-promotion', 'Live/HealthPromotionList', NULL, 51, 1, 1, 'live:health-promotion: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;
INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, icon, sort, status, visible, permission_key, remark, deleted)
VALUES (6111, 6110, '新增养生科普', 'live_health_promotion_add', 'B', NULL, NULL, NULL, 1, 1, 1, 'live:health-promotion: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;
INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, icon, sort, status, visible, permission_key, remark, deleted)
VALUES (6112, 6110, '编辑养生科普', 'live_health_promotion_edit', 'B', NULL, NULL, NULL, 2, 1, 1, 'live:health-promotion: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;
INSERT INTO sys_menu (id, parent_id, name, menu_code, type, path, component, icon, sort, status, visible, permission_key, remark, deleted)
VALUES (6113, 6110, '删除养生科普', 'live_health_promotion_delete', 'B', NULL, NULL, NULL, 3, 1, 1, 'live:health-promotion: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(6110L, 6100L, "养生科普", "live_health_promotion_manage", "C",
                "/live/promotion/health-promotion", "Live/HealthPromotionList", null, 51, "live:health-promotion:view", "养生科普菜单"));
        menuIds.add(saveMenu(6111L, 6110L, "新增养生科普", "live_health_promotion_add", "B",
                null, null, null, 1, "live:health-promotion:add", "养生科普新增按钮"));
        menuIds.add(saveMenu(6112L, 6110L, "编辑养生科普", "live_health_promotion_edit", "B",
                null, null, null, 2, "live:health-promotion:edit", "养生科普编辑按钮"));
        menuIds.add(saveMenu(6113L, 6110L, "删除养生科普", "live_health_promotion_delete", "B",
                null, null, null, 3, "live:health-promotion:delete", "养生科普删除按钮"));
        menuIds.add(saveMenu(6100L, 3000L, "宣传知识", "live_promotion_root", "M",
                "/live/promotion", null, null, 20, null, "宣传知识目录"));
        menuIds.add(saveMenu(6010L, 6100L, "项目介绍", "live_project_introduction_manage", "C",
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/dto/HealthPopularizationSaveRequest.java
@@ -34,6 +34,9 @@
    @Size(max = 255, message = "视频文件名长度不能超过255个字符")
    private String videoFileName;
    @Size(max = 255, message = "视频介绍长度不能超过255个字符")
    private String videoDescription;
    @NotNull(message = "请选择是否启用")
    private Integer status;
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/entity/HealthPopularization.java
@@ -40,6 +40,9 @@
    @TableField("video_file_name")
    private String videoFileName;
    @TableField("video_description")
    private String videoDescription;
    private Integer status;
    @TableField("is_home_recommend")
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/service/HealthPopularizationAppService.java
@@ -96,6 +96,7 @@
            item.setContent(content);
            item.setVideoUrl(null);
            item.setVideoFileName(null);
            item.setVideoDescription(null);
            return;
        }
@@ -106,6 +107,7 @@
        item.setContent(null);
        item.setVideoUrl(videoUrl);
        item.setVideoFileName(trimToNull(req.getVideoFileName()));
        item.setVideoDescription(trimToNull(req.getVideoDescription()));
    }
    private int validateContentType(Integer contentType) {
@@ -153,6 +155,7 @@
        vo.setContent(item.getContent());
        vo.setVideoUrl(item.getVideoUrl());
        vo.setVideoFileName(item.getVideoFileName());
        vo.setVideoDescription(item.getVideoDescription());
        vo.setStatus(item.getStatus());
        vo.setIsHomeRecommend(item.getIsHomeRecommend());
        vo.setClickCount(defaultNumber(item.getClickCount()));
tcm-training-admin-backend/src/main/java/com/tcm/training/admin/live/vo/HealthPopularizationDetailVO.java
@@ -17,6 +17,7 @@
    private String content;
    private String videoUrl;
    private String videoFileName;
    private String videoDescription;
    private Integer status;
    private Integer isHomeRecommend;
    private Integer clickCount;
tcm-training-admin/config/routes.ts
@@ -473,6 +473,26 @@
            component: './live/health-popularization/manage',
          },
          {
            name: '养生科普',
            path: '/live/promotion/health-promotion',
            access: 'live:health-promotion:view',
            component: './live/health-promotion',
          },
          {
            name: '新增养生科普',
            path: '/live/promotion/health-promotion/create',
            hideInMenu: true,
            access: 'live:health-promotion:add',
            component: './live/health-promotion/manage',
          },
          {
            name: '编辑养生科普',
            path: '/live/promotion/health-promotion/:id/edit',
            hideInMenu: true,
            access: 'live:health-promotion:edit',
            component: './live/health-promotion/manage',
          },
          {
            name: '项目介绍',
            path: '/live/promotion/project-introduction',
            access: 'live:project-introduction:view',
tcm-training-admin/src/access.js
@@ -41,7 +41,9 @@
      !!permission?.['live:watch:view'] ||
      !!permission?.['live:expert:view'] ||
      !!permission?.['live:project-introduction:view'] ||
      !!permission?.['live:project-introduction-password:view'],
      !!permission?.['live:project-introduction-password:view'] ||
      !!permission?.['live:health-popularization:view'] ||
      !!permission?.['live:health-promotion:view'],
    canViewTradeFinanceModule:
      !!permission?.['course:purchase-order:view'] ||
      !!permission?.['member:purchase-order:view'] ||
tcm-training-admin/src/pages/live/health-promotion/index.tsx
New file
@@ -0,0 +1,240 @@
import {
  deleteHealthPromotion,
  getHealthPromotionPage,
} 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;
  status?: number;
};
type PromotionItem = {
  id: number;
  title: string;
  coverImg?: string;
  clickCount?: number;
  sort?: number;
  contentType: number;
  status: number;
  createdAt: string;
};
const contentTypeOptions = [
  { label: '图文', value: 1 },
  { label: '视频', value: 2 },
];
const statusOptions = [
  { label: '启用', value: 1 },
  { label: '禁用', value: 0 },
];
// 养生科普只有视频内容,固定 contentType=2
const trimSearchValues = (values: SearchValues) => ({
  keyword: values.keyword?.trim() || undefined,
  contentType: 2,
  status: values.status,
});
const contentTypeText = (value?: number) =>
  contentTypeOptions.find((item) => item.value === value)?.label || '-';
const statusText = (value?: number) => (value === 1 ? '启用' : '禁用');
const HealthPromotionPage: React.FC = () => {
  const access = useAccess();
  const [searchForm] = Form.useForm<SearchValues>();
  const [list, setList] = useState<PromotionItem[]>([]);
  const [loading, setLoading] = useState(false);
  const [pagination, setPagination] = useState({ page: 1, pageSize: 10, total: 0 });
  const canView = access['live:health-promotion:view'];
  const canAdd = access['live:health-promotion:add'];
  const canEdit = access['live:health-promotion:edit'];
  const canDelete = access['live:health-promotion:delete'];
  const refreshKey = history.location.search;
  const loadList = async (page = pagination.page, pageSize = pagination.pageSize) => {
    setLoading(true);
    try {
      const res = await getHealthPromotionPage({
        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: PromotionItem) => {
    Modal.confirm({
      title: '确定要删除该养生科普吗?',
      okText: '删除',
      cancelText: '取消',
      onOk: async () => {
        await deleteHealthPromotion(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<PromotionItem> = [
    {
      title: '序号',
      width: 72,
      render: (_, __, index) => (pagination.page - 1) * pagination.pageSize + index + 1,
    },
    {
      title: '标题',
      dataIndex: 'title',
      width: 320,
      render: (_, record) => (
        <div className="health-promotion-table__title">
          {record.coverImg ? (
            <Image src={record.coverImg} width={64} height={40} preview={false} />
          ) : (
            <div className="health-promotion-table__cover-placeholder">
              {record.title?.slice(0, 2) || '-'}
            </div>
          )}
          <span>{record.title || '-'}</span>
        </div>
      ),
    },
    {
      title: '点击量',
      dataIndex: 'clickCount',
      width: 120,
      render: (value) => value || 0,
    },
    {
      title: '排序',
      dataIndex: 'sort',
      width: 100,
      render: (value) => value ?? 0,
    },
    {
      title: '类型',
      dataIndex: 'contentType',
      width: 100,
      render: (value) => contentTypeText(value),
    },
    {
      title: '状态',
      dataIndex: 'status',
      width: 100,
      render: (value) => statusText(value),
    },
    {
      title: '创建时间',
      dataIndex: 'createdAt',
      width: 180,
    },
    {
      title: '操作',
      width: 180,
      render: (_, record) => (
        <Space size={16} className="health-promotion-table__actions">
          {canEdit ? (
            <button
              type="button"
              onClick={() => history.push(`/live/promotion/health-promotion/${record.id}/edit`)}
            >
              编辑
            </button>
          ) : null}
          {canDelete ? (
            <button type="button" onClick={() => handleDelete(record)}>
              删除
            </button>
          ) : null}
        </Space>
      ),
    },
  ];
  return (
    <div className="health-promotion-page">
      <div className="health-promotion-page__panel">
        <div className="health-promotion-page__title">养生科普</div>
        <div className="health-promotion-search-card">
          <Form form={searchForm} layout="horizontal" className="health-promotion-search-form">
            <Form.Item label="标题" name="keyword">
              <Input placeholder="请输入" allowClear />
            </Form.Item>
            <Form.Item label="状态" name="status">
              <Select placeholder="全部" allowClear options={statusOptions} />
            </Form.Item>
            <div className="health-promotion-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="health-promotion-toolbar">
          {canAdd ? (
            <Button type="primary" onClick={() => history.push('/live/promotion/health-promotion/create')}>
              添加
            </Button>
          ) : null}
        </div>
        <div className="health-promotion-table-card">
          <Table<PromotionItem>
            rowKey="id"
            columns={columns}
            dataSource={list}
            loading={loading}
            pagination={false}
            scroll={{ x: 980 }}
          />
          <div className="health-promotion-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 HealthPromotionPage;
tcm-training-admin/src/pages/live/health-promotion/manage.tsx
New file
@@ -0,0 +1,240 @@
import {
  createHealthPromotion,
  getHealthPromotionDetail,
  updateHealthPromotion,
  uploadHealthPromotionImage,
  uploadHealthPromotionVideo,
} from './service';
import { PlusOutlined, UploadOutlined } from '@ant-design/icons';
import { history, useAccess } from '@umijs/max';
import { Button, Form, Image, Input, InputNumber, Switch, Upload, message } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import './style.less';
const HealthPromotionManagePage: 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/promotion/health-promotion';
  const recordId = useMemo(() => {
    if (isCreate) {
      return '';
    }
    const segments = pathname.split('/');
    return segments[segments.length - 2] || '';
  }, [isCreate, pathname]);
  const canAccess = isCreate ? access['live:health-promotion:add'] : access['live:health-promotion:edit'];
  useEffect(() => {
    if (!canAccess) {
      history.replace('/Welcome');
      return;
    }
    const bootstrap = async () => {
      setLoading(true);
      try {
        if (isCreate) {
          setCoverImg('');
          form.setFieldsValue({
            title: '',
            videoUrl: '',
            videoFileName: '',
            videoDescription: '',
            sort: 0,
            status: true,
            isHomeRecommend: true,
          });
          return;
        }
        const res = await getHealthPromotionDetail(recordId);
        const detail = res.data;
        setCoverImg(detail.coverImg || '');
        form.setFieldsValue({
          title: detail.title,
          coverImg: detail.coverImg,
          videoUrl: detail.videoUrl || '',
          videoFileName: detail.videoFileName || '',
          videoDescription: detail.videoDescription || '',
          sort: detail.sort ?? 0,
          status: detail.status === 1,
          isHomeRecommend: detail.isHomeRecommend === 1,
        });
      } finally {
        setLoading(false);
      }
    };
    bootstrap();
  }, [canAccess, form, isCreate, recordId]);
  const handleSubmit = async () => {
    const values = await form.validateFields();
    const payload = {
      title: values.title?.trim(),
      coverImg,
      // 养生科普固定为视频类型
      contentType: 2,
      videoUrl: values.videoUrl?.trim(),
      videoFileName: values.videoFileName?.trim(),
      videoDescription: values.videoDescription?.trim(),
      sort: values.sort,
      status: values.status ? 1 : 0,
      isHomeRecommend: values.isHomeRecommend ? 1 : 0,
    };
    setSubmitting(true);
    try {
      if (isCreate) {
        await createHealthPromotion(payload);
        message.success('新增成功');
      } else {
        await updateHealthPromotion(recordId, payload);
        message.success('保存成功');
      }
      history.replace(`${listPagePath}?refresh=${Date.now()}`);
    } finally {
      setSubmitting(false);
    }
  };
  return (
    <div className="health-promotion-page">
      <div className="health-promotion-page__panel">
        <div className="health-promotion-page__title">{isCreate ? '添加养生科普' : '编辑养生科普'}</div>
        <Form form={form} layout="vertical" className="health-promotion-edit-form">
          <div className="health-promotion-edit-form__section-title">信息</div>
          <div className="health-promotion-edit-form__grid">
            <Form.Item label="标题" name="title" rules={[{ required: true, message: '请输入标题' }]}>
              <Input placeholder="请输入" maxLength={128} disabled={loading} />
            </Form.Item>
            <Form.Item label="视频封面图" className="health-promotion-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 uploadHealthPromotionImage(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="health-promotion-edit-form__cover-box">
                  {coverImg ? (
                    <Image src={coverImg} width={96} height={96} preview={false} />
                  ) : uploadingCover ? (
                    '上传中...'
                  ) : (
                    <PlusOutlined />
                  )}
                </div>
              </Upload>
            </Form.Item>
            <div className="health-promotion-edit-form__switch-group">
              <div className="health-promotion-edit-form__switch-item">
                <span className="health-promotion-edit-form__switch-label">是否启用</span>
                <Form.Item name="status" valuePropName="checked" rules={[{ required: true, message: '请选择是否启用' }]}>
                  <Switch />
                </Form.Item>
              </div>
              <div className="health-promotion-edit-form__switch-item">
                <span className="health-promotion-edit-form__switch-label">首页推荐</span>
                <Form.Item
                  name="isHomeRecommend"
                  valuePropName="checked"
                  rules={[{ required: true, message: '请选择是否首页推荐' }]}
                >
                  <Switch />
                </Form.Item>
              </div>
            </div>
            <Form.Item label="排序" name="sort" rules={[{ required: true, message: '请输入排序' }]}>
              <InputNumber min={0} precision={0} placeholder="请输入" style={{ width: '100%' }} disabled={loading} />
            </Form.Item>
            <Form.Item
              label="视频地址"
              name="videoUrl"
              extra="本地视频和视频地址必须填一个"
              rules={[{ required: true, message: '请填写视频地址或上传本地视频' }]}
            >
              <Input placeholder="请输入" disabled={loading} />
            </Form.Item>
            <Form.Item
              label="视频介绍"
              name="videoDescription"
              rules={[{ required: true, message: '请输入视频介绍' }]}
            >
              <Input.TextArea placeholder="请输入视频介绍" maxLength={255} rows={3} disabled={loading} />
            </Form.Item>
            <Form.Item label="本地视频" extra="本地视频和视频地址必须填一个">
              <div className="health-promotion-edit-form__video-upload">
                <Upload
                  accept="video/*"
                  showUploadList={false}
                  customRequest={async (options) => {
                    setUploadingVideo(true);
                    try {
                      const res = await uploadHealthPromotionVideo(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>
          </div>
          <div className="health-promotion-edit-form__footer">
            <Button onClick={() => history.push(listPagePath)}>取消</Button>
            <Button type="primary" loading={submitting} onClick={handleSubmit}>
              保存
            </Button>
          </div>
        </Form>
      </div>
    </div>
  );
};
export default HealthPromotionManagePage;
tcm-training-admin/src/pages/live/health-promotion/service.ts
New file
@@ -0,0 +1,55 @@
import { request } from '@umijs/max';
// 养生科普与健康知识共用后端接口(同一张表 tcm_health_popularization),
// 通过 contentType=2(视频)区分养生科普,contentType=1(图文)区分健康知识。
export async function getHealthPromotionPage(params?: Record<string, any>) {
  return request('/health-popularizations', {
    method: 'GET',
    params,
  });
}
export async function getHealthPromotionDetail(id: number | string) {
  return request(`/health-popularizations/${id}`, {
    method: 'GET',
  });
}
export async function createHealthPromotion(data: Record<string, any>) {
  return request('/health-popularizations', {
    method: 'POST',
    data,
  });
}
export async function updateHealthPromotion(id: number | string, data: Record<string, any>) {
  return request(`/health-popularizations/${id}`, {
    method: 'PUT',
    data,
  });
}
export async function deleteHealthPromotion(id: number | string) {
  return request(`/health-popularizations/${id}`, {
    method: 'DELETE',
  });
}
export async function uploadHealthPromotionImage(file: File) {
  const formData = new FormData();
  formData.append('file', file);
  return request('/upload/image', {
    method: 'POST',
    data: formData,
  });
}
export async function uploadHealthPromotionVideo(file: File) {
  const formData = new FormData();
  formData.append('file', file);
  return request('/upload/video', {
    method: 'POST',
    data: formData,
  });
}
tcm-training-admin/src/pages/live/health-promotion/style.less
New file
@@ -0,0 +1,223 @@
.health-promotion-page {
  min-height: calc(100vh - 48px);
  padding: 18px 20px 24px;
  background: #f2f5f9;
}
.health-promotion-page__panel {
  padding: 18px 18px 24px;
  border-radius: 10px;
  background: #fff;
  box-shadow: 0 2px 10px rgba(15, 23, 42, 0.04);
}
.health-promotion-page__title {
  margin-bottom: 16px;
  color: #303133;
  font-size: 20px;
  font-weight: 500;
}
.health-promotion-search-card {
  padding: 16px 18px 10px;
  border: 1px solid #e7edf4;
  border-radius: 6px;
}
.health-promotion-search-form {
  display: grid;
  grid-template-columns: repeat(4, minmax(200px, 1fr)) auto;
  gap: 10px 18px;
  align-items: start;
}
.health-promotion-search-form .ant-form-item {
  margin-bottom: 0;
}
.health-promotion-search-form .ant-form-item-row {
  display: grid;
  grid-template-columns: 64px minmax(0, 1fr);
  align-items: center;
  column-gap: 12px;
}
.health-promotion-search-form .ant-form-item-label {
  padding: 0;
}
.health-promotion-search-form__actions {
  display: flex;
  justify-content: flex-end;
  gap: 12px;
  min-width: 160px;
}
.health-promotion-toolbar {
  display: flex;
  justify-content: flex-end;
  margin: 16px 0 10px;
}
.health-promotion-table-card {
  overflow: hidden;
  border: 1px solid #e7edf4;
  border-radius: 6px;
}
.health-promotion-table-card .ant-table-thead > tr > th {
  background: #f8fafc;
  color: #303133;
  font-weight: 500;
}
.health-promotion-table__title {
  display: flex;
  align-items: center;
  gap: 12px;
}
.health-promotion-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;
}
.health-promotion-table__actions {
  button {
    padding: 0;
    border: none;
    background: none;
    color: #1677ff;
    cursor: pointer;
  }
}
.health-promotion-pagination {
  display: flex;
  justify-content: flex-end;
  padding: 16px 18px;
}
.health-promotion-edit-form__section-title {
  margin-bottom: 16px;
  padding-left: 8px;
  border-left: 3px solid #1677ff;
  color: #303133;
  font-size: 15px;
  font-weight: 500;
}
.health-promotion-edit-form__grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0 36px;
  align-items: start;
}
.health-promotion-edit-form .ant-form-item {
  margin-bottom: 18px;
}
.health-promotion-edit-form__upload {
  grid-column: span 1;
}
.health-promotion-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;
}
.health-promotion-edit-form__switch-group {
  display: flex;
  align-items: center;
  gap: 28px;
  min-height: 72px;
}
.health-promotion-edit-form__switch-item {
  display: flex;
  align-items: center;
  gap: 10px;
}
.health-promotion-edit-form__switch-item .ant-form-item {
  margin-bottom: 0;
}
.health-promotion-edit-form__switch-label {
  color: #606266;
  white-space: nowrap;
}
.health-promotion-edit-form__video-upload {
  display: flex;
  align-items: center;
  gap: 12px;
}
.health-promotion-edit-form__video-upload span {
  color: #606266;
  word-break: break-all;
}
.health-promotion-edit-form__footer {
  display: flex;
  gap: 12px;
  margin-top: 12px;
}
@media (max-width: 1200px) {
  .health-promotion-search-form {
    grid-template-columns: repeat(2, minmax(200px, 1fr));
  }
  .health-promotion-edit-form__grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
@media (max-width: 768px) {
  .health-promotion-page {
    padding: 12px;
  }
  .health-promotion-page__panel {
    padding: 16px;
  }
  .health-promotion-search-form {
    grid-template-columns: 1fr;
  }
  .health-promotion-search-form__actions {
    justify-content: flex-start;
  }
  .health-promotion-edit-form__grid {
    grid-template-columns: 1fr;
  }
  .health-promotion-edit-form__switch-group,
  .health-promotion-edit-form__video-upload {
    flex-direction: column;
    align-items: flex-start;
  }
}