Browse Source

feat(merge): merge sc

feat: 流媒体服务
Lind 3 years ago
parent
commit
ca4607cf65

+ 0 - 52
src/pages/Log/Access/Detail/index.tsx

@@ -1,52 +0,0 @@
-import { Descriptions, Modal } from 'antd';
-import type { AccessLogItem } from '@/pages/Log/Access/typings';
-import { useEffect, useState } from 'react';
-import moment from 'moment';
-
-interface Props {
-  data: Partial<AccessLogItem>;
-  close: () => void;
-}
-
-const Detail = (props: Props) => {
-  const [data, setDada] = useState<Partial<AccessLogItem>>(props.data || {});
-
-  useEffect(() => {
-    setDada(props.data);
-  }, [props.data]);
-
-  return (
-    <Modal title={'详情'} visible onCancel={props.close} onOk={props.close} width={1000}>
-      <Descriptions bordered>
-        <Descriptions.Item label="URL">{data?.url}</Descriptions.Item>
-        <Descriptions.Item label="请求方法" span={2}>
-          {data?.httpMethod}
-        </Descriptions.Item>
-        <Descriptions.Item label="动作">{data?.action}</Descriptions.Item>
-        <Descriptions.Item label="类名" span={2}>
-          {data?.target}
-        </Descriptions.Item>
-        <Descriptions.Item label="方法名">{data?.method}</Descriptions.Item>
-        <Descriptions.Item label="IP" span={2}>
-          {data?.ip}
-        </Descriptions.Item>
-        <Descriptions.Item label="请求时间">
-          {moment(data?.requestTime).format('YYYY-MM-DD HH:mm:ss')}
-        </Descriptions.Item>
-        <Descriptions.Item label="请求耗时" span={2}>
-          {(data?.responseTime || 0) - (data?.requestTime || 0)}ms
-        </Descriptions.Item>
-        <Descriptions.Item label="请求头" span={3}>
-          {JSON.stringify(data?.httpHeaders)}
-        </Descriptions.Item>
-        <Descriptions.Item label="请求参数" span={3}>
-          {JSON.stringify(data?.parameters)}
-        </Descriptions.Item>
-        <Descriptions.Item label="异常信息" span={3}>
-          {data?.exception}
-        </Descriptions.Item>
-      </Descriptions>
-    </Modal>
-  );
-};
-export default Detail;

+ 0 - 136
src/pages/Log/Access/index.tsx

@@ -1,136 +0,0 @@
-import BaseService from '@/utils/BaseService';
-import { useRef, useState } from 'react';
-import type { ProColumns, ActionType } from '@jetlinks/pro-table';
-import type { AccessLogItem } from '@/pages/Log/Access/typings';
-import moment from 'moment';
-import { Tag, Tooltip } from 'antd';
-import { EyeOutlined } from '@ant-design/icons';
-import { useIntl } from '@@/plugin-locale/localeExports';
-import ProTable from '@jetlinks/pro-table';
-import SearchComponent from '@/components/SearchComponent';
-import Detail from '@/pages/Log/Access/Detail';
-
-const service = new BaseService('logger/access');
-
-const Access = () => {
-  const actionRef = useRef<ActionType>();
-  const intl = useIntl();
-  const [param, setParam] = useState({});
-  const [visible, setVisible] = useState<boolean>(false);
-  const [current, setCurrent] = useState<Partial<AccessLogItem>>({});
-
-  const columns: ProColumns<AccessLogItem>[] = [
-    {
-      title: 'IP',
-      dataIndex: 'ip',
-      ellipsis: true,
-    },
-    {
-      title: intl.formatMessage({
-        id: 'pages.log.access.url',
-        defaultMessage: '请求路径',
-      }),
-      dataIndex: 'url',
-      ellipsis: true,
-    },
-    {
-      title: '请求方法',
-      dataIndex: 'httpMethod',
-      ellipsis: true,
-    },
-    {
-      title: intl.formatMessage({
-        id: 'pages.table.description',
-        defaultMessage: '说明',
-      }),
-      dataIndex: 'description',
-      ellipsis: true,
-      render: (text, record) => {
-        return `${record.action}-${record.describe}`;
-      },
-    },
-    {
-      title: intl.formatMessage({
-        id: 'pages.log.access.requestTime',
-        defaultMessage: '请求时间',
-      }),
-      dataIndex: 'requestTime',
-      sorter: true,
-      valueType: 'dateTime',
-      defaultSortOrder: 'descend',
-      ellipsis: true,
-      width: 200,
-      renderText: (text: string) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
-    },
-    {
-      title: intl.formatMessage({
-        id: 'pages.log.access.requestTimeConsuming',
-        defaultMessage: '请求耗时',
-      }),
-      renderText: (record: AccessLogItem) => (
-        <Tag color="purple">{record.responseTime - record.requestTime}ms</Tag>
-      ),
-    },
-    {
-      title: intl.formatMessage({
-        id: 'pages.log.access.requestUser',
-        defaultMessage: '请求用户',
-      }),
-      dataIndex: 'context.username',
-      render: (text) => <Tag color="geekblue">{text}</Tag>,
-    },
-    {
-      title: intl.formatMessage({
-        id: 'pages.data.option',
-        defaultMessage: '操作',
-      }),
-      valueType: 'option',
-      align: 'center',
-      render: (text, record) => [
-        <a
-          key="editable"
-          onClick={() => {
-            setVisible(true);
-            setCurrent(record);
-          }}
-        >
-          <Tooltip title={'查看'}>
-            <EyeOutlined />
-          </Tooltip>
-        </a>,
-      ],
-    },
-  ];
-  return (
-    <>
-      <SearchComponent<AccessLogItem>
-        field={columns}
-        target="access-log"
-        onSearch={(data) => {
-          actionRef.current?.reset?.();
-          setParam(data);
-        }}
-      />
-      <ProTable<AccessLogItem>
-        columns={columns}
-        params={param}
-        request={async (params) =>
-          service.query({ ...params, sorts: [{ name: 'responseTime', order: 'desc' }] })
-        }
-        defaultParams={{ sorts: [{ responseTime: 'desc' }] }}
-        search={false}
-        actionRef={actionRef}
-      />
-      {visible && (
-        <Detail
-          data={current}
-          close={() => {
-            setVisible(false);
-            setCurrent({});
-          }}
-        />
-      )}
-    </>
-  );
-};
-export default Access;

+ 0 - 16
src/pages/Log/Access/typings.d.ts

@@ -1,16 +0,0 @@
-export type AccessLogItem = {
-  id: string;
-  context: any;
-  describe: string;
-  exception: string;
-  httpHeaders: any;
-  httpMethod: string;
-  ip: string;
-  method: string;
-  parameters: any;
-  requestTime: number;
-  responseTime: number;
-  target: string;
-  url: string;
-  action: string;
-};

+ 0 - 35
src/pages/Log/System/Detail/index.tsx

@@ -1,35 +0,0 @@
-import { Input, Modal, Space, Tag } from 'antd';
-import type { SystemLogItem } from '@/pages/Log/System/typings';
-import { useEffect, useState } from 'react';
-import moment from 'moment';
-
-interface Props {
-  data: Partial<SystemLogItem>;
-  close: () => void;
-}
-
-const Detail = (props: Props) => {
-  const [data, setDada] = useState<Partial<SystemLogItem>>(props.data || {});
-
-  useEffect(() => {
-    setDada(props.data);
-  }, [props.data]);
-
-  return (
-    <Modal title={'详情'} visible onCancel={props.close} onOk={props.close} width={1000}>
-      <Space>
-        <span>[{data?.threadName}]</span>
-        <span>{moment(data?.createTime).format('YYYY-MM-DD HH:mm:ss')}</span>
-        <span>{data?.className}</span>
-      </Space>
-      <p>
-        <Tag color={data?.level === 'ERROR' ? 'red' : 'orange'}>{data?.level}</Tag>
-        {data?.message}
-      </p>
-      <div>
-        <Input.TextArea rows={20} value={data?.exceptionStack} />
-      </div>
-    </Modal>
-  );
-};
-export default Detail;

+ 0 - 140
src/pages/Log/System/index.tsx

@@ -1,140 +0,0 @@
-import { useIntl } from '@@/plugin-locale/localeExports';
-import { useRef, useState } from 'react';
-import type { ProColumns, ActionType } from '@jetlinks/pro-table';
-import type { SystemLogItem } from '@/pages/Log/System/typings';
-import { Tag, Tooltip } from 'antd';
-import moment from 'moment';
-import BaseService from '@/utils/BaseService';
-import { EyeOutlined } from '@ant-design/icons';
-import ProTable from '@jetlinks/pro-table';
-import SearchComponent from '@/components/SearchComponent';
-import Detail from '@/pages/Log/System/Detail';
-
-const service = new BaseService<SystemLogItem>('logger/system');
-const System = () => {
-  const intl = useIntl();
-  const actionRef = useRef<ActionType>();
-  const [param, setParam] = useState({});
-  const [visible, setVisible] = useState<boolean>(false);
-  const [current, setCurrent] = useState<Partial<SystemLogItem>>({});
-
-  const columns: ProColumns<SystemLogItem>[] = [
-    {
-      title: intl.formatMessage({
-        id: 'pages.table.name',
-        defaultMessage: '名称',
-      }),
-      dataIndex: 'name',
-      ellipsis: true,
-    },
-    {
-      title: '日志级别',
-      dataIndex: 'level',
-      width: 80,
-      render: (text) => <Tag color={text === 'ERROR' ? 'red' : 'orange'}>{text}</Tag>,
-      valueType: 'select',
-      valueEnum: {
-        ERROR: {
-          text: 'ERROR',
-          status: 'ERROR',
-        },
-        INFO: {
-          text: 'INFO',
-          status: 'INFO',
-        },
-        DEBUG: {
-          text: 'DEBUG',
-          status: 'DEBUG',
-        },
-        WARN: {
-          text: 'WARN',
-          status: 'WARN',
-        },
-      },
-    },
-    {
-      title: intl.formatMessage({
-        id: 'pages.log.system.logContent',
-        defaultMessage: '日志内容',
-      }),
-      dataIndex: 'exceptionStack',
-      ellipsis: true,
-    },
-    {
-      title: intl.formatMessage({
-        id: 'pages.log.system.serviceName',
-        defaultMessage: '服务名',
-      }),
-      dataIndex: 'context.server',
-      width: 150,
-      ellipsis: true,
-    },
-    {
-      title: intl.formatMessage({
-        id: 'pages.log.system.creationTime',
-        defaultMessage: '创建时间',
-      }),
-      dataIndex: 'createTime',
-      width: 200,
-      sorter: true,
-      ellipsis: true,
-      valueType: 'dateTime',
-      defaultSortOrder: 'descend',
-      renderText: (text) => moment(text).format('YYYY-MM-DD HH:mm:ss'),
-    },
-    {
-      title: intl.formatMessage({
-        id: 'pages.data.option',
-        defaultMessage: '操作',
-      }),
-      valueType: 'option',
-      align: 'center',
-      width: 200,
-      render: (text, record) => [
-        <a
-          key="editable"
-          onClick={() => {
-            setVisible(true);
-            setCurrent(record);
-          }}
-        >
-          <Tooltip title="查看">
-            <EyeOutlined />
-          </Tooltip>
-        </a>,
-      ],
-    },
-  ];
-  return (
-    <>
-      <SearchComponent<SystemLogItem>
-        field={columns}
-        target="system-log"
-        onSearch={(data) => {
-          actionRef.current?.reset?.();
-          setParam(data);
-        }}
-      />
-      <ProTable<SystemLogItem>
-        columns={columns}
-        params={param}
-        request={async (params) =>
-          service.query({ ...params, sorts: [{ name: 'createTime', order: 'desc' }] })
-        }
-        defaultParams={{ sorts: [{ createTime: 'desc' }] }}
-        search={false}
-        actionRef={actionRef}
-      />
-      {visible && (
-        <Detail
-          data={current}
-          close={() => {
-            setVisible(false);
-            setCurrent({});
-          }}
-        />
-      )}
-    </>
-  );
-};
-export default System;

+ 0 - 14
src/pages/Log/System/typings.d.ts

@@ -1,14 +0,0 @@
-export type SystemLogItem = {
-  id: string;
-  className: string;
-  context: any;
-  createTime: number;
-  exceptionStack: string;
-  level: string;
-  lineNumber: number;
-  message: string;
-  methodName: string;
-  name: string;
-  threadId: string;
-  threadName: string;
-};

+ 0 - 28
src/pages/Log/index.tsx

@@ -1,28 +0,0 @@
-import { PageContainer } from '@ant-design/pro-layout';
-import { useState } from 'react';
-import Access from '@/pages/Log/Access';
-import System from '@/pages/Log/System';
-
-const Log = () => {
-  const [tab, setTab] = useState<string>('access');
-  const list = [
-    {
-      key: 'access',
-      tab: '访问日志',
-      component: <Access />,
-    },
-    {
-      key: 'system',
-      tab: '系统日志',
-      component: <System />,
-    },
-  ];
-
-  return (
-    <PageContainer onTabChange={setTab} tabList={list} tabActiveKey={tab}>
-      {list.find((k) => k.key === tab)?.component}
-    </PageContainer>
-  );
-};
-
-export default Log;

+ 411 - 0
src/pages/media/Stream/Detail/index.tsx

@@ -0,0 +1,411 @@
+import { PageContainer } from '@ant-design/pro-layout';
+import {
+  Button,
+  Card,
+  Checkbox,
+  Col,
+  Form,
+  Input,
+  InputNumber,
+  message,
+  Row,
+  Select,
+  Tooltip,
+} from 'antd';
+import { useEffect, useState } from 'react';
+import { service, StreamModel } from '@/pages/media/Stream';
+import { useParams } from 'umi';
+import { QuestionCircleOutlined } from '@ant-design/icons';
+
+const re =
+  /^([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.([0-9]|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])$/;
+
+// API host
+interface APIComponentProps {
+  onChange?: (data: any) => void;
+  value?: {
+    apiHost?: string;
+    apiPort?: number;
+  };
+}
+
+const APIComponent = (props: APIComponentProps) => {
+  const { value, onChange } = props;
+  const [data, setData] = useState<{ apiHost?: string; apiPort?: number } | undefined>(value);
+
+  useEffect(() => {
+    setData(value);
+  }, [value]);
+
+  return (
+    <div style={{ display: 'flex', alignItems: 'center' }}>
+      <Input
+        onChange={(e) => {
+          if (onChange) {
+            const item = {
+              ...data,
+              apiHost: e.target.value,
+            };
+            setData(item);
+            onChange(item);
+          }
+        }}
+        value={data?.apiHost}
+        style={{ marginRight: 10 }}
+        placeholder="请输入API Host"
+      />
+      <InputNumber
+        style={{ minWidth: 150 }}
+        value={data?.apiPort}
+        min={1}
+        max={65535}
+        onChange={(e: number) => {
+          if (onChange) {
+            const item = {
+              ...data,
+              apiPort: e,
+            };
+            setData(item);
+            onChange(item);
+          }
+        }}
+      />
+    </div>
+  );
+};
+
+interface RTPComponentProps {
+  onChange?: (data: any) => void;
+  value?: {
+    rtpIp?: string;
+    rtpPort?: number;
+    dynamicRtpPort?: boolean;
+    dynamicRtpPortRange?: number[];
+  };
+}
+
+const RTPComponent = (props: RTPComponentProps) => {
+  const { value, onChange } = props;
+  const [checked, setChecked] = useState<boolean>(value?.dynamicRtpPort || false);
+  const [data, setData] = useState<any>(value);
+
+  useEffect(() => {
+    setData(value);
+    setChecked(!!value?.dynamicRtpPort);
+  }, [value]);
+
+  return (
+    <div style={{ display: 'flex', alignItems: 'center' }}>
+      <Input
+        style={{ maxWidth: 400 }}
+        placeholder="请输入RTP IP"
+        value={data?.rtpIp}
+        onChange={(e) => {
+          if (onChange) {
+            const item = {
+              ...data,
+              rtpIp: e.target.value,
+            };
+            setData(item);
+            onChange(item);
+          }
+        }}
+      />
+      {!checked ? (
+        <InputNumber
+          style={{ minWidth: 150, margin: '0 10px' }}
+          min={1}
+          max={65535}
+          value={data?.rtpPort}
+          placeholder="请输入端口"
+          onChange={(e) => {
+            if (onChange) {
+              const item = {
+                ...data,
+                rtpPort: e,
+              };
+              setData(item);
+              onChange(item);
+            }
+          }}
+        />
+      ) : (
+        <div style={{ margin: '0 10px' }}>
+          <InputNumber
+            style={{ minWidth: 150 }}
+            min={1}
+            max={65535}
+            placeholder="起始端口"
+            value={data?.dynamicRtpPortRange?.[0]}
+            onChange={(e) => {
+              if (onChange) {
+                const item = {
+                  ...data,
+                  dynamicRtpPortRange: [e, data?.dynamicRtpPortRange?.[1]],
+                };
+                setData(item);
+                onChange(item);
+              }
+            }}
+          />{' '}
+          至{' '}
+          <InputNumber
+            style={{ minWidth: 150 }}
+            min={1}
+            max={65535}
+            placeholder="终止端口"
+            value={data?.dynamicRtpPortRange?.[1]}
+            onChange={(e) => {
+              if (onChange) {
+                const item = {
+                  ...data,
+                  dynamicRtpPortRange: [data?.dynamicRtpPortRange?.[0], e],
+                };
+                setData(item);
+                onChange(item);
+              }
+            }}
+          />
+        </div>
+      )}
+
+      <Checkbox
+        checked={data?.dynamicRtpPort}
+        onChange={(e) => {
+          setChecked(e.target.checked);
+          if (onChange) {
+            const item = {
+              ...data,
+              dynamicRtpPort: e.target.checked,
+            };
+            setData(item);
+            onChange(item);
+          }
+        }}
+      >
+        动态端口
+      </Checkbox>
+    </div>
+  );
+};
+
+const Detail = () => {
+  const params = useParams<{ id: string }>();
+  const [form] = Form.useForm();
+  const [providers, setProviders] = useState<any[]>([]);
+
+  useEffect(() => {
+    service.queryProviders().then((resp) => {
+      if (resp.status === 200) {
+        setProviders(resp.result);
+      }
+    });
+    if (params.id && params.id !== ':id') {
+      service.detail(params.id).then((resp) => {
+        if (resp.status === 200) {
+          StreamModel.current = resp.result;
+          form.setFieldsValue({
+            name: StreamModel.current?.name,
+            provider: StreamModel.current?.provider,
+            secret: StreamModel.current?.configuration?.secret,
+            api: {
+              apiHost: StreamModel.current.configuration?.apiHost,
+              apiPort: StreamModel.current.configuration?.apiPort,
+            },
+            rtp: {
+              rtpIp: StreamModel.current.configuration?.rtpIp,
+              rtpPort: StreamModel.current.configuration?.rtpPort,
+              dynamicRtpPort: StreamModel.current.configuration?.dynamicRtpPort || false,
+              dynamicRtpPortRange: StreamModel.current.configuration?.dynamicRtpPortRange || [],
+            },
+          });
+        }
+      });
+    }
+  }, [params.id]);
+
+  const checkAPI = (_: any, value: { apiHost: string; apiPort: number }) => {
+    if (Number(value.apiPort) < 1 && Number(value.apiPort) > 65535) {
+      return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
+    }
+    if (!re.test(value.apiHost)) {
+      return Promise.reject(new Error('请输入正确的IP地址'));
+    }
+    return Promise.resolve();
+  };
+  const checkRIP = (
+    _: any,
+    value: {
+      rtpIp: string;
+      rtpPort: number;
+      dynamicRtpPort: boolean;
+      dynamicRtpPortRange: number[];
+    },
+  ) => {
+    if (!re.test(value.rtpIp)) {
+      return Promise.reject(new Error('请输入正确的IP地址'));
+    }
+    if (value.dynamicRtpPort) {
+      if (value.dynamicRtpPortRange) {
+        if (value.dynamicRtpPortRange?.[0]) {
+          if (
+            Number(value.dynamicRtpPortRange?.[0]) < 1 &&
+            Number(value.dynamicRtpPortRange?.[0]) > 65535
+          ) {
+            return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
+          }
+        }
+        if (value.dynamicRtpPortRange?.[1]) {
+          if (
+            Number(value.dynamicRtpPortRange?.[1]) < 1 &&
+            Number(value.dynamicRtpPortRange?.[1]) > 65535
+          ) {
+            return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
+          }
+        }
+        if (
+          value.dynamicRtpPortRange?.[0] &&
+          value.dynamicRtpPortRange?.[1] &&
+          value.dynamicRtpPortRange?.[0] > value.dynamicRtpPortRange?.[1]
+        ) {
+          return Promise.reject(new Error('终止端口需大于等于起始端'));
+        }
+      }
+    } else {
+      if (Number(value.rtpPort) < 1 && Number(value.rtpPort) > 65535) {
+        return Promise.reject(new Error('端口请输入1~65535之间的正整数'));
+      }
+    }
+    return Promise.resolve();
+  };
+
+  return (
+    <PageContainer>
+      <Card>
+        <Form
+          layout="vertical"
+          form={form}
+          onFinish={async (values: any) => {
+            const param = {
+              name: values.name,
+              provider: values.provider,
+              configuration: {
+                secret: values?.secret,
+                apiHost: values.api?.apiHost,
+                apiPort: values.api?.apiPort,
+                rtpIp: values.rtp?.rtpIp,
+                rtpPort: values.rtp?.rtpPort,
+                dynamicRtpPort: values.rtp?.dynamicRtpPort,
+                dynamicRtpPortRange: values.rtp?.dynamicRtpPortRange || [],
+              },
+            };
+            let resp = undefined;
+            if (params.id && params.id !== ':id') {
+              resp = await service.update({ ...param, id: params.id });
+            } else {
+              resp = await service.save(param);
+            }
+            if (resp && resp.status === 200) {
+              message.success('操作成功!');
+              history.back();
+            }
+          }}
+        >
+          <Row gutter={[16, 16]}>
+            <Col span={12}>
+              <Form.Item
+                label="流媒体名称"
+                name="name"
+                rules={[
+                  {
+                    required: true,
+                    message: '请输入流媒体名称',
+                  },
+                  {
+                    max: 64,
+                    message: '最多输入64个字符',
+                  },
+                ]}
+              >
+                <Input placeholder="请输入流媒体名称" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="服务商"
+                name="provider"
+                rules={[{ required: true, message: '请选择服务商' }]}
+              >
+                <Select placeholder="请选择服务商">
+                  {providers.map((item) => (
+                    <Select.Option key={item.id} value={item.id}>
+                      {item.name}
+                    </Select.Option>
+                  ))}
+                </Select>
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label="密钥"
+                name="secret"
+                rules={[
+                  {
+                    max: 64,
+                    message: '最多输入64个字符',
+                  },
+                ]}
+              >
+                <Input.Password placeholder="请输入密钥" />
+              </Form.Item>
+            </Col>
+            <Col span={12}>
+              <Form.Item
+                label={
+                  <span>
+                    API Host
+                    <Tooltip style={{ marginLeft: 5 }} title="调用流媒体接口时请求的服务地址">
+                      <QuestionCircleOutlined />
+                    </Tooltip>
+                  </span>
+                }
+                name="api"
+                rules={[{ required: true, message: '请输入API Host' }, { validator: checkAPI }]}
+              >
+                <APIComponent />
+              </Form.Item>
+            </Col>
+            <Col span={24}>
+              <Form.Item
+                label={
+                  <span>
+                    RTP IP
+                    <Tooltip
+                      style={{ marginLeft: 5 }}
+                      title="视频设备将流推送到该IP地址下,部分设备仅支持IP地址,建议全是用IP地址"
+                    >
+                      <QuestionCircleOutlined />
+                    </Tooltip>
+                  </span>
+                }
+                name="rtp"
+                rules={[{ required: true, message: '请输入RTP IP' }, { validator: checkRIP }]}
+              >
+                <RTPComponent />
+              </Form.Item>
+            </Col>
+            <Col span={24}>
+              <Form.Item>
+                <Button type="primary" htmlType="submit">
+                  保存
+                </Button>
+              </Form.Item>
+            </Col>
+          </Row>
+        </Form>
+      </Card>
+    </PageContainer>
+  );
+};
+
+export default Detail;

+ 31 - 0
src/pages/media/Stream/index.less

@@ -0,0 +1,31 @@
+.card {
+  .header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    width: 100%;
+    .title {
+      width: '50%';
+      overflow: hidden;
+      font-weight: 800;
+      white-space: nowrap;
+      text-overflow: ellipsis;
+    }
+    .title::before {
+      margin-right: 10px;
+      background-color: #2810ff;
+      content: '|';
+    }
+  }
+
+  .content {
+    display: flex;
+    justify-content: space-between;
+    width: 100%;
+    margin-top: 10px;
+    .item {
+      display: flex;
+      flex-direction: column;
+    }
+  }
+}

+ 184 - 0
src/pages/media/Stream/index.tsx

@@ -0,0 +1,184 @@
+import { PageContainer } from '@ant-design/pro-layout';
+import type { StreamItem } from '@/pages/media/Stream/typings';
+import SearchComponent from '@/components/SearchComponent';
+import type { ProColumns } from '@jetlinks/pro-table';
+import { Button, Card, Col, Empty, message, Pagination, Popconfirm, Row, Space } from 'antd';
+import { useEffect, useState } from 'react';
+import Service from '@/pages/media/Stream/service';
+import { getMenuPathByParams, MENUS_CODE } from '@/utils/menu';
+import { useHistory } from 'umi';
+import styles from './index.less';
+import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
+import { model } from '@formily/reactive';
+
+export const service = new Service('media/server');
+
+export const StreamModel = model<{
+  current: Partial<StreamItem>;
+}>({
+  current: {},
+});
+
+const Stream = () => {
+  const history = useHistory<Record<string, string>>();
+  const [param, setParam] = useState<any>({ pageSize: 10, terms: [] });
+
+  const columns: ProColumns<StreamItem>[] = [
+    {
+      dataIndex: 'name',
+      title: '名称',
+      ellipsis: true,
+    },
+    {
+      dataIndex: 'description',
+      title: '说明',
+      ellipsis: true,
+    },
+  ];
+
+  const [dataSource, setDataSource] = useState<any>({
+    data: [],
+    pageSize: 10,
+    pageIndex: 0,
+    total: 0,
+  });
+
+  const handleSearch = (params?: any) => {
+    service
+      .query({
+        ...params,
+        sorts: [
+          {
+            name: 'id',
+            order: 'desc',
+          },
+        ],
+      })
+      .then((resp) => {
+        if (resp.status === 200) {
+          setDataSource(resp.result);
+        }
+      });
+  };
+
+  useEffect(() => {
+    handleSearch(param);
+  }, [param]);
+
+  return (
+    <PageContainer>
+      <SearchComponent<StreamItem>
+        field={columns}
+        target="stream"
+        onSearch={(data) => {
+          setParam({
+            ...param,
+            terms: data?.terms ? [...data?.terms] : [],
+          });
+        }}
+      />
+      <Card>
+        <Button
+          type="primary"
+          onClick={() => {
+            history.push(`${getMenuPathByParams(MENUS_CODE['media/Stream/Detail'])}`);
+            StreamModel.current = {};
+          }}
+        >
+          新增
+        </Button>
+        {dataSource.data.length > 0 ? (
+          <>
+            <Row gutter={[16, 16]} style={{ marginTop: 10 }}>
+              {(dataSource?.data || []).map((item: any) => (
+                <Col key={item.id} span={12}>
+                  <Card hoverable>
+                    <div className={styles.card}>
+                      <div className={styles.header}>
+                        <div className={styles.title}>{item?.name}</div>
+                        <div className={styles.actions}>
+                          <Space>
+                            <a
+                              onClick={() => {
+                                history.push(
+                                  `${getMenuPathByParams(
+                                    MENUS_CODE['media/Stream/Detail'],
+                                    item.id,
+                                  )}`,
+                                );
+                                StreamModel.current = { ...item };
+                              }}
+                            >
+                              <EditOutlined />
+                              编辑
+                            </a>
+                            <Popconfirm
+                              title={'确认删除?'}
+                              onConfirm={() => {
+                                service.remove(item.id).then((resp: any) => {
+                                  if (resp.status === 200) {
+                                    message.success('操作成功!');
+                                    handleSearch({ pageSize: 10, terms: [] });
+                                  }
+                                });
+                              }}
+                            >
+                              <a>
+                                <DeleteOutlined />
+                                删除
+                              </a>
+                            </Popconfirm>
+                          </Space>
+                        </div>
+                      </div>
+                      <div className={styles.content}>
+                        <div className={styles.item}>
+                          <div className={styles.itemTitle}>服务商</div>
+                          <p>{item?.provider}</p>
+                        </div>
+                        <div className={styles.item}>
+                          <div className={styles.itemTitle}>RTP IP</div>
+                          <p>{item?.configuration?.rtpIp}</p>
+                        </div>
+                        <div className={styles.item}>
+                          <div className={styles.itemTitle}>API HOST</div>
+                          <p>{item?.configuration?.apiHost}</p>
+                        </div>
+                      </div>
+                    </div>
+                  </Card>
+                </Col>
+              ))}
+            </Row>
+            <div style={{ display: 'flex', marginTop: 20, justifyContent: 'flex-end' }}>
+              <Pagination
+                showSizeChanger
+                size="small"
+                className={'pro-table-card-pagination'}
+                total={dataSource?.total || 0}
+                current={dataSource?.pageIndex + 1}
+                onChange={(page, size) => {
+                  handleSearch({
+                    ...param,
+                    pageIndex: page - 1,
+                    pageSize: size,
+                  });
+                }}
+                pageSizeOptions={[10, 20, 50, 100]}
+                pageSize={dataSource?.pageSize}
+                showTotal={(num) => {
+                  const minSize = dataSource?.pageIndex * dataSource?.pageSize + 1;
+                  const MaxSize = (dataSource?.pageIndex + 1) * dataSource?.pageSize;
+                  return `第 ${minSize} - ${MaxSize > num ? num : MaxSize} 条/总共 ${num} 条`;
+                }}
+              />
+            </div>
+          </>
+        ) : (
+          <Empty />
+        )}
+      </Card>
+    </PageContainer>
+  );
+};
+export default Stream;

+ 13 - 0
src/pages/media/Stream/service.ts

@@ -0,0 +1,13 @@
+import BaseService from '@/utils/BaseService';
+import { request } from 'umi';
+import SystemConst from '@/utils/const';
+import type { StreamItem } from './typings';
+
+class Service extends BaseService<StreamItem> {
+  queryProviders = () =>
+    request(`/${SystemConst.API_BASE}/media/server/providers`, {
+      method: 'GET',
+    });
+}
+
+export default Service;

+ 15 - 0
src/pages/media/Stream/typings.d.ts

@@ -0,0 +1,15 @@
+import type { BaseItem } from '@/utils/typings';
+
+type StreamItem = {
+  description: string;
+  provider: string;
+  configuration: {
+    secret?: string;
+    apiHost: string;
+    apiPort: number;
+    rtpIp: string;
+    rtpPort: number;
+    dynamicRtpPort: boolean;
+    dynamicRtpPortRange: number[];
+  };
+} & BaseItem;

+ 3 - 1
src/utils/menu/router.ts

@@ -44,7 +44,8 @@ export const MENUS_CODE = {
   'media/Config': 'media/Config',
   'media/Device': 'media/Device',
   'media/Reveal': 'media/Reveal',
-
+  'media/Stream': 'media/Stream',
+  'media/Stream/Detail': 'media/Stream/Detail',
   'notice/Type': 'notice/Type',
   'media/SplitScreen': 'media/SplitScreen',
   'notice/Config': 'notice/Config',
@@ -107,4 +108,5 @@ export const getDetailNameByCode = {
   'system/Role/Detail': '权限配置',
   'link/Type/Detail': '网络组件详情',
   'link/AccessConfig/Detail': '配置详情',
+  'media/Stream/Detail': '流媒体详情',
 };