Explorar el Código

Merge branch 'feature_260509_replace_upload_new_datasource' of Web/contentCooper into master

liulidong hace 3 días
padre
commit
b3c76280a6

+ 6 - 0
src/assets/images/publishContent/wxXcx.svg

@@ -0,0 +1,6 @@
+<?xml version="1.0" standalone="no"?>
+<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="64" height="64">
+  <path d="M704 128H256C185.6 128 128 185.6 128 256v512c0 70.4 57.6 128 128 128h512c70.4 0 128-57.6 128-128V416c0-17.7-14.3-32-32-32s-32 14.3-32 32v352c0 35.3-28.7 64-64 64H256c-35.3 0-64-28.7-64-64V256c0-35.3 28.7-64 64-64h448c17.7 0 32-14.3 32-32s-14.3-32-32-32z"/>
+  <path d="M832 224a96 96 0 1 1-192 0 96 96 0 0 1 192 0z"/>
+  <path d="M320 480c-17.7 0-32 14.3-32 32s14.3 32 32 32h384c17.7 0 32-14.3 32-32s-14.3-32-32-32H320zM320 640c-17.7 0-32 14.3-32 32s14.3 32 32 32h256c17.7 0 32-14.3 32-32s-14.3-32-32-32H320z"/>
+</svg>

+ 12 - 6
src/components/layout/sidebar.tsx

@@ -3,27 +3,32 @@ import { Badge, Layout, Menu } from 'antd';
 import { AdminRouterItem, routes } from '../../router';
 import { useLocation, useNavigate } from 'react-router-dom';
 import useMessagesHook from '@src/hooks/messages';
+import { getUserInfo } from '@src/http/sso';
 
 const { Sider } = Layout;
 
-const getMenuItems = (routes: AdminRouterItem[], notReadMessageCount: number): any[] => {
+const getMenuItems = (routes: AdminRouterItem[], notReadMessageCount: number, userType?: number): any[] => {
   return routes.map(itm => {
     if (!itm.meta) return null
+    if (itm.requireType != null) {
+      const allowed = Array.isArray(itm.requireType) ? itm.requireType : [itm.requireType]
+      if (userType == null || !allowed.includes(userType)) return null
+    }
     let children: any[] = []
-    if (itm.children) children = getMenuItems(itm.children, notReadMessageCount)
-    
+    if (itm.children) children = getMenuItems(itm.children, notReadMessageCount, userType)
+
     const menuItem = children.length > 0 ? {
       ...itm.meta,
       children
     } : {
       ...itm.meta,
       path: itm.path,
-			}    
+			}
     // 为消息菜单添加未读数量徽章
     if (menuItem.key === '/messages' && notReadMessageCount > 0) {
       menuItem.extra = <Badge count={notReadMessageCount} />
     }
-    
+
     return menuItem
   }).filter(itm => !!itm)
 }
@@ -45,7 +50,8 @@ const PageSidebar = (props: {
   const location = useLocation()
 
 	useEffect(() => {
-		const _menuItems = getMenuItems(routes, notReadMessageCount)
+		const userType = getUserInfo()?.type
+		const _menuItems = getMenuItems(routes, notReadMessageCount, userType)
 		setMenuItems(_menuItems)
 	}, [notReadMessageCount])
 

+ 9 - 0
src/http/api.ts

@@ -40,6 +40,15 @@ export const getDemandVideoContentListApi = `${import.meta.env.VITE_API_URL}/con
 export const getShareQrPic = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/qw/getSharePic`
 export const getShareQrLink = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/getShareUrlLink`
 
+/* 小程序投流(仅内部账号) */
+export const getXcxPlanListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/xcx/list`
+export const saveXcxPlanApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/xcx/save`
+export const deleteXcxPlanApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/xcx/delete`
+export const xcxPlanMultiLinkApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/xcx/multiLink`
+export const xcxPlanExportApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/xcx/export`
+export const getXcxSharePicApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/xcx/getSharePic`
+export const getXcxAudiencePackageListApi = `${import.meta.env.VITE_API_URL}/contentPlatform/plan/xcx/getAudiencePackageList`
+
 /* 数据统计 */
 export const gzhDataList = `${import.meta.env.VITE_API_URL}/contentPlatform/datastat/gzh`
 export const gzhDataExport = `${import.meta.env.VITE_API_URL}/contentPlatform/datastat/gzh/export`

+ 3 - 2
src/http/sso.ts

@@ -5,7 +5,7 @@ import {
   userLogin, sendVerificationCode, loginPhone,
 } from './api.ts'
 
-type UserInfo = { 
+type UserInfo = {
 	channel: string,
 	contactName: string,
 	createTimestamp: number,
@@ -13,7 +13,8 @@ type UserInfo = {
 	identity: number,
 	name: string,
 	telNum: string,
-	token: string
+	token: string,
+	type?: number
 }
 
 export function getUserInfo(): UserInfo {

+ 4 - 1
src/router/index.tsx

@@ -12,7 +12,10 @@ export type AdminRouterItem = RouteObject & {
   meta?: MenuItemType
 	children?: AdminRouterItem[],
 	sortKey?: number,
-	extra?: any
+	extra?: any,
+	// 仅当登录用户的 ContentPlatformAccount.type 命中时显示此菜单
+	// 支持单值或数组,数组语义为"任一命中即可"
+	requireType?: number | number[]
 }
 
 /**

+ 13 - 0
src/views/publishContent/publishContent.router.tsx

@@ -3,12 +3,14 @@ import { AdminRouterItem } from "../../router";
 import React, { Suspense } from 'react';
 import WeComIcon from "@src/assets/images/publishContent/wxCom.svg?react";
 import WeGZHIcon from "@src/assets/images/publishContent/wxGzh.svg?react";
+import XcxIcon from "@src/assets/images/publishContent/wxXcx.svg?react";
 import LogoIcon from "@src/assets/images/login/logo.svg?react";
 import { Outlet } from "react-router-dom";
 
 // Lazy load components
 const WeCom = React.lazy(() => import('./weCom/index'));
 const WeGZH = React.lazy(() => import('./weGZH/index'));
+const XcxTouliu = React.lazy(() => import('./xcxTouliu/index'));
 
 // Loading fallback component
 // eslint-disable-next-line react-refresh/only-export-components
@@ -59,6 +61,17 @@ const demoRoutes: AdminRouterItem[] = [
 					icon: <Icon component={WeComIcon} className="!text-[20px]"/>,
 				}
 			},
+			{
+				path: 'xcxTouliu',
+				element: <LazyComponent Component={XcxTouliu} />,
+				requireType: [2, 3],
+				meta: {
+					label: "小程序投流",
+					title: "小程序投流",
+					key: "/publishContent/xcxTouliu",
+					icon: <Icon component={XcxIcon} className="!text-[20px]" />,
+				}
+			},
 		]
 	}
 ]

+ 1 - 0
src/views/publishContent/weCom/type.ts

@@ -26,6 +26,7 @@ export enum VideoSearchPlanType {
 	企微社群 = 2,
 	企微自动回复 = 3,
 	公众号推送 = 4,
+	小程序投流 = 5,
 }
 
 export interface WeVideoItem {

+ 1 - 1
src/views/publishContent/weGZH/components/publishPlanModal/index.tsx

@@ -326,7 +326,7 @@ const AddPunlishPlanModal: React.FC<AddPunlishPlanModalProps> = ({ visible, isSu
 									))}
 		
 									{/* Add Video Button - Conditionally Rendered */}
-									{selectedVideos.length < 20 && (
+									{selectedVideos.length < (planType === GzhPlanType.自动回复 ? 3 : 20) && (
 										<div
 											className={`w-[240px] h-[316px] flex flex-col justify-center items-center border border-dashed rounded ${accountId ? 'border-gray-300 cursor-pointer dark:border-gray-600 hover:border-blue-500 hover:text-blue-500' : 'border-gray-200 text-gray-300 cursor-not-allowed bg-gray-50'}`}
 											onClick={openVideoSelector} // Open the drawer on click

+ 7 - 2
src/views/publishContent/weGZH/components/videoSelectModal/index.tsx

@@ -113,9 +113,11 @@ interface VideoSelectModalProps {
 	initialSelectedIds?: number[];
 	selectedVideos?: VideoItem[];
 	ghName?: string;
+	// 小程序投流入口:按人群包过滤候选(只影响 demand 来源池 prior/posterior)
+	crowdPackage?: string;
 }
 
-const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible, onClose, onOk, initialSelectedIds = [], selectedVideos = [], ghName }) => {
+const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible, onClose, onOk, initialSelectedIds = [], selectedVideos = [], ghName, crowdPackage }) => {
 	const { videoCategoryOptions } = useVideoCategoryOptions();
 	const { uploadLogVideoListQuery, uploadLogVideoPlay, uploadLogVideoPlayEnd, uploadLogVideoCollect } = useLogger();
 	const debouncedUploadLogVideoListQuery = useMemo(() => debounce(uploadLogVideoListQuery, 500), [uploadLogVideoListQuery]);
@@ -145,7 +147,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 	const currentPageRef = useRef(1);
 	const getVideoListRef = useRef<(pageNum: number, mode: LoadMode) => Promise<void>>();
 	const reqIdRef = useRef(0);
-	const MAX_SELECTION = 20;
+	const MAX_SELECTION = planType === GzhPlanType.自动回复 ? 3 : 20;
 
 	useEffect(() => { hasMoreRef.current = hasMore; }, [hasMore]);
 	useEffect(() => { loadingRef.current = loading; }, [loading]);
@@ -156,6 +158,8 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 			return VideoSearchPlanType.自动回复;
 		} else if (planType === GzhPlanType.公众号推送) {
 			return VideoSearchPlanType.公众号推送;
+		} else if (planType === GzhPlanType.小程序投流) {
+			return VideoSearchPlanType.小程序投流;
 		} else {
 			return VideoSearchPlanType.服务号推送;
 		}
@@ -192,6 +196,7 @@ const VideoSelectModal: React.FC<VideoSelectModalProps> = ({ planType, visible,
 			pageSize: PAGE_SIZE,
 			...(source ? { source } : {}),
 			...(ghName ? { ghName } : {}),
+			...(crowdPackage ? { crowdPackage } : {}),
 		};
 
 		const res = await http.post<VideoListResponse>(getVideoContentListApi, requestParams).catch(() => {

+ 1 - 0
src/views/publishContent/weGZH/hooks/useGzhPlanList.ts

@@ -7,6 +7,7 @@ export enum GzhPlanType {
 	自动回复 = '0',
 	服务号推送 = '1',
 	公众号推送 = '2',
+	小程序投流 = '3',
 }
 
 export interface GzhPlanDataType {

+ 251 - 0
src/views/publishContent/xcxTouliu/components/publishPlanModal/index.tsx

@@ -0,0 +1,251 @@
+import React, { useEffect, useState } from 'react';
+import { Modal, Form, Input, Select, Button, Card, Typography, message } from 'antd';
+import { CloseOutlined, PlusOutlined, EditOutlined, CaretRightFilled } from '@ant-design/icons';
+import VideoSelectModal from '@src/views/publishContent/weGZH/components/videoSelectModal';
+import EditTitleCoverModal from '@src/views/publishContent/weGZH/components/editTitleCoverModal';
+import { VideoItem } from '@src/views/publishContent/weGZH/components/types';
+import { GzhPlanType } from '@src/views/publishContent/weGZH/hooks/useGzhPlanList';
+import { VideoSearchPlanType } from '@src/views/publishContent/weCom/type';
+import useLogger from '@src/hooks/useLogger';
+import { XcxPlanDataType } from '../../hooks/useXcxPlanList';
+import { useAudiencePackageOptions } from '../../hooks/useAudiencePackageOptions';
+
+const { Paragraph } = Typography;
+
+interface XcxPublishPlanModalProps {
+	visible: boolean;
+	onCancel: () => void;
+	onOk: (values: XcxPlanDataType & { videoList: VideoItem[] }) => void;
+	isSubmiting?: boolean;
+}
+
+const XcxPublishPlanModal: React.FC<XcxPublishPlanModalProps> = ({
+	visible,
+	isSubmiting,
+	onCancel,
+	onOk,
+}) => {
+	const [form] = Form.useForm();
+	const audiencePackage = Form.useWatch('audiencePackage', form);
+	const [selectedVideos, setSelectedVideos] = useState<VideoItem[]>([]);
+	const [isVideoSelectVisible, setIsVideoSelectVisible] = useState(false);
+	const [playingVideo, setPlayingVideo] = useState<VideoItem | null>(null);
+	const [editingVideo, setEditingVideo] = useState<VideoItem | null>(null);
+	const { uploadLogVideoCreatePublish } = useLogger();
+	const { options: audiencePackageOptions } = useAudiencePackageOptions();
+
+	useEffect(() => {
+		if (!visible) return;
+		form.resetFields();
+		setSelectedVideos([]);
+	}, [visible]);
+
+	const handleOk = () => {
+		form
+			.validateFields()
+			.then((values) => {
+				if (selectedVideos.length === 0) {
+					message.error('请至少选择一个视频');
+					return;
+				}
+				onOk({ ...values, videoList: selectedVideos });
+				uploadLogVideoCreatePublish({
+					videoList: selectedVideos,
+					traceId: Date.now(),
+					planType: VideoSearchPlanType.小程序投流,
+					subChannel: 'xcxTouliu',
+				});
+			})
+			.catch((info) => {
+				console.log('Validate Failed:', info);
+			});
+	};
+
+	const removeVideo = (idToRemove: number) => {
+		setSelectedVideos((cur) => cur.filter((v) => v.videoId !== idToRemove));
+	};
+
+	const openVideoSelector = () => {
+		if (!audiencePackage) {
+			message.warning('请先选择人群包,为您推荐适配的视频');
+			return;
+		}
+		setIsVideoSelectVisible(true);
+	};
+
+	const handleVideoSelectionOk = (newlySelectedVideos: VideoItem[]) => {
+		const currentCustomData = new Map<number, Partial<VideoItem>>();
+		selectedVideos.forEach((v) => {
+			if (v.videoId) {
+				currentCustomData.set(v.videoId, {
+					customTitle: v.customTitle,
+					customCover: v.customCover,
+					customCoverType: v.customCoverType,
+				});
+			}
+		});
+		const mergedVideos = newlySelectedVideos.map((newVideo) => {
+			const customData = currentCustomData.get(newVideo.videoId);
+			return { ...newVideo, ...customData };
+		});
+		setSelectedVideos(mergedVideos);
+		setIsVideoSelectVisible(false);
+	};
+
+	const handleEditOk = (updatedData: Partial<VideoItem>) => {
+		setSelectedVideos((cur) =>
+			cur.map((v) =>
+				v.videoId === editingVideo?.videoId ? { ...v, ...updatedData } : v
+			)
+		);
+		setEditingVideo(null);
+	};
+
+	return (
+		<>
+			<Modal
+				title="创建发布计划"
+				open={visible}
+				destroyOnClose
+				onCancel={onCancel}
+				width={900}
+				footer={[
+					<Button key="back" onClick={onCancel}>
+						取消
+					</Button>,
+					<Button key="submit" type="primary" loading={isSubmiting} onClick={handleOk}>
+						确定
+					</Button>,
+				]}
+				zIndex={10}
+			>
+				<Form form={form} layout="vertical">
+					<Form.Item
+						name="audiencePackage"
+						label="人群包选择"
+						labelCol={{ span: 4 }}
+						labelAlign="left"
+						layout="horizontal"
+						rules={[{ required: true, message: '请选择人群包' }]}
+					>
+						<Select
+							placeholder="选择人群包"
+							className="!w-80"
+							allowClear
+							showSearch
+							filterOption={(input, option) =>
+								(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())
+							}
+							options={audiencePackageOptions.map((v) => ({ label: v, value: v }))}
+							onChange={() => setSelectedVideos([])}
+						/>
+					</Form.Item>
+					<Form.Item
+						name="remark"
+						label="备注"
+						labelCol={{ span: 4 }}
+						labelAlign="left"
+						layout="horizontal"
+					>
+						<Input.TextArea placeholder="可选,内部备注" className="!w-80" rows={2} maxLength={200} />
+					</Form.Item>
+					<Form.Item label="发布内容" required>
+						<div className="flex flex-wrap gap-4 max-h-[660px] overflow-y-auto pr-2">
+							{selectedVideos.map((video) => (
+								<Card key={video.videoId} className="w-[240px] relative group">
+									<Button
+										shape="circle"
+										icon={<CloseOutlined />}
+										className="!absolute top-1 right-1 z-10 bg-gray-400 bg-opacity-50 border-none text-white hidden group-hover:inline-flex justify-center items-center"
+										size="small"
+										onClick={() => removeVideo(video.videoId)}
+									/>
+									<div className="p-0">
+										<Paragraph
+											className="mt-1 !mb-1"
+											ellipsis={{ rows: 2, tooltip: true }}
+											title={video.customTitle || video.title}
+										>
+											{video.customTitle || video.title}
+										</Paragraph>
+									</div>
+									<div
+										className="relative"
+										style={{ paddingBottom: '79.8%' }}
+										onClick={(e) => {
+											e.stopPropagation();
+											setPlayingVideo(video);
+										}}
+									>
+										<img
+											src={video.customCover || video.cover}
+											referrerPolicy="no-referrer"
+											className="absolute inset-0 w-full h-full object-cover"
+										/>
+										<div className="absolute inset-0 flex justify-center items-center cursor-pointer">
+											<CaretRightFilled className="!text-white text-4xl bg-black/20 rounded-full p-1 pl-2" />
+										</div>
+									</div>
+									<div className="p-3">
+										<Button
+											icon={<EditOutlined />}
+											className="w-full mt-2"
+											onClick={() => setEditingVideo(video)}
+										>
+											编辑标题/封面
+										</Button>
+									</div>
+								</Card>
+							))}
+							{selectedVideos.length < 20 && (
+								<div
+									className={`w-[240px] h-[316px] flex flex-col justify-center items-center border border-dashed rounded ${audiencePackage ? 'border-gray-300 cursor-pointer dark:border-gray-600 hover:border-blue-500 hover:text-blue-500' : 'border-gray-200 text-gray-300 cursor-not-allowed bg-gray-50'}`}
+									onClick={openVideoSelector}
+								>
+									<PlusOutlined className="text-2xl mb-2" />
+									<Typography.Text type={audiencePackage ? undefined : 'secondary'}>添加视频</Typography.Text>
+								</div>
+							)}
+						</div>
+					</Form.Item>
+				</Form>
+			</Modal>
+
+			<VideoSelectModal
+				planType={GzhPlanType.小程序投流}
+				visible={isVideoSelectVisible}
+				onClose={() => setIsVideoSelectVisible(false)}
+				onOk={handleVideoSelectionOk}
+				selectedVideos={selectedVideos}
+				initialSelectedIds={selectedVideos.map((v) => v.videoId)}
+				crowdPackage={audiencePackage}
+			/>
+
+			<Modal
+				open={!!playingVideo}
+				onCancel={() => setPlayingVideo(null)}
+				title={playingVideo?.customTitle || playingVideo?.title}
+				footer={null}
+				destroyOnClose
+				width={720}
+				styles={{ body: { padding: 0, background: '#000' } }}
+				zIndex={20}
+			>
+				{playingVideo && (
+					<video controls autoPlay className="w-full h-auto max-h-[80vh] block" src={playingVideo.video}>
+						Your browser does not support the video tag.
+					</video>
+				)}
+			</Modal>
+
+			<EditTitleCoverModal
+				visible={!!editingVideo}
+				onCancel={() => setEditingVideo(null)}
+				onOk={handleEditOk}
+				video={editingVideo}
+			/>
+		</>
+	);
+};
+
+export default XcxPublishPlanModal;

+ 28 - 0
src/views/publishContent/xcxTouliu/hooks/useAudiencePackageOptions.ts

@@ -0,0 +1,28 @@
+import { useCallback, useEffect, useState } from 'react';
+import request from '@src/http/index';
+import { getXcxAudiencePackageListApi } from '@src/http/api';
+
+export const useAudiencePackageOptions = () => {
+	const [options, setOptions] = useState<string[]>([]);
+	const [loading, setLoading] = useState(false);
+
+	const fetchOptions = useCallback(async () => {
+		try {
+			setLoading(true);
+			const res = await request.get<string[]>(getXcxAudiencePackageListApi);
+			if (res?.data && Array.isArray(res.data)) {
+				setOptions(res.data);
+			}
+		} catch (err) {
+			console.error('fetch audience package options failed', err);
+		} finally {
+			setLoading(false);
+		}
+	}, []);
+
+	useEffect(() => {
+		fetchOptions();
+	}, [fetchOptions]);
+
+	return { options, loading, refetch: fetchOptions };
+};

+ 74 - 0
src/views/publishContent/xcxTouliu/hooks/useXcxPlanList.ts

@@ -0,0 +1,74 @@
+import { useState } from 'react';
+import { getXcxPlanListApi } from '@src/http/api';
+import request from '@src/http/index';
+import { VideoItem } from '@src/views/publishContent/weGZH/components/types';
+
+export interface XcxPlanDataType {
+	id: number;
+	audiencePackage: string;
+	title: string;
+	cover: string;
+	shareCover?: string;
+	video: string;
+	pageUrl: string;
+	remark?: string;
+	createTimestamp: number;
+	videoList?: VideoItem[];
+}
+
+interface XcxPlanListParams {
+	audiencePackage?: string;
+	title?: string;
+	createTimestampStart?: number;
+	createTimestampEnd?: number;
+	pageNum: number;
+	pageSize: number;
+}
+
+interface XcxPlanListResponse {
+	objs: XcxPlanDataType[];
+	totalSize: number;
+}
+
+export const useXcxPlanList = () => {
+	const [xcxPlanList, setXcxPlanList] = useState<XcxPlanDataType[]>([]);
+	const [loading, setLoading] = useState(false);
+	const [error, setError] = useState<string | null>(null);
+	const [totalSize, setTotalSize] = useState(0);
+
+	const getXcxPlanList = async ({
+		audiencePackage,
+		title,
+		createTimestampStart,
+		createTimestampEnd,
+		pageNum,
+		pageSize,
+	}: XcxPlanListParams) => {
+		try {
+			setLoading(true);
+			setError(null);
+			const data = await request.post<XcxPlanListResponse>(getXcxPlanListApi, {
+				audiencePackage,
+				title,
+				createTimestampStart,
+				createTimestampEnd,
+				pageNum,
+				pageSize,
+			});
+			setXcxPlanList(data.data.objs as XcxPlanDataType[]);
+			setTotalSize(data.data.totalSize);
+		} catch (err) {
+			setError(err instanceof Error ? err.message : 'Failed to fetch xcx plan list');
+		} finally {
+			setLoading(false);
+		}
+	};
+
+	return {
+		xcxPlanList,
+		getXcxPlanList,
+		totalSize,
+		loading,
+		error,
+	};
+};

+ 3 - 0
src/views/publishContent/xcxTouliu/index.module.css

@@ -0,0 +1,3 @@
+.antTable {
+	border-top: none;
+}

+ 449 - 0
src/views/publishContent/xcxTouliu/index.tsx

@@ -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;