|
|
@@ -0,0 +1,449 @@
|
|
|
+import React, { useEffect, useMemo, useState } from 'react';
|
|
|
+import { Space, Table, Button, Input, Select, DatePicker, message, Typography, Spin, Popconfirm, Modal, InputNumber } from 'antd';
|
|
|
+import { DownloadOutlined } from '@ant-design/icons';
|
|
|
+import type { TableProps } from 'antd';
|
|
|
+import dayjs, { Dayjs } from 'dayjs';
|
|
|
+import copy from 'copy-to-clipboard';
|
|
|
+import { QRCodeSVG } from 'qrcode.react';
|
|
|
+import modal from 'antd/es/modal';
|
|
|
+import styles from './index.module.css';
|
|
|
+import XcxPublishPlanModal from './components/publishPlanModal';
|
|
|
+import VideoPlayModal from '@src/views/publishContent/weCom/components/videoPlayModal';
|
|
|
+import { useXcxPlanList, XcxPlanDataType } from './hooks/useXcxPlanList';
|
|
|
+import { useAudiencePackageOptions } from './hooks/useAudiencePackageOptions';
|
|
|
+import { VideoItem } from '@src/views/publishContent/weGZH/components/types';
|
|
|
+import http from '@src/http';
|
|
|
+import { deleteXcxPlanApi, getShareQrLink, saveXcxPlanApi, xcxPlanExportApi, xcxPlanMultiLinkApi } from '@src/http/api';
|
|
|
+import { getUserInfo } from '@src/http/sso';
|
|
|
+
|
|
|
+const { RangePicker } = DatePicker;
|
|
|
+const TableHeight = window.innerHeight - 380;
|
|
|
+
|
|
|
+const XcxTouliuContent: React.FC = () => {
|
|
|
+ const [audiencePackage, setAudiencePackage] = useState<string>('');
|
|
|
+ const [videoTitle, setVideoTitle] = useState<string>('');
|
|
|
+ const [dateRange, setDateRange] = useState<[Dayjs | null, Dayjs | null]>();
|
|
|
+ const [isShowAddPunlishPlan, setIsShowAddPunlishPlan] = useState<boolean>(false);
|
|
|
+ const [isSubmiting, setIsSubmiting] = useState<boolean>(false);
|
|
|
+ const [isLoading, setIsLoading] = useState<boolean>(false);
|
|
|
+ const [pageNum, setPageNum] = useState<number>(1);
|
|
|
+ const [pageSize, setPageSize] = useState<number>(10);
|
|
|
+ const [playingPlan, setPlayingPlan] = useState<XcxPlanDataType | null>(null);
|
|
|
+ const [multiLinkPlan, setMultiLinkPlan] = useState<XcxPlanDataType | null>(null);
|
|
|
+ const [multiLinkCount, setMultiLinkCount] = useState<number>(10);
|
|
|
+ const [multiLinkLoading, setMultiLinkLoading] = useState<boolean>(false);
|
|
|
+ const [isExportModalVisible, setIsExportModalVisible] = useState<boolean>(false);
|
|
|
+ const [exportDateRange, setExportDateRange] = useState<[Dayjs, Dayjs] | undefined>();
|
|
|
+ const [isExporting, setIsExporting] = useState<boolean>(false);
|
|
|
+ const { xcxPlanList, getXcxPlanList, totalSize } = useXcxPlanList();
|
|
|
+ const { options: audiencePackageOptions } = useAudiencePackageOptions();
|
|
|
+ const userType = useMemo(() => getUserInfo()?.type, []);
|
|
|
+ const canMultiLink = userType === 2 || userType === 3;
|
|
|
+
|
|
|
+ const columns: TableProps<XcxPlanDataType>['columns'] = [
|
|
|
+ {
|
|
|
+ title: '人群包',
|
|
|
+ dataIndex: 'audiencePackage',
|
|
|
+ key: 'audiencePackage',
|
|
|
+ width: 200,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '视频标题',
|
|
|
+ dataIndex: 'title',
|
|
|
+ key: 'title',
|
|
|
+ ellipsis: true,
|
|
|
+ render: (_, record) => (
|
|
|
+ <Typography.Paragraph
|
|
|
+ style={{ maxWidth: '300px', marginBottom: 0 }}
|
|
|
+ ellipsis={{ rows: 2, tooltip: true }}
|
|
|
+ >
|
|
|
+ {record.title}
|
|
|
+ </Typography.Paragraph>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '封面',
|
|
|
+ dataIndex: 'cover',
|
|
|
+ key: 'cover',
|
|
|
+ width: 110,
|
|
|
+ render: (_, record) =>
|
|
|
+ record.cover ? (
|
|
|
+ <img src={record.cover} referrerPolicy="no-referrer" className="w-[80px] h-auto" />
|
|
|
+ ) : '-',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '备注',
|
|
|
+ dataIndex: 'remark',
|
|
|
+ key: 'remark',
|
|
|
+ width: 180,
|
|
|
+ ellipsis: true,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '计划创建时间',
|
|
|
+ dataIndex: 'createTimestamp',
|
|
|
+ key: 'createTimestamp',
|
|
|
+ width: 180,
|
|
|
+ render: (_, record) =>
|
|
|
+ record.createTimestamp ? dayjs(record.createTimestamp).format('YYYY-MM-DD HH:mm:ss') : '',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ title: '操作',
|
|
|
+ key: 'action',
|
|
|
+ fixed: 'right',
|
|
|
+ width: 560,
|
|
|
+ render: (_, record) => (
|
|
|
+ <Space size="middle">
|
|
|
+ <Button type="link" onClick={() => setPlayingPlan(record)}>播放</Button>
|
|
|
+ <Button type="link" onClick={() => downloadFile(record.shareCover || record.cover, (record.title || 'cover') + '_cover')}>下载封面</Button>
|
|
|
+ <Button type="link" onClick={() => showQrCodeModal(record.pageUrl)}>二维码</Button>
|
|
|
+ <Button type="link" onClick={() => copyToClipboard(record.pageUrl)}>复制链接</Button>
|
|
|
+ {canMultiLink && (
|
|
|
+ <Button type="link" onClick={() => openMultiLinkModal(record)}>复制多链接</Button>
|
|
|
+ )}
|
|
|
+ <Popconfirm
|
|
|
+ title="确定删除该计划吗?"
|
|
|
+ okText="确定"
|
|
|
+ cancelText="取消"
|
|
|
+ onConfirm={() => deletePlan(record)}
|
|
|
+ >
|
|
|
+ <Button type="link">删除</Button>
|
|
|
+ </Popconfirm>
|
|
|
+ </Space>
|
|
|
+ ),
|
|
|
+ },
|
|
|
+ ];
|
|
|
+
|
|
|
+ const copyToClipboard = (text: string) => {
|
|
|
+ if (!text) {
|
|
|
+ message.warning('暂无链接');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ copy(text);
|
|
|
+ message.success('复制成功');
|
|
|
+ };
|
|
|
+
|
|
|
+ const downloadFile = (url: string, filename: string) => {
|
|
|
+ if (!url) {
|
|
|
+ message.warning('无可用文件下载');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const link = document.createElement('a');
|
|
|
+ link.href = url;
|
|
|
+ link.download = filename || url.substring(url.lastIndexOf('/') + 1);
|
|
|
+ link.target = '_blank';
|
|
|
+ link.rel = 'noopener noreferrer';
|
|
|
+ document.body.appendChild(link);
|
|
|
+ link.click();
|
|
|
+ document.body.removeChild(link);
|
|
|
+ message.success('开始下载...');
|
|
|
+ };
|
|
|
+
|
|
|
+ const showQrCodeModal = (pageUrl: string) => {
|
|
|
+ if (!pageUrl) {
|
|
|
+ message.warning('暂无链接');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ http.get<string>(getShareQrLink, { params: { pageUrl } }).then(res => {
|
|
|
+ modal.info({
|
|
|
+ title: '二维码',
|
|
|
+ content: <QRCodeSVG value={res.data} size={256} />,
|
|
|
+ });
|
|
|
+ }).catch(err => {
|
|
|
+ message.error(err?.msg || '获取二维码失败');
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const openMultiLinkModal = (record: XcxPlanDataType) => {
|
|
|
+ setMultiLinkPlan(record);
|
|
|
+ setMultiLinkCount(10);
|
|
|
+ };
|
|
|
+
|
|
|
+ const closeMultiLinkModal = () => {
|
|
|
+ if (multiLinkLoading) return;
|
|
|
+ setMultiLinkPlan(null);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleMultiLink = async () => {
|
|
|
+ if (!multiLinkPlan) return;
|
|
|
+ if (!multiLinkCount || multiLinkCount < 1 || multiLinkCount > 200) {
|
|
|
+ message.warning('生成数量必须在 1-200 之间');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setMultiLinkLoading(true);
|
|
|
+ try {
|
|
|
+ const res = await http.post<XcxPlanDataType[]>(xcxPlanMultiLinkApi, {
|
|
|
+ planId: multiLinkPlan.id,
|
|
|
+ count: multiLinkCount,
|
|
|
+ });
|
|
|
+ if (res?.code === 0 && Array.isArray(res.data) && res.data.length > 0) {
|
|
|
+ const links = res.data.map((p) => p.pageUrl).filter(Boolean).join('\n');
|
|
|
+ if (links && copy(links)) {
|
|
|
+ message.success(`已生成 ${res.data.length} 条并复制到剪贴板`);
|
|
|
+ } else {
|
|
|
+ message.warning('生成成功但复制失败,请手动到列表中获取');
|
|
|
+ }
|
|
|
+ setMultiLinkPlan(null);
|
|
|
+ refresh();
|
|
|
+ } else {
|
|
|
+ message.error(res?.msg || '生成失败');
|
|
|
+ }
|
|
|
+ } catch (err: any) {
|
|
|
+ message.error(err?.msg || '生成失败');
|
|
|
+ } finally {
|
|
|
+ setMultiLinkLoading(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const deletePlan = async (record: XcxPlanDataType) => {
|
|
|
+ setIsLoading(true);
|
|
|
+ const res = await http
|
|
|
+ .post(deleteXcxPlanApi, { id: record.id })
|
|
|
+ .catch((err) => {
|
|
|
+ message.error(err?.msg || '删除失败');
|
|
|
+ });
|
|
|
+ if (res?.code === 0) {
|
|
|
+ message.success('删除成功');
|
|
|
+ refresh();
|
|
|
+ } else if (res) {
|
|
|
+ message.error(res?.msg || '删除失败');
|
|
|
+ }
|
|
|
+ setIsLoading(false);
|
|
|
+ };
|
|
|
+
|
|
|
+ const addPunlishPlan = () => {
|
|
|
+ setIsShowAddPunlishPlan(true);
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleExport = () => {
|
|
|
+ setIsExporting(true);
|
|
|
+ http.post<string>(xcxPlanExportApi, {
|
|
|
+ audiencePackage: audiencePackage || undefined,
|
|
|
+ title: videoTitle || undefined,
|
|
|
+ createTimestampStart: exportDateRange?.[0] ? exportDateRange[0].startOf('day').valueOf() : undefined,
|
|
|
+ createTimestampEnd: exportDateRange?.[1] ? exportDateRange[1].add(1, 'day').startOf('day').valueOf() : undefined,
|
|
|
+ }).then(res => {
|
|
|
+ if (res.code === 0 && res.data) {
|
|
|
+ const a = document.createElement('a');
|
|
|
+ a.href = res.data;
|
|
|
+ a.target = '_blank';
|
|
|
+ a.rel = 'noopener noreferrer';
|
|
|
+ document.body.appendChild(a);
|
|
|
+ a.click();
|
|
|
+ document.body.removeChild(a);
|
|
|
+ message.success('已开始下载');
|
|
|
+ setIsExportModalVisible(false);
|
|
|
+ setExportDateRange(undefined);
|
|
|
+ } else {
|
|
|
+ message.error(res.msg || '导出失败');
|
|
|
+ }
|
|
|
+ }).catch(err => {
|
|
|
+ message.error(err?.msg || '导出失败');
|
|
|
+ }).finally(() => {
|
|
|
+ setIsExporting(false);
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleAddPunlishPlan = async (
|
|
|
+ params: XcxPlanDataType & { videoList: VideoItem[] }
|
|
|
+ ) => {
|
|
|
+ setIsSubmiting(true);
|
|
|
+ const payload = {
|
|
|
+ audiencePackage: params.audiencePackage,
|
|
|
+ remark: params.remark,
|
|
|
+ videoList: (params.videoList || []).map((v) => ({
|
|
|
+ videoId: v.videoId,
|
|
|
+ title: v.customTitle || v.title,
|
|
|
+ cover: v.customCover || v.cover,
|
|
|
+ video: v.video,
|
|
|
+ experimentId: v.experimentId,
|
|
|
+ })),
|
|
|
+ };
|
|
|
+ const res = await http
|
|
|
+ .post<XcxPlanDataType>(saveXcxPlanApi, payload)
|
|
|
+ .catch((err) => {
|
|
|
+ message.error(err?.msg || '保存失败');
|
|
|
+ })
|
|
|
+ .finally(() => {
|
|
|
+ setIsSubmiting(false);
|
|
|
+ });
|
|
|
+ if (res?.code === 0) {
|
|
|
+ message.success('发布计划保存成功');
|
|
|
+ setIsShowAddPunlishPlan(false);
|
|
|
+ refresh();
|
|
|
+ } else if (res) {
|
|
|
+ message.error(res?.msg || '保存失败');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const refresh = (overridePage?: number) => {
|
|
|
+ const targetPage = overridePage ?? pageNum;
|
|
|
+ getXcxPlanList({
|
|
|
+ pageNum: targetPage,
|
|
|
+ pageSize,
|
|
|
+ audiencePackage: audiencePackage || undefined,
|
|
|
+ title: videoTitle || undefined,
|
|
|
+ createTimestampStart: dateRange?.[0]?.unix() ? dateRange[0].unix() * 1000 : undefined,
|
|
|
+ createTimestampEnd: dateRange?.[1]?.unix() ? dateRange[1].unix() * 1000 : undefined,
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ getXcxPlanList({ pageNum: 1, pageSize });
|
|
|
+ // eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ const handleSearch = () => {
|
|
|
+ setPageNum(1);
|
|
|
+ refresh(1);
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <Spin spinning={isLoading || isSubmiting}>
|
|
|
+ <div className="rounded-lg">
|
|
|
+ <div className="text-lg font-medium mb-3">小程序投流</div>
|
|
|
+
|
|
|
+ <div className="flex flex-wrap gap-4 mb-3">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-gray-600">人群包:</span>
|
|
|
+ <Select
|
|
|
+ placeholder="选择人群包"
|
|
|
+ style={{ width: 200 }}
|
|
|
+ value={audiencePackage || undefined}
|
|
|
+ allowClear
|
|
|
+ showSearch
|
|
|
+ filterOption={(input, option) =>
|
|
|
+ (option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())
|
|
|
+ }
|
|
|
+ options={audiencePackageOptions.map((v) => ({ label: v, value: v }))}
|
|
|
+ onChange={(v) => setAudiencePackage(v || '')}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span className="text-gray-600">视频标题:</span>
|
|
|
+ <Input
|
|
|
+ placeholder="搜索视频标题"
|
|
|
+ style={{ width: 200 }}
|
|
|
+ value={videoTitle}
|
|
|
+ allowClear
|
|
|
+ onPressEnter={handleSearch}
|
|
|
+ onChange={(e) => setVideoTitle(e.target.value)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <RangePicker
|
|
|
+ placeholder={['开始时间', '结束时间']}
|
|
|
+ style={{ width: 270 }}
|
|
|
+ allowClear
|
|
|
+ value={dateRange}
|
|
|
+ onChange={(dates) => setDateRange(dates || undefined)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Button type="primary" className="ml-2" onClick={handleSearch}>搜索</Button>
|
|
|
+ <Button type="primary" onClick={addPunlishPlan}>+ 创建发布</Button>
|
|
|
+ <Button type="primary" icon={<DownloadOutlined />} onClick={() => setIsExportModalVisible(true)}>已选视频导出</Button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <Table
|
|
|
+ rowKey={(record) => record.id}
|
|
|
+ className={styles.antTable}
|
|
|
+ columns={columns}
|
|
|
+ dataSource={xcxPlanList}
|
|
|
+ scroll={{ x: 'max-content', y: TableHeight }}
|
|
|
+ pagination={{
|
|
|
+ total: totalSize,
|
|
|
+ pageSize,
|
|
|
+ current: pageNum,
|
|
|
+ showTotal: (total) => `共 ${total} 条`,
|
|
|
+ onChange: (page, size) => {
|
|
|
+ setPageNum(page);
|
|
|
+ setPageSize(size);
|
|
|
+ getXcxPlanList({
|
|
|
+ pageNum: page,
|
|
|
+ pageSize: size,
|
|
|
+ audiencePackage: audiencePackage || undefined,
|
|
|
+ title: videoTitle || undefined,
|
|
|
+ createTimestampStart: dateRange?.[0]?.unix() ? dateRange[0].unix() * 1000 : undefined,
|
|
|
+ createTimestampEnd: dateRange?.[1]?.unix() ? dateRange[1].unix() * 1000 : undefined,
|
|
|
+ });
|
|
|
+ },
|
|
|
+ }}
|
|
|
+ />
|
|
|
+
|
|
|
+ <XcxPublishPlanModal
|
|
|
+ visible={isShowAddPunlishPlan}
|
|
|
+ onCancel={() => setIsShowAddPunlishPlan(false)}
|
|
|
+ onOk={handleAddPunlishPlan}
|
|
|
+ isSubmiting={isSubmiting}
|
|
|
+ />
|
|
|
+
|
|
|
+ <VideoPlayModal
|
|
|
+ visible={!!playingPlan}
|
|
|
+ onClose={() => setPlayingPlan(null)}
|
|
|
+ videoUrl={playingPlan?.video || ''}
|
|
|
+ title={playingPlan?.title || ''}
|
|
|
+ />
|
|
|
+
|
|
|
+ <Modal
|
|
|
+ title="批量生成链接"
|
|
|
+ open={!!multiLinkPlan}
|
|
|
+ onCancel={closeMultiLinkModal}
|
|
|
+ onOk={handleMultiLink}
|
|
|
+ confirmLoading={multiLinkLoading}
|
|
|
+ maskClosable={false}
|
|
|
+ closable={!multiLinkLoading}
|
|
|
+ okText="生成并复制"
|
|
|
+ cancelText="取消"
|
|
|
+ destroyOnClose
|
|
|
+ >
|
|
|
+ <div className="text-gray-600 text-sm mb-3">
|
|
|
+ 将基于该计划生成 N 份相同视频/人群包的新计划,生成完成后所有链接将自动复制到剪贴板。
|
|
|
+ </div>
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <span>生成数量:</span>
|
|
|
+ <InputNumber
|
|
|
+ min={1}
|
|
|
+ max={200}
|
|
|
+ value={multiLinkCount}
|
|
|
+ onChange={(v) => setMultiLinkCount(Number(v) || 1)}
|
|
|
+ disabled={multiLinkLoading}
|
|
|
+ />
|
|
|
+ <span className="text-gray-400 text-xs">(1-200)</span>
|
|
|
+ </div>
|
|
|
+ {multiLinkLoading && (
|
|
|
+ <div className="text-orange-500 text-xs mt-3">
|
|
|
+ 链接生成耗时较长,请勿关闭窗口...
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </Modal>
|
|
|
+
|
|
|
+ <Modal
|
|
|
+ title="已选视频导出"
|
|
|
+ open={isExportModalVisible}
|
|
|
+ onCancel={() => setIsExportModalVisible(false)}
|
|
|
+ onOk={handleExport}
|
|
|
+ confirmLoading={isExporting}
|
|
|
+ okText="确认导出"
|
|
|
+ cancelText="取消"
|
|
|
+ >
|
|
|
+ <div className="flex items-center gap-2 my-4">
|
|
|
+ <span className="text-gray-600">日期范围:</span>
|
|
|
+ <RangePicker
|
|
|
+ placeholder={['开始日期', '结束日期']}
|
|
|
+ value={exportDateRange}
|
|
|
+ onChange={(dates) => setExportDateRange(dates as [Dayjs, Dayjs] | undefined)}
|
|
|
+ allowClear
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div className="text-gray-400 text-sm">
|
|
|
+ 不选日期默认导出当天数据;最多导出 2000 条;会沿用当前页的「人群包 / 视频标题」筛选条件。
|
|
|
+ </div>
|
|
|
+ </Modal>
|
|
|
+ </div>
|
|
|
+ </Spin>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default XcxTouliuContent;
|