This commit is contained in:
LittleBoy 2024-11-27 10:27:18 +08:00
parent 25de13eb85
commit 17903f5486
15 changed files with 509 additions and 295 deletions

View File

@ -13,6 +13,7 @@
--app-header-header: 90px; --app-header-header: 90px;
--container-width: 1440px; --container-width: 1440px;
} }
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@ -31,66 +32,81 @@
} }
} }
} }
.card{
.card {
@apply bg-white rounded-lg p-5 my-10; @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; @apply w-4 h-4 mr-2 border border-gray-400 rounded-2xl inline-block flex items-center justify-center relative;
padding: 3px; padding: 3px;
&:after{
&:after {
@apply inline-block bg-blue-500 w-full h-full; @apply inline-block bg-blue-500 w-full h-full;
content: ' '; content: ' ';
border-radius: 50%; border-radius: 50%;
opacity: 0; opacity: 0;
} }
} }
.checkbox-icon{
.checkbox-icon {
@apply rounded; @apply rounded;
padding: 0; padding: 0;
&:before{
&:before {
content: ' '; content: ' ';
display: inline-block; display: inline-block;
border-left: solid 2px #fff; border-left: solid 2px #fff;
border-bottom: solid 2px #fff; border-bottom: solid 2px #fff;
height: 6px; height: 6px;
width: 10px; width: 10px;
position: absolute; position: absolute;
transform: rotate(-45deg) translateY(-1px) translateX(1px); transform: rotate(-45deg) translateY(-1px) translateX(1px);
opacity: 0; opacity: 0;
} }
&:after{
&:after {
border-radius: 2px; border-radius: 2px;
} }
} }
.ant-select-item-option { .ant-select-item-option {
&.ant-select-item-option-selected{ &.ant-select-item-option-selected {
.radio-icon,.checkbox-icon{ .radio-icon, .checkbox-icon {
@apply border-blue-500; @apply border-blue-500;
&:after,&:before { &:after, &:before {
opacity: 1; opacity: 1;
} }
} }
} }
} }
.select-hide-checked{
.ant-select-item-option-selected{ .select-hide-checked {
.ant-select-item-option-state{ .ant-select-item-option-selected {
opacity: 0 !important; .ant-select-item-option-state {
opacity: 0 !important;
} }
} }
} }
.select-no-wrap{
.ant-select-selector{ .select-no-wrap {
.ant-select-selection-overflow{ .ant-select-selector {
.ant-select-selection-overflow {
@apply flex flex-nowrap; @apply flex flex-nowrap;
} }
} }
} }
.simple-pagination{
.ant-pagination-simple-pager{ .simple-pagination {
input[type=text]{ .ant-pagination-simple-pager {
input[type=text] {
width: auto; width: auto;
display: inline-block; 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));
} }

View File

@ -37,12 +37,24 @@
} }
.uploadImage { .uploadImage {
@apply flex justify-center items-center cursor-pointer; @apply flex justify-center items-center relative;
img { img {
display: block; display: block;
max-width: 100%; max-width: 100%;
max-height: 200px; 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 { .text {

View 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>);
}

View File

@ -1,6 +1,6 @@
import React from "react"; import React from "react";
import styles from './article.module.scss' import styles from './article.module.scss'
import {Input, Upload} from "antd"; import {Button, Input, Upload} from "antd";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
@ -15,7 +15,14 @@ export function BlockImage({data,editable}: Props) {
{editable ? <div> {editable ? <div>
<Upload accept="image/*"> <Upload accept="image/*">
<div className={styles.uploadImage} > <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> </div>
</Upload> </Upload>
</div> : <div className={styles.uploadImage}><img src={data.content}/></div>} </div> : <div className={styles.uploadImage}><img src={data.content}/></div>}

View File

@ -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" 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"/> fill="currentColor"/>
</svg> </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>
) )

View File

@ -1,10 +1,19 @@
import ReactPlayer from 'react-player' import ReactPlayer from 'react-player'
import { useSetState } from "ahooks";
import { PauseOutlined, PlayCircleOutlined, FullscreenOutlined, FullscreenExitOutlined } from "@ant-design/icons" import { PauseOutlined, PlayCircleOutlined, FullscreenOutlined, FullscreenExitOutlined } from "@ant-design/icons"
import { Progress } from "antd"; 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 }) { 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, playing: false,
muted: false, muted: false,
// 是否全屏 // 是否全屏
@ -13,9 +22,15 @@ export function Player({ url, cover, simple, showControls }: { url: string; cove
playedSeconds: 0, playedSeconds: 0,
duration: 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"> return <div className="video-player">
{simple ? <div> {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> : <> </div> : <>
<ReactPlayer <ReactPlayer
url={url} url={url}
@ -27,16 +42,13 @@ export function Player({ url, cover, simple, showControls }: { url: string; cove
onEnded={() => setState({ playing: false })} onEnded={() => setState({ playing: false })}
onPause={() => setState({ playing: false })} onPause={() => setState({ playing: false })}
onReady={(_player) => { onReady={(_player) => {
setState({ duration: _player.getDuration() }) setState({duration: _player.getDuration() })
}} }}
onProgress={(_) => { onProgress={(_) => {
setState((_prev) => { setState(_prev=>({
return { playedSeconds: _.playedSeconds,
..._prev, progress: Math.floor(_.playedSeconds / _prev.duration * 100)
playedSeconds: _.playedSeconds, }))
progress: Math.floor(_.playedSeconds / _prev.duration * 100)
}
})
}} }}
/> />
<div className="video-control p-2 flex items-center gap-2"> <div className="video-control p-2 flex items-center gap-2">

View 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>
}

View File

@ -1,41 +1,126 @@
import ArticleGroup from "@/components/article/group.tsx"; import {Button, message, Modal} from "antd";
import {Input, Modal} from "antd"; import React, {useRef, useState} from "react";
import {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() { export default function CreateIndex() {
const [visible, setVisible] = useState(true) const [editNews, setEditNews] = useSetState<{
const [groups, setGroups] = useState<ArticleContentGroup[]>(ArticleGroupList); title?: string;
return (<div> groups?: ArticleContentGroup[];
<h1>create index</h1> }>({})
<Modal
title={'编辑文章'} const [videoData, setVideoData] = useState<VideoInfo[]>(MockVideoDataList)
open={visible} const [modal, contextHolder] = Modal.useModal()
maskClosable={false} const videoRef = useRef<HTMLVideoElement | null>(null)
keyboard={false} const [state,setState] = useSetState({
width={800} checkedAll: false
onCancel={() => setVisible(false)} })
> const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
<div className="article-title mt-5"> const processDeleteVideo = async (_idArray: number[]) => {
<div className="title"> message.info('删除成功!!!' + _idArray.join(''));
<span className="text text-base"></span> }
<span className="require ml-1 font-bold text-red-500">*</span>
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>
<div className="box mt-1"> <DndContext onDragEnd={(e) => {
<Input placeholder={'请输入文章标题'}/> 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> </div>
<div className="aricle-body mt-2"> </div>
<div className="title"> <ArticleEditModal title={editNews.title} groups={editNews.groups}/>
<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>) </div>)
} }

View File

@ -19,8 +19,8 @@ export default function VideoDetail({video, onClose}: Props) {
<Modal open={!!video} title="新闻视频详情" width={1000} footer={null} onCancel={onClose}> <Modal open={!!video} title="新闻视频详情" width={1000} footer={null} onCancel={onClose}>
<div className="flex gap-2 my-5"> <div className="flex gap-2 my-5">
<div className="news-video w-[350px]"> <div className="news-video w-[350px]">
<div className="video-container bg-gray-100 rounded overflow-hidden"> <div className="video-container bg-gray-100 rounded overflow-hidden h-[400px]">
<Player url={'http://localhost:10020/ymca.mp4'} simple showControls /> <Player url={'https://file.wx.wm-app.xyz/os/media/ymca.mp4'} simple showControls />
</div> </div>
<div className="video-info text-right text-sm text-gray-600 mt-3"> <div className="video-info text-right text-sm text-gray-600 mt-3">
<span>创建时间: 5小时前</span> <span>创建时间: 5小时前</span>

View File

@ -1,182 +1,90 @@
import React, {useEffect, useRef, useState} from "react"; import React, {useRef, useState} from "react";
import {Modal} from "antd"; import {Button, message, Modal} from "antd";
import {MenuOutlined, MinusCircleFilled, CheckCircleFilled} from "@ant-design/icons"; import {SortableContext, arrayMove} from '@dnd-kit/sortable';
import {useSetState} from "ahooks";
import {clsx} from "clsx";
import {SortableContext, useSortable,arrayMove} from '@dnd-kit/sortable';
import {DndContext} from "@dnd-kit/core"; 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() { export default function LiveIndex() {
const [videoData, setVideoData] = useState<VideoInfo[]>([ const [videoData, setVideoData] = useState<VideoInfo[]>(MockVideoDataList)
{
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 [modal, contextHolder] = Modal.useModal() const [modal, contextHolder] = Modal.useModal()
const videoRef = useRef<HTMLVideoElement | null>(null) const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const playVideo = (video: VideoInfo) => { const processDeleteVideo = async (_idArray: number[]) => {
console.log('play',video) message.info('删除成功!!!' + _idArray.join(''));
if (videoRef.current) {
videoRef.current!.src = video.play_url
}
} }
const handleDeleteBatch = () => {
modal.confirm({
title: '提示',
content: '是否要删除选择的视频?',
onOk: () => processDeleteVideo(checkedIdArray)
})
}
return (<div className="container py-10 page-live"> return (<div className="container py-10 page-live">
{contextHolder} {contextHolder}
<div className="flex"> <div className="flex">
<div className="video-list-container bg-white p-10 rounded flex-1"> <div className="video-player-container mr-8 flex flex-col">
<DndContext onDragEnd={(e) => { <div className="text-center text-base"></div>
const {active, over} = e; <div className="video-player flex justify-center flex-1 mt-5">
if (over && active.id !== over.id) { <div className=" rounded overflow-hidden w-[360px] h-[700px]">
let oldIndex = -1, newIndex = -1; <iframe src="https://fm.gachafun.com/" className="border-0 w-full h-full max-h-full"></iframe>
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> </div>
</div> </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>
</div>) </div>)
} }

View File

@ -5,36 +5,10 @@ import React from "react";
import {NewsSources} from "@/pages/news/components/news-source.ts"; import {NewsSources} from "@/pages/news/components/news-source.ts";
import {useRequest, useSetState} from "ahooks"; import {useRequest, useSetState} from "ahooks";
import {formatTime} from "@/util/strings.ts"; 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[] = [ const dataList: NewsInfo[] = [
{ {
id: 1, id: 1,
@ -62,7 +36,11 @@ const rowSelection: TableProps<NewsInfo>['rowSelection'] = {
}; };
export default function NewEdit() { 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), source: NewsSources.map(s => s.value),
search: '', search: '',
page: 1 page: 1
@ -70,38 +48,72 @@ export default function NewEdit() {
const {data} = useRequest(async () => { const {data} = useRequest(async () => {
return [...dataList] return [...dataList]
}, { }, {
refreshDeps: [state] refreshDeps: [params]
}) })
const handleSelectChange = (values: string[]) => { const handleSelectChange = (values: string[]) => {
if (values.length == 0) { if (values.length == 0) {
setState({source: []}) setParams({source: []})
return; return;
} }
const lastValue = values[values.length - 1]; const lastValue = values[values.length - 1];
const source = NewsSources.map(s => s.value) || []; const source = NewsSources.map(s => s.value) || [];
const isChecked = values.length > state.source.length; // 是选中还是取消选中 const isChecked = values.length > params.source.length; // 是选中还是取消选中
if (lastValue == 'all') { if (lastValue == 'all') {
setState({source}) setParams({source})
} else if (isChecked && values.length == source.length - 1 && !values.includes('all')) { // 除全部之外已经都选了 则直接勾选所有 } else if (isChecked && values.length == source.length - 1 && !values.includes('all')) { // 除全部之外已经都选了 则直接勾选所有
setState({source}) setParams({source})
} else { } 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')) { if (params.source.length > 0 && params.source.length > values.length && diffValues.includes('all')) {
setState({source: []}) setParams({source: []})
return; 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"> return (<div className="container pb-5 news-edit">
<Card className="search-panel-container my-5"> <Card className="search-panel-container my-5">
<div className="search-form flex gap-5 justify-between"> <div className="search-form flex gap-5 justify-between">
<div className="search-form-input flex gap-2 items-center"> <div className="search-form-input flex gap-2 items-center">
<Input <Input
onPressEnter={(e) => { onPressEnter={(e) => {
setState({search: e.target.value}) setParams({search: e.target.value})
}} }}
type="text" className="rounded px-3 w-[220px]" type="text" className="rounded px-3 w-[220px]"
suffix={<SearchOutlined/>} suffix={<SearchOutlined/>}
@ -109,7 +121,8 @@ export default function NewEdit() {
/> />
<span className="ml-5 text-sm"></span> <span className="ml-5 text-sm"></span>
<Select <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" options={NewsSources} popupClassName="select-hide-checked"
mode="multiple" showSearch={false} mode="multiple" showSearch={false}
onChange={handleSelectChange} onChange={handleSelectChange}
@ -127,7 +140,9 @@ export default function NewEdit() {
}} }}
/> />
</div> </div>
<Button type="primary"></Button> <Button type="primary" onClick={() => {
setEditNews({title: '', groups: []})
}}></Button>
</div> </div>
<div className="news-list-container mt-5"> <div className="news-list-container mt-5">
<Table<NewsInfo> <Table<NewsInfo>
@ -138,15 +153,16 @@ export default function NewEdit() {
pagination={{ pagination={{
position: ['bottomLeft'], position: ['bottomLeft'],
simple: true, simple: true,
defaultCurrent:1, defaultCurrent: 1,
total:5000004, total: 5000004,
pageSize:20, pageSize: 20,
showSizeChanger:false, showSizeChanger: false,
rootClassName:'simple-pagination', rootClassName: 'simple-pagination',
onChange: (page)=>setState({page}) onChange: (page) => setParams({page})
}} }}
/> />
</div> </div>
<ArticleEditModal title={editNews.title} groups={editNews.groups}/>
</Card> </Card>
</div>) </div>)
} }

View File

@ -8,6 +8,7 @@ import styles from './style.module.scss'
export default function NewsIndex() { export default function NewsIndex() {
const [list,] = useState([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) const [list,] = useState([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
const [checkedId, setCheckedId] = useState<number[]>([])
const [activeNews, setActiveNews] = useState<NewsInfo>() const [activeNews, setActiveNews] = useState<NewsInfo>()
return (<div className={'container pb-5'}> return (<div className={'container pb-5'}>
<Card className="search-panel-container my-5"> <Card className="search-panel-container my-5">
@ -38,17 +39,27 @@ export default function NewsIndex() {
<div className={styles.newsList}> <div className={styles.newsList}>
{list.map(id => ( {list.map(id => (
<div key={id} className={`py-3 flex items-start border-b border-gray-100 group`}> <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"> <div
<Checkbox/> 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>
<div className="news-content"> <div className="news-content">
<div className="title text-lg cursor-pointer" onClick={() => { <div className="flex items-center justify-between">
setActiveNews({ <div className="title text-lg cursor-pointer" onClick={() => {
id: 1, setActiveNews({
title: '习近平抵达巴西利亚开始对巴西进行国事访问', id: 1,
content: '', cover: "", source: "", time: "" title: '习近平抵达巴西利亚开始对巴西进行国事访问',
}) content: '', cover: "", source: "", time: ""
}}>西西访 })
}}>西西访
</div>
{id == 1 && <div className="text-sm text-blue-500"></div>}
</div> </div>
<div className="content flex gap-3 mt-2 mb-3"> <div className="content flex gap-3 mt-2 mb-3">
<div className="cover border border-gray-100 flex items-center rounded overflow-hidden" <div className="cover border border-gray-100 flex items-center rounded overflow-hidden"
@ -61,7 +72,7 @@ export default function NewsIndex() {
西西西西访 西西西西访
</div> </div>
</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> <div>: <span></span></div>
{/*<Divider type="vertical" />*/} {/*<Divider type="vertical" />*/}
<div>: <span>2024-11-18 10:10:12</span></div> <div>: <span>2024-11-18 10:10:12</span></div>

View File

@ -16,6 +16,7 @@ type FieldType = {
export default function FormLogin() { export default function FormLogin() {
const [disabled, setDisabled] = useState(true) const [disabled, setDisabled] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const {login} = useAuth(); const {login} = useAuth();
const navigate = useNavigate() const navigate = useNavigate()
@ -23,15 +24,16 @@ export default function FormLogin() {
const [phone, setPhone] = useState<string>() const [phone, setPhone] = useState<string>()
const {sending, countdown, sendCode} = useSmsCode() const {sending, countdown, sendCode} = useSmsCode()
const onFinish: FormProps<FieldType>['onFinish'] = (values) => { const onFinish: FormProps<FieldType>['onFinish'] = (values) => {
if (values.username != 'admin') { if (!values.username || !/^1\d{10}$/.test(values.username)) {
setError('账号或密码错误') setError('账号或密码错误')
return return
} }
setLoading(true)
login(values.username, values.password!).then(() => { login(values.username, values.password!).then(() => {
navigate(params.get('from') || '/') navigate(params.get('from') || '/')
}).catch(e => { }).catch(e => {
setError(e.message) setError(e.message)
}); }).finally(()=>setLoading(false));
}; };
return (<div className="form"> return (<div className="form">
@ -81,8 +83,8 @@ export default function FormLogin() {
</Form.Item> </Form.Item>
<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> </Button>
</Form.Item> </Form.Item>
</Form> </Form>

View File

@ -21,6 +21,7 @@ export async function getUserInfo() {
* @param state * @param state
*/ */
export async function auth(_username: string, _password: string) { export async function auth(_username: string, _password: string) {
await sleep(1500);
return mockUser; return mockUser;
//return post<UserProfile>('/auth', {code, state}) //return post<UserProfile>('/auth', {code, state})
} }

1
src/types/api.d.ts vendored
View File

@ -26,4 +26,5 @@ declare interface VideoInfo {
description: string; description: string;
tags: string[]; tags: string[];
create_time: number; create_time: number;
checked?:boolean
} }