update
This commit is contained in:
parent
25de13eb85
commit
17903f5486
@ -13,6 +13,7 @@
|
||||
--app-header-header: 90px;
|
||||
--container-width: 1440px;
|
||||
}
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@ -31,66 +32,81 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.card{
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg p-5 my-10;
|
||||
}
|
||||
.radio-icon,.checkbox-icon{
|
||||
|
||||
.radio-icon, .checkbox-icon {
|
||||
@apply w-4 h-4 mr-2 border border-gray-400 rounded-2xl inline-block flex items-center justify-center relative;
|
||||
padding: 3px;
|
||||
&:after{
|
||||
|
||||
&:after {
|
||||
@apply inline-block bg-blue-500 w-full h-full;
|
||||
content: ' ';
|
||||
border-radius: 50%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.checkbox-icon{
|
||||
|
||||
.checkbox-icon {
|
||||
@apply rounded;
|
||||
padding: 0;
|
||||
&:before{
|
||||
|
||||
&:before {
|
||||
content: ' ';
|
||||
display: inline-block;
|
||||
border-left: solid 2px #fff;
|
||||
border-bottom: solid 2px #fff;
|
||||
border-bottom: solid 2px #fff;
|
||||
height: 6px;
|
||||
width: 10px;
|
||||
position: absolute;
|
||||
transform: rotate(-45deg) translateY(-1px) translateX(1px);
|
||||
opacity: 0;
|
||||
}
|
||||
&:after{
|
||||
|
||||
&:after {
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-item-option {
|
||||
&.ant-select-item-option-selected{
|
||||
.radio-icon,.checkbox-icon{
|
||||
&.ant-select-item-option-selected {
|
||||
.radio-icon, .checkbox-icon {
|
||||
@apply border-blue-500;
|
||||
&:after,&:before {
|
||||
&:after, &:before {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.select-hide-checked{
|
||||
.ant-select-item-option-selected{
|
||||
.ant-select-item-option-state{
|
||||
opacity: 0 !important;
|
||||
|
||||
.select-hide-checked {
|
||||
.ant-select-item-option-selected {
|
||||
.ant-select-item-option-state {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.select-no-wrap{
|
||||
.ant-select-selector{
|
||||
.ant-select-selection-overflow{
|
||||
|
||||
.select-no-wrap {
|
||||
.ant-select-selector {
|
||||
.ant-select-selection-overflow {
|
||||
@apply flex flex-nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
.simple-pagination{
|
||||
.ant-pagination-simple-pager{
|
||||
input[type=text]{
|
||||
|
||||
.simple-pagination {
|
||||
.ant-pagination-simple-pager {
|
||||
input[type=text] {
|
||||
width: auto;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.video-item-shadow {
|
||||
box-shadow: 0 0 6px 0 var(--tw-shadow-color);
|
||||
//filter: drop-shadow(0 0 6px var(--tw-shadow-color));
|
||||
}
|
@ -37,12 +37,24 @@
|
||||
}
|
||||
|
||||
.uploadImage {
|
||||
@apply flex justify-center items-center cursor-pointer;
|
||||
@apply flex justify-center items-center relative;
|
||||
img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
}
|
||||
.uploadTips{
|
||||
@apply absolute inset-0 cursor-pointer opacity-0 rounded flex items-center justify-center bg-black/50 text-white;
|
||||
}
|
||||
.imagePlaceholder{
|
||||
@apply flex items-center justify-center;
|
||||
height: 100px;
|
||||
}
|
||||
&:hover{
|
||||
.uploadTips{
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
|
63
src/components/article/edit-modal.tsx
Normal file
63
src/components/article/edit-modal.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import {Input, Modal} from "antd";
|
||||
import ArticleGroup from "@/components/article/group.tsx";
|
||||
import {useEffect, useState} from "react";
|
||||
import {useSetState} from "ahooks";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
groups?: ArticleContentGroup[];
|
||||
onSave?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ArticleEditModal(props: Props) {
|
||||
|
||||
const [groups, setGroups] = useState<ArticleContentGroup[]>([]);
|
||||
const [title, setTitle] = useState(props.title)
|
||||
const [state, setState] = useSetState({
|
||||
loading: false,
|
||||
open: false
|
||||
})
|
||||
const handleSave = () => {
|
||||
setState({loading: true})
|
||||
props.onSave?.().finally(() => {
|
||||
setState({loading: false,open: false})
|
||||
})
|
||||
}
|
||||
useEffect(() => {
|
||||
setState({open: typeof(props.title) != "undefined"})
|
||||
setGroups(props.groups || [])
|
||||
setTitle(props.title||'')
|
||||
}, [props.title,props.groups])
|
||||
|
||||
return (<Modal
|
||||
title={'编辑文章'}
|
||||
open={state.open}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
width={800}
|
||||
onCancel={()=>setState({open: false})}
|
||||
okButtonProps={{loading: state.loading}}
|
||||
coOk={handleSave}
|
||||
>
|
||||
<div className="article-title mt-5">
|
||||
<div className="title">
|
||||
<span className="text text-base">标题</span>
|
||||
<span className="require ml-1 font-bold text-red-500">*</span>
|
||||
</div>
|
||||
<div className="box mt-1">
|
||||
<Input value={title} onChange={e => {
|
||||
setTitle(e.target.value)
|
||||
}} placeholder={'请输入文章标题'}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aricle-body mt-2">
|
||||
<div className="title">
|
||||
<span className="text text-base">正文</span>
|
||||
<span className="require ml-1 font-bold text-red-500">*</span>
|
||||
</div>
|
||||
<div className="box mt-1">
|
||||
<ArticleGroup editable groups={groups} onChange={list => setGroups(() => list)}/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>);
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import styles from './article.module.scss'
|
||||
import {Input, Upload} from "antd";
|
||||
import {Button, Input, Upload} from "antd";
|
||||
|
||||
type Props = {
|
||||
children?: React.ReactNode;
|
||||
@ -15,7 +15,14 @@ export function BlockImage({data,editable}: Props) {
|
||||
{editable ? <div>
|
||||
<Upload accept="image/*">
|
||||
<div className={styles.uploadImage} >
|
||||
<img src={data.content}/>
|
||||
{ data.content ? <>
|
||||
<img src={data.content}/>
|
||||
<div className={styles.uploadTips}>
|
||||
<span>编辑</span>
|
||||
</div>
|
||||
</> : <div className={styles.imagePlaceholder}>
|
||||
<Button>选择图片</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</Upload>
|
||||
</div> : <div className={styles.uploadImage}><img src={data.content}/></div>}
|
||||
|
@ -491,4 +491,13 @@ export const IconLive = ({style,className}: { style?: React.CSSProperties;classN
|
||||
d="M772.437333 97.52381l51.712 51.712-126.342095 126.342095H828.952381a73.142857 73.142857 0 0 1 73.142857 73.142857v487.619048a73.142857 73.142857 0 0 1-73.142857 73.142857H195.047619a73.142857 73.142857 0 0 1-73.142857-73.142857v-487.619048a73.142857 73.142857 0 0 1 73.142857-73.142857h131.120762L199.850667 149.23581 251.562667 97.52381l178.054095 178.054095h164.742095L772.437333 97.52381zM828.952381 348.720762H195.047619v487.619048h633.904762v-487.619048z m-280.380952 73.142857v341.333333h-73.142858v-341.333333h73.142858z m-134.095239 73.142857v195.047619h-73.142857v-195.047619h73.142857z m268.190477 24.380953v146.285714h-73.142857v-146.285714h73.142857z"
|
||||
fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const IconEdit = ({style,className}: { style?: React.CSSProperties;className?:string; }) => (
|
||||
<svg className={`svg-icon ${className||''} icon-delete`} style={style} xmlns="http://www.w3.org/2000/svg"
|
||||
width="1em" height="1em" viewBox="0 0 1024 1024" version="1.1">
|
||||
<path
|
||||
d="M804.6 689.8l64-64c10-10 27.4-3 27.4 11.4V928c0 53-43 96-96 96H96c-53 0-96-43-96-96V224c0-53 43-96 96-96h547c14.2 0 21.4 17.2 11.4 27.4l-64 64c-3 3-7 4.6-11.4 4.6H96v704h704V701c0-4.2 1.6-8.2 4.6-11.2z m313.2-403.6L592.6 811.4l-180.8 20c-52.4 5.8-97-38.4-91.2-91.2l20-180.8L865.8 34.2c45.8-45.8 119.8-45.8 165.4 0l86.4 86.4c45.8 45.8 45.8 120 0.2 165.6zM920.2 348L804 231.8 432.4 603.6l-14.6 130.6 130.6-14.6L920.2 348z m129.6-159.4l-86.4-86.4c-8.2-8.2-21.6-8.2-29.6 0L872 164l116.2 116.2 61.8-61.8c8-8.4 8-21.6-0.2-29.8z"
|
||||
fill="currentColor"/>
|
||||
</svg>
|
||||
)
|
@ -1,10 +1,19 @@
|
||||
import ReactPlayer from 'react-player'
|
||||
import { useSetState } from "ahooks";
|
||||
import { PauseOutlined, PlayCircleOutlined, FullscreenOutlined, FullscreenExitOutlined } from "@ant-design/icons"
|
||||
import { Progress } from "antd";
|
||||
import {useState} from "react";
|
||||
|
||||
type State = {
|
||||
playing: boolean
|
||||
muted: boolean
|
||||
fullscreen: boolean
|
||||
progress: number
|
||||
playedSeconds: number
|
||||
duration: number
|
||||
}
|
||||
type StateUpdate = Partial<State> | ((prev: State) => Partial<State>)
|
||||
export function Player({ url, cover, simple, showControls }: { url: string; cover?: string; simple?: boolean; showControls?: boolean }) {
|
||||
const [state, setState] = useSetState({
|
||||
const [state, _setState] = useState<State>({
|
||||
playing: false,
|
||||
muted: false,
|
||||
// 是否全屏
|
||||
@ -13,9 +22,15 @@ export function Player({ url, cover, simple, showControls }: { url: string; cove
|
||||
playedSeconds: 0,
|
||||
duration: 0
|
||||
})
|
||||
const setState = (data: StateUpdate) => {
|
||||
_setState(prev => {
|
||||
if (typeof(data) === 'function') return { ...prev, ...data(prev) }
|
||||
return { ...prev, ...data }
|
||||
})
|
||||
}
|
||||
return <div className="video-player">
|
||||
{simple ? <div>
|
||||
<video src={url} poster={cover} controls={showControls}></video>
|
||||
<video style={{width:400,height:400}} preload={'metadata'} src={url} poster={cover} controls={showControls}></video>
|
||||
</div> : <>
|
||||
<ReactPlayer
|
||||
url={url}
|
||||
@ -27,16 +42,13 @@ export function Player({ url, cover, simple, showControls }: { url: string; cove
|
||||
onEnded={() => setState({ playing: false })}
|
||||
onPause={() => setState({ playing: false })}
|
||||
onReady={(_player) => {
|
||||
setState({ duration: _player.getDuration() })
|
||||
setState({duration: _player.getDuration() })
|
||||
}}
|
||||
onProgress={(_) => {
|
||||
setState((_prev) => {
|
||||
return {
|
||||
..._prev,
|
||||
playedSeconds: _.playedSeconds,
|
||||
progress: Math.floor(_.playedSeconds / _prev.duration * 100)
|
||||
}
|
||||
})
|
||||
setState(_prev=>({
|
||||
playedSeconds: _.playedSeconds,
|
||||
progress: Math.floor(_.playedSeconds / _prev.duration * 100)
|
||||
}))
|
||||
}}
|
||||
/>
|
||||
<div className="video-control p-2 flex items-center gap-2">
|
||||
|
71
src/components/video/video-list-item.tsx
Normal file
71
src/components/video/video-list-item.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import {useSortable} from "@dnd-kit/sortable";
|
||||
import {useSetState} from "ahooks";
|
||||
import React, {useEffect} from "react";
|
||||
import {clsx} from "clsx";
|
||||
import {CheckCircleFilled, MenuOutlined, MinusCircleFilled} from "@ant-design/icons";
|
||||
|
||||
import {IconEdit, IconPlay} from "@/components/icons";
|
||||
import {Popconfirm} from "antd";
|
||||
|
||||
type Props = {
|
||||
video: VideoInfo,
|
||||
index?: number;
|
||||
checked?: boolean;
|
||||
active?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
onPlay?: () => void;
|
||||
onEdit?: () => void;
|
||||
onRemove?: () => void;
|
||||
id:number;
|
||||
}
|
||||
|
||||
export const VideoListItem = ({index,id, video, onPlay, onRemove, checked, onCheckedChange,onEdit,active}: Props) => {
|
||||
const {
|
||||
attributes, listeners,
|
||||
setNodeRef, transform
|
||||
} = useSortable({resizeObserverConfig: {}, id})
|
||||
|
||||
|
||||
const [state, setState] = useSetState<{checked?:boolean}>({})
|
||||
useEffect(() => {
|
||||
setState({checked})
|
||||
}, [checked])
|
||||
|
||||
return <div
|
||||
className={'video-item flex items-center gap-3 mb-5'}
|
||||
ref={setNodeRef} style={{transform: `translateY(${transform?.y || 0}px)`,}}>
|
||||
{index && index > 0 && <div className="flex items-center px-2">
|
||||
<div
|
||||
className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{id}</div>
|
||||
</div>}
|
||||
<div className={`video-item-info flex gap-2 flex-1 bg-gray-100 min-h-[40px] rounded-lg p-3 shadow-blue-500 ${active?'video-item-shadow':''}`}>
|
||||
<div className={'video-title leading-7 flex-1'}>{video.id} - {video.title}</div>
|
||||
<div className={'video-item-cover'}>
|
||||
<img className="w-[100px] rounded-md" src={video.cover} alt={video.title}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="operation flex items-center ml-2 gap-3 text-lg text-gray-400">
|
||||
<button className="hover:text-blue-500" {...attributes} {...listeners}>
|
||||
<MenuOutlined/>
|
||||
</button>
|
||||
{onPlay && <button className="hover:text-blue-500" onClick={onPlay} style={{fontSize: '1.3em'}}><IconPlay/>
|
||||
</button>}
|
||||
{onEdit && <button className="hover:text-blue-500" onClick={onEdit} style={{fontSize: '1.1em'}}><IconEdit/>
|
||||
</button>}
|
||||
<button className="hover:text-blue-300" onClick={() => {
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(!state.checked)
|
||||
} else {
|
||||
setState({checked: !state.checked})
|
||||
}
|
||||
}}><CheckCircleFilled className={clsx({'text-blue-500': state.checked})}/></button>
|
||||
{onRemove && <Popconfirm
|
||||
title="提示"
|
||||
description={<div style={{minWidth: 150}}><span>请确认删除此视频?</span></div>}
|
||||
onConfirm={onRemove}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
><button className="hover:text-blue-500"><MinusCircleFilled/></button></Popconfirm>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
@ -1,41 +1,126 @@
|
||||
import ArticleGroup from "@/components/article/group.tsx";
|
||||
import {Input, Modal} from "antd";
|
||||
import {useState} from "react";
|
||||
import {Button, message, Modal} from "antd";
|
||||
import React, {useRef, useState} from "react";
|
||||
|
||||
import { ArticleGroupList } from "@/_local/mock-data";
|
||||
import {ArticleGroupList, MockVideoDataList} from "@/_local/mock-data";
|
||||
import {DndContext} from "@dnd-kit/core";
|
||||
import {arrayMove, SortableContext} from "@dnd-kit/sortable";
|
||||
import {VideoListItem} from "@/components/video/video-list-item.tsx";
|
||||
import ArticleEditModal from "@/components/article/edit-modal.tsx";
|
||||
import {useSetState} from "ahooks";
|
||||
import {CheckCircleFilled} from "@ant-design/icons";
|
||||
import {clsx} from "clsx";
|
||||
|
||||
|
||||
export default function CreateIndex() {
|
||||
const [visible, setVisible] = useState(true)
|
||||
const [groups, setGroups] = useState<ArticleContentGroup[]>(ArticleGroupList);
|
||||
return (<div>
|
||||
<h1>create index</h1>
|
||||
<Modal
|
||||
title={'编辑文章'}
|
||||
open={visible}
|
||||
maskClosable={false}
|
||||
keyboard={false}
|
||||
width={800}
|
||||
onCancel={() => setVisible(false)}
|
||||
>
|
||||
<div className="article-title mt-5">
|
||||
<div className="title">
|
||||
<span className="text text-base">标题</span>
|
||||
<span className="require ml-1 font-bold text-red-500">*</span>
|
||||
const [editNews, setEditNews] = useSetState<{
|
||||
title?: string;
|
||||
groups?: ArticleContentGroup[];
|
||||
}>({})
|
||||
|
||||
const [videoData, setVideoData] = useState<VideoInfo[]>(MockVideoDataList)
|
||||
const [modal, contextHolder] = Modal.useModal()
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const [state,setState] = useSetState({
|
||||
checkedAll: false
|
||||
})
|
||||
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
|
||||
const processDeleteVideo = async (_idArray: number[]) => {
|
||||
message.info('删除成功!!!' + _idArray.join(''));
|
||||
}
|
||||
|
||||
const handleDeleteBatch = () => {
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否要删除选择的视频?',
|
||||
onOk: () => processDeleteVideo(checkedIdArray)
|
||||
})
|
||||
}
|
||||
|
||||
const playVideo = (video: VideoInfo) => {
|
||||
console.log('play', video)
|
||||
if (videoRef.current) {
|
||||
videoRef.current!.src = video.play_url
|
||||
}
|
||||
}
|
||||
const handleAllCheckedChange = ()=>{
|
||||
// setVideoData(list=>{
|
||||
// list.map(s=>{
|
||||
// s.checked = !state.checkedAll
|
||||
// })
|
||||
// return list
|
||||
// })
|
||||
setCheckedIdArray(state.checkedAll?[]:videoData.map(v=>v.id))
|
||||
setState({
|
||||
checkedAll: !state.checkedAll
|
||||
})
|
||||
}
|
||||
|
||||
return (<div className="container py-10 page-live">
|
||||
{contextHolder}
|
||||
<div className="flex">
|
||||
<div className="video-list-container bg-white p-10 rounded flex-1">
|
||||
<div className="live-control flex justify-between mb-8">
|
||||
<div className="pl-[70px]">
|
||||
<span>视频时长: 00:00:29</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="cursor-pointer" onClick={handleDeleteBatch}>批量删除</span>
|
||||
<button className="ml-5 hover:text-blue-300 text-gray-400 text-lg" onClick={handleAllCheckedChange}>
|
||||
<CheckCircleFilled className={clsx({'text-blue-500': state.checkedAll})}/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="box mt-1">
|
||||
<Input placeholder={'请输入文章标题'}/>
|
||||
<DndContext onDragEnd={(e) => {
|
||||
const {active, over} = e;
|
||||
if (over && active.id !== over.id) {
|
||||
let oldIndex = -1, newIndex = -1;
|
||||
const originArr = [...videoData]
|
||||
setVideoData((items) => {
|
||||
oldIndex = items.findIndex(s => s.id == active.id);
|
||||
newIndex = items.findIndex(s => s.id == over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否要移动到指定位置',
|
||||
onCancel: () => {
|
||||
setVideoData(originArr);
|
||||
}
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<SortableContext items={videoData}>
|
||||
{videoData.map((v, index) => (
|
||||
<VideoListItem
|
||||
video={v}
|
||||
index={index + 1}
|
||||
id={v.id}
|
||||
key={index}
|
||||
checked={checkedIdArray.includes(v.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckedIdArray(idArray => {
|
||||
const newArr = checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
|
||||
setState({checkedAll: newArr.length == videoData.length})
|
||||
return newArr;
|
||||
})
|
||||
}}
|
||||
onPlay={() => playVideo(v)}
|
||||
onEdit={() => {
|
||||
setEditNews({title:v.title, groups: [...ArticleGroupList]})
|
||||
}}
|
||||
/>))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
<div className="video-player-container ml-8 w-[400px] flex flex-col">
|
||||
<div className="text-center text-base mt-10">预览视频</div>
|
||||
<div className="video-player flex items-center justify-center flex-1">
|
||||
<div className=" rounded overflow-hidden">
|
||||
<video ref={videoRef} controls autoPlay className="w-full bg-white min-w-[360px]"></video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="aricle-body mt-2">
|
||||
<div className="title">
|
||||
<span className="text text-base">正文</span>
|
||||
<span className="require ml-1 font-bold text-red-500">*</span>
|
||||
</div>
|
||||
<div className="box mt-1">
|
||||
<ArticleGroup groups={groups} onChange={list => setGroups(() => list)}/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
<ArticleEditModal title={editNews.title} groups={editNews.groups}/>
|
||||
</div>)
|
||||
}
|
@ -19,8 +19,8 @@ export default function VideoDetail({video, onClose}: Props) {
|
||||
<Modal open={!!video} title="新闻视频详情" width={1000} footer={null} onCancel={onClose}>
|
||||
<div className="flex gap-2 my-5">
|
||||
<div className="news-video w-[350px]">
|
||||
<div className="video-container bg-gray-100 rounded overflow-hidden">
|
||||
<Player url={'http://localhost:10020/ymca.mp4'} simple showControls />
|
||||
<div className="video-container bg-gray-100 rounded overflow-hidden h-[400px]">
|
||||
<Player url={'https://file.wx.wm-app.xyz/os/media/ymca.mp4'} simple showControls />
|
||||
</div>
|
||||
<div className="video-info text-right text-sm text-gray-600 mt-3">
|
||||
<span>创建时间: 5小时前</span>
|
||||
|
@ -1,182 +1,90 @@
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
import {Modal} from "antd";
|
||||
import {MenuOutlined, MinusCircleFilled, CheckCircleFilled} from "@ant-design/icons";
|
||||
import {useSetState} from "ahooks";
|
||||
import {clsx} from "clsx";
|
||||
import {SortableContext, useSortable,arrayMove} from '@dnd-kit/sortable';
|
||||
import React, {useRef, useState} from "react";
|
||||
import {Button, message, Modal} from "antd";
|
||||
import {SortableContext, arrayMove} from '@dnd-kit/sortable';
|
||||
import {DndContext} from "@dnd-kit/core";
|
||||
|
||||
import {IconPlay} from "@/components/icons";
|
||||
import {VideoListItem} from "@/components/video/video-list-item.tsx";
|
||||
import {MockVideoDataList} from "@/_local/mock-data.ts";
|
||||
|
||||
const VideoInfoItem = ({index,id, video, onPlay, onRemove, checked, onCheckedChange}: {
|
||||
video: VideoInfo,
|
||||
index?: number;
|
||||
checked?: boolean;
|
||||
onCheckedChange?: (checked: boolean) => void;
|
||||
onPlay?: () => void;
|
||||
onRemove?: () => void;
|
||||
id:number;
|
||||
}) => {
|
||||
const {
|
||||
attributes, listeners,
|
||||
setNodeRef, transform
|
||||
} = useSortable({resizeObserverConfig: {}, id})
|
||||
|
||||
|
||||
const [state, setState] = useSetState({
|
||||
checked: false
|
||||
})
|
||||
useEffect(() => {
|
||||
setState({checked})
|
||||
}, [checked])
|
||||
|
||||
return <div
|
||||
className={'video-item flex items-center gap-3 mb-5'}
|
||||
ref={setNodeRef} style={{transform: `translateY(${transform?.y || 0}px)`,}}>
|
||||
{index && index > 0 && <div className="flex items-center px-2">
|
||||
<div
|
||||
className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{id}</div>
|
||||
</div>}
|
||||
<div className={'video-item-info flex gap-2 flex-1 bg-gray-100 min-h-[40px] rounded-lg p-3'}>
|
||||
<div className={'video-title leading-7 flex-1'}>{video.id} - {video.title}</div>
|
||||
<div className={'video-item-cover'}>
|
||||
<img className="w-[100px] rounded-md" src={video.cover} alt={video.title}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="operation flex items-center gap-3 text-lg text-gray-400">
|
||||
<button className="hover:text-blue-500" {...attributes} {...listeners}>
|
||||
<MenuOutlined/>
|
||||
</button>
|
||||
{onPlay && <button className="hover:text-blue-500" onClick={onPlay} style={{fontSize: '1.3em'}}><IconPlay/>
|
||||
</button>}
|
||||
<button className="hover:text-blue-300" onClick={() => {
|
||||
if (onCheckedChange) {
|
||||
onCheckedChange(!state.checked)
|
||||
} else {
|
||||
setState({checked: !state.checked})
|
||||
}
|
||||
}}><CheckCircleFilled className={clsx({'text-blue-500': state.checked})}/></button>
|
||||
{onRemove && <button className="hover:text-blue-500" onClick={onRemove}><MinusCircleFilled/></button>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
export default function LiveIndex() {
|
||||
const [videoData, setVideoData] = useState<VideoInfo[]>([
|
||||
{
|
||||
id: 1,
|
||||
title: '习近平出席巴西总统卢拉举行的欢迎宴会',
|
||||
cover: '//www.81.cn/syjdt/_attachment/2024/11/21/16353279_e74ba3ed3897343b03ecee69dd0e2c19.jpg',
|
||||
duration: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
play_url: 'https://reflect.app/home/build/q-c3d7becf.webm',
|
||||
description: '1',
|
||||
tags: ['1'],
|
||||
create_time: 1732187665,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '习近平向2024年世界互联网大会乌镇峰会开幕视频致贺 指明方向凝聚共识',
|
||||
cover: '//www.81.cn/syjdt/_attachment/2024/11/21/16353279_e74ba3ed3897343b03ecee69dd0e2c19.jpg',
|
||||
duration: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
play_url: 'https://file.wx.wm-app.xyz/os/media/ymca.mp4',
|
||||
description: '1',
|
||||
tags: ['1'],
|
||||
create_time: 1732187665,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '中华人民共和国和巴西联邦共和国关于携手构建更公正世界和更可持续星球的中巴命运共同体的联合声明',
|
||||
cover: '//www.81.cn/syjdt/_attachment/2024/11/21/16353279_e74ba3ed3897343b03ecee69dd0e2c19.jpg',
|
||||
duration: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
play_url: 'https://reflect.app/home/build/q-c3d7becf.webm',
|
||||
description: '1',
|
||||
tags: ['1'],
|
||||
create_time: 1732187665,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '中华人民共和国和巴西联邦共和国关于携手构建更公正世界和更可持续星球的中巴命运共同体的联合声明',
|
||||
cover: '//www.81.cn/syjdt/_attachment/2024/11/21/16353279_e74ba3ed3897343b03ecee69dd0e2c19.jpg',
|
||||
duration: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
play_url: 'https://file.wx.wm-app.xyz/os/media/ymca.mp4',
|
||||
description: '1',
|
||||
tags: ['1'],
|
||||
create_time: 1732187665,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '中华人民共和国和巴西联邦共和国关于携手构建更公正世界和更可持续星球的中巴命运共同体的联合声明',
|
||||
cover: '//www.81.cn/syjdt/_attachment/2024/11/21/16353279_e74ba3ed3897343b03ecee69dd0e2c19.jpg',
|
||||
duration: 100,
|
||||
width: 100,
|
||||
height: 100,
|
||||
play_url: 'https://reflect.app/home/build/q-c3d7becf.webm',
|
||||
description: '1',
|
||||
tags: ['1'],
|
||||
create_time: 1732187665,
|
||||
}
|
||||
])
|
||||
const [videoData, setVideoData] = useState<VideoInfo[]>(MockVideoDataList)
|
||||
const [modal, contextHolder] = Modal.useModal()
|
||||
const videoRef = useRef<HTMLVideoElement | null>(null)
|
||||
const playVideo = (video: VideoInfo) => {
|
||||
console.log('play',video)
|
||||
if (videoRef.current) {
|
||||
videoRef.current!.src = video.play_url
|
||||
}
|
||||
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
|
||||
const processDeleteVideo = async (_idArray: number[]) => {
|
||||
message.info('删除成功!!!' + _idArray.join(''));
|
||||
}
|
||||
const handleDeleteBatch = () => {
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否要删除选择的视频?',
|
||||
onOk: () => processDeleteVideo(checkedIdArray)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return (<div className="container py-10 page-live">
|
||||
{contextHolder}
|
||||
<div className="flex">
|
||||
<div className="video-list-container bg-white p-10 rounded flex-1">
|
||||
<DndContext onDragEnd={(e) => {
|
||||
const {active, over} = e;
|
||||
if (over && active.id !== over.id) {
|
||||
let oldIndex = -1, newIndex = -1;
|
||||
const originArr = [...videoData]
|
||||
setVideoData((items) => {
|
||||
oldIndex = items.findIndex(s => s.id == active.id);
|
||||
newIndex = items.findIndex(s => s.id == over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否要移动到指定位置',
|
||||
onCancel: () => {
|
||||
setVideoData(originArr);
|
||||
}
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<SortableContext items={videoData}>
|
||||
{videoData.map((v, index) => (
|
||||
<VideoInfoItem
|
||||
video={v}
|
||||
index={index + 1}
|
||||
id={v.id}
|
||||
key={index}
|
||||
onPlay={() => playVideo(v)}
|
||||
onRemove={() => {
|
||||
}}
|
||||
/>))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
<div className="video-player-container ml-5 w-[300px] flex flex-col">
|
||||
<div className="text-center text-base">预览视频</div>
|
||||
<div className="video-player flex items-center justify-center flex-1">
|
||||
<div className="bg-white rounded overflow-hidden">
|
||||
<video ref={videoRef} autoPlay className="w-full"></video>
|
||||
<div className="video-player-container mr-8 flex flex-col">
|
||||
<div className="text-center text-base">数字人直播间</div>
|
||||
<div className="video-player flex justify-center flex-1 mt-5">
|
||||
<div className=" rounded overflow-hidden w-[360px] h-[700px]">
|
||||
<iframe src="https://fm.gachafun.com/" className="border-0 w-full h-full max-h-full"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-list-container flex-1">
|
||||
<div className=" bg-white py-8 px-6 rounded py-1">
|
||||
<div className="live-control flex justify-between mb-8">
|
||||
<div className="flex gap-2">
|
||||
<Button type="primary">开始直播</Button>
|
||||
<Button>暂停直播</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="cursor-pointer" onClick={handleDeleteBatch}>批量删除</span>
|
||||
</div>
|
||||
</div>
|
||||
<DndContext onDragEnd={(e) => {
|
||||
const {active, over} = e;
|
||||
if (over && active.id !== over.id) {
|
||||
let oldIndex = -1, newIndex = -1;
|
||||
const originArr = [...videoData]
|
||||
setVideoData((items) => {
|
||||
oldIndex = items.findIndex(s => s.id == active.id);
|
||||
newIndex = items.findIndex(s => s.id == over.id);
|
||||
return arrayMove(items, oldIndex, newIndex);
|
||||
});
|
||||
modal.confirm({
|
||||
title: '提示',
|
||||
content: '是否要移动到指定位置',
|
||||
onCancel: () => {
|
||||
setVideoData(originArr);
|
||||
},
|
||||
onOk: () => {
|
||||
setVideoData([...videoData])
|
||||
}
|
||||
})
|
||||
}
|
||||
}}>
|
||||
<SortableContext items={videoData}>
|
||||
{videoData.map((v, index) => (
|
||||
<VideoListItem
|
||||
video={v}
|
||||
index={index + 1}
|
||||
id={v.id}
|
||||
active={index == 0}
|
||||
key={index}
|
||||
onCheckedChange={(checked) => {
|
||||
setCheckedIdArray(idArray => {
|
||||
return checked ? idArray.concat(v.id) : idArray.filter(id => id != v.id);
|
||||
})
|
||||
}}
|
||||
onRemove={() => processDeleteVideo([v.id])}
|
||||
/>))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
@ -5,36 +5,10 @@ import React from "react";
|
||||
import {NewsSources} from "@/pages/news/components/news-source.ts";
|
||||
import {useRequest, useSetState} from "ahooks";
|
||||
import {formatTime} from "@/util/strings.ts";
|
||||
import ArticleEditModal from "@/components/article/edit-modal.tsx";
|
||||
import {ArticleGroupList} from "@/_local/mock-data.ts";
|
||||
|
||||
|
||||
const columns: TableColumnsType<NewsInfo> = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
// render: (text: string) => <a>{text}</a>,
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'time',
|
||||
render: (_, record) => {
|
||||
return formatTime(record.time, 'YYYY-MM-DD HH:mm')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
render: () => (<Button type="link">编辑</Button>),
|
||||
},
|
||||
];
|
||||
|
||||
const dataList: NewsInfo[] = [
|
||||
{
|
||||
id: 1,
|
||||
@ -62,7 +36,11 @@ const rowSelection: TableProps<NewsInfo>['rowSelection'] = {
|
||||
};
|
||||
|
||||
export default function NewEdit() {
|
||||
const [state, setState] = useSetState({
|
||||
const [editNews, setEditNews] = useSetState<{
|
||||
title?: string;
|
||||
groups?: ArticleContentGroup[];
|
||||
}>({})
|
||||
const [params, setParams] = useSetState({
|
||||
source: NewsSources.map(s => s.value),
|
||||
search: '',
|
||||
page: 1
|
||||
@ -70,38 +48,72 @@ export default function NewEdit() {
|
||||
const {data} = useRequest(async () => {
|
||||
return [...dataList]
|
||||
}, {
|
||||
refreshDeps: [state]
|
||||
refreshDeps: [params]
|
||||
})
|
||||
|
||||
const handleSelectChange = (values: string[]) => {
|
||||
if (values.length == 0) {
|
||||
setState({source: []})
|
||||
setParams({source: []})
|
||||
return;
|
||||
}
|
||||
const lastValue = values[values.length - 1];
|
||||
const source = NewsSources.map(s => s.value) || [];
|
||||
const isChecked = values.length > state.source.length; // 是选中还是取消选中
|
||||
const isChecked = values.length > params.source.length; // 是选中还是取消选中
|
||||
if (lastValue == 'all') {
|
||||
setState({source})
|
||||
setParams({source})
|
||||
} else if (isChecked && values.length == source.length - 1 && !values.includes('all')) { // 除全部之外已经都选了 则直接勾选所有
|
||||
setState({source})
|
||||
setParams({source})
|
||||
} else {
|
||||
const diffValues = state.source.filter(s => !values.includes(s));
|
||||
const diffValues = params.source.filter(s => !values.includes(s));
|
||||
// 取消的是全部 则取消所有勾选
|
||||
if (state.source.length > 0 && state.source.length > values.length && diffValues.includes('all')) {
|
||||
setState({source: []})
|
||||
if (params.source.length > 0 && params.source.length > values.length && diffValues.includes('all')) {
|
||||
setParams({source: []})
|
||||
return;
|
||||
}
|
||||
setState({source: values.filter(s => s != 'all')})
|
||||
setParams({source: values.filter(s => s != 'all')})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const columns: TableColumnsType<NewsInfo> = [
|
||||
{
|
||||
title: '标题',
|
||||
dataIndex: 'title',
|
||||
// render: (text: string) => <a>{text}</a>,
|
||||
},
|
||||
{
|
||||
title: '内容',
|
||||
dataIndex: 'content',
|
||||
},
|
||||
{
|
||||
title: '来源',
|
||||
dataIndex: 'source',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'time',
|
||||
render: (_, record) => {
|
||||
return formatTime(record.time, 'YYYY-MM-DD HH:mm')
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
align: 'center',
|
||||
render: (_, record) => (<Button type="link" onClick={() => {
|
||||
setEditNews({
|
||||
title: record.title, groups: [...ArticleGroupList]
|
||||
})
|
||||
}}>编辑</Button>),
|
||||
},
|
||||
];
|
||||
|
||||
return (<div className="container pb-5 news-edit">
|
||||
<Card className="search-panel-container my-5">
|
||||
<div className="search-form flex gap-5 justify-between">
|
||||
<div className="search-form-input flex gap-2 items-center">
|
||||
<Input
|
||||
onPressEnter={(e) => {
|
||||
setState({search: e.target.value})
|
||||
setParams({search: e.target.value})
|
||||
}}
|
||||
type="text" className="rounded px-3 w-[220px]"
|
||||
suffix={<SearchOutlined/>}
|
||||
@ -109,7 +121,8 @@ export default function NewEdit() {
|
||||
/>
|
||||
<span className="ml-5 text-sm">来源</span>
|
||||
<Select
|
||||
value={state.source} className="min-w-[300px] select-no-wrap select-hide-checked max-w-[300px] "
|
||||
value={params.source}
|
||||
className="min-w-[300px] select-no-wrap select-hide-checked max-w-[300px] "
|
||||
options={NewsSources} popupClassName="select-hide-checked"
|
||||
mode="multiple" showSearch={false}
|
||||
onChange={handleSelectChange}
|
||||
@ -127,7 +140,9 @@ export default function NewEdit() {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button type="primary">手动新增</Button>
|
||||
<Button type="primary" onClick={() => {
|
||||
setEditNews({title: '', groups: []})
|
||||
}}>手动新增</Button>
|
||||
</div>
|
||||
<div className="news-list-container mt-5">
|
||||
<Table<NewsInfo>
|
||||
@ -138,15 +153,16 @@ export default function NewEdit() {
|
||||
pagination={{
|
||||
position: ['bottomLeft'],
|
||||
simple: true,
|
||||
defaultCurrent:1,
|
||||
total:5000004,
|
||||
pageSize:20,
|
||||
showSizeChanger:false,
|
||||
rootClassName:'simple-pagination',
|
||||
onChange: (page)=>setState({page})
|
||||
defaultCurrent: 1,
|
||||
total: 5000004,
|
||||
pageSize: 20,
|
||||
showSizeChanger: false,
|
||||
rootClassName: 'simple-pagination',
|
||||
onChange: (page) => setParams({page})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ArticleEditModal title={editNews.title} groups={editNews.groups}/>
|
||||
</Card>
|
||||
</div>)
|
||||
}
|
@ -8,6 +8,7 @@ import styles from './style.module.scss'
|
||||
|
||||
export default function NewsIndex() {
|
||||
const [list,] = useState([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||
const [checkedId, setCheckedId] = useState<number[]>([])
|
||||
const [activeNews, setActiveNews] = useState<NewsInfo>()
|
||||
return (<div className={'container pb-5'}>
|
||||
<Card className="search-panel-container my-5">
|
||||
@ -38,17 +39,27 @@ export default function NewsIndex() {
|
||||
<div className={styles.newsList}>
|
||||
{list.map(id => (
|
||||
<div key={id} className={`py-3 flex items-start border-b border-gray-100 group`}>
|
||||
<div className="checkbox mr-2 opacity-0 group-hover:opacity-100">
|
||||
<Checkbox/>
|
||||
<div
|
||||
className={`checkbox mr-2 ${checkedId.includes(id) ? '' : 'opacity-0'} group-hover:opacity-100`}>
|
||||
<Checkbox checked={checkedId.includes(id)} onChange={() => {
|
||||
if (checkedId.includes(id)) {
|
||||
setCheckedId(checkedId.filter(item => item != id))
|
||||
} else {
|
||||
setCheckedId([...checkedId, id])
|
||||
}
|
||||
}}/>
|
||||
</div>
|
||||
<div className="news-content">
|
||||
<div className="title text-lg cursor-pointer" onClick={() => {
|
||||
setActiveNews({
|
||||
id: 1,
|
||||
title: '习近平抵达巴西利亚开始对巴西进行国事访问',
|
||||
content: '', cover: "", source: "", time: ""
|
||||
})
|
||||
}}>习近平抵达巴西利亚开始对巴西进行国事访问
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="title text-lg cursor-pointer" onClick={() => {
|
||||
setActiveNews({
|
||||
id: 1,
|
||||
title: '习近平抵达巴西利亚开始对巴西进行国事访问',
|
||||
content: '', cover: "", source: "", time: ""
|
||||
})
|
||||
}}>习近平抵达巴西利亚开始对巴西进行国事访问
|
||||
</div>
|
||||
{id == 1 && <div className="text-sm text-blue-500">已加入编辑界面</div>}
|
||||
</div>
|
||||
<div className="content flex gap-3 mt-2 mb-3">
|
||||
<div className="cover border border-gray-100 flex items-center rounded overflow-hidden"
|
||||
@ -61,7 +72,7 @@ export default function NewsIndex() {
|
||||
专机抵达巴西利亚空军基地时,巴西总统府首席部长科斯塔、巴西利亚空军基地司令米格尔、司法部长莱万多夫斯基、总统府机构关系部长帕迪利亚等高级官员在机场热情迎接,代表卢拉总统和巴西政府热烈欢迎习近平主席到访。
|
||||
</div>
|
||||
</div>
|
||||
<div className="info text-gray-300 flex items-center gap-3 text-sm">
|
||||
<div className="info text-gray-300 flex items-center justify-between gap-3 text-sm">
|
||||
<div>来源: <span>新华社</span></div>
|
||||
{/*<Divider type="vertical" />*/}
|
||||
<div>发布时间: <span>2024-11-18 10:10:12</span></div>
|
||||
|
@ -16,6 +16,7 @@ type FieldType = {
|
||||
|
||||
export default function FormLogin() {
|
||||
const [disabled, setDisabled] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string>()
|
||||
const {login} = useAuth();
|
||||
const navigate = useNavigate()
|
||||
@ -23,15 +24,16 @@ export default function FormLogin() {
|
||||
const [phone, setPhone] = useState<string>()
|
||||
const {sending, countdown, sendCode} = useSmsCode()
|
||||
const onFinish: FormProps<FieldType>['onFinish'] = (values) => {
|
||||
if (values.username != 'admin') {
|
||||
if (!values.username || !/^1\d{10}$/.test(values.username)) {
|
||||
setError('账号或密码错误')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
login(values.username, values.password!).then(() => {
|
||||
navigate(params.get('from') || '/')
|
||||
}).catch(e => {
|
||||
setError(e.message)
|
||||
});
|
||||
}).finally(()=>setLoading(false));
|
||||
};
|
||||
|
||||
return (<div className="form">
|
||||
@ -81,8 +83,8 @@ export default function FormLogin() {
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button disabled={disabled} type="primary" size={'large'} htmlType="submit" block shape={'round'}>
|
||||
立即登录
|
||||
<Button disabled={disabled || loading} loading={loading} type="primary" size={'large'} htmlType="submit" block shape={'round'}>
|
||||
{login?'登录中':'立即登录'}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
@ -21,6 +21,7 @@ export async function getUserInfo() {
|
||||
* @param state
|
||||
*/
|
||||
export async function auth(_username: string, _password: string) {
|
||||
await sleep(1500);
|
||||
return mockUser;
|
||||
//return post<UserProfile>('/auth', {code, state})
|
||||
}
|
1
src/types/api.d.ts
vendored
1
src/types/api.d.ts
vendored
@ -26,4 +26,5 @@ declare interface VideoInfo {
|
||||
description: string;
|
||||
tags: string[];
|
||||
create_time: number;
|
||||
checked?:boolean
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user