Compare commits

...

3 Commits

Author SHA1 Message Date
ddda905608 merge new editor 2024-12-14 22:11:24 +08:00
c63b0c088e feat: 添加视频相关数字对接 2024-12-14 22:10:11 +08:00
97d9200217 feat: 编辑样式调整为新的样式 2024-12-14 16:28:01 +08:00
23 changed files with 532 additions and 206 deletions

View File

@ -1,5 +1,5 @@
.blockContainer { .blockContainer {
@apply flex mb-5; @apply flex mb-5;
} }
.block { .block {
@ -14,6 +14,10 @@
} }
} }
.blockFist {
@apply p-0 border-0 !important;
}
.blockItem { .blockItem {
} }
@ -21,40 +25,61 @@
.group { .group {
} }
.image { .imageList {
@apply border border-blue-200 p-2 flex-1 rounded focus:border-blue-200; @apply grid grid-cols-4 gap-4 p-3 border border-blue-200;
min-height: 100px; :global {
&:hover{ .ant-upload-wrapper {
@apply border-blue-500;
}
:global{
.ant-upload-wrapper{
display: block; display: block;
border: none; border: none;
padding: 0; padding: 0;
} }
.ant-upload{
.ant-upload {
display: block; display: block;
} }
img {
@apply block m-0;
max-width: 100%;
height: 100px;
object-fit: contain;
padding: 2px;
}
} }
} }
.image {
@apply rounded bg-gray-100;
height: 100px;
&:hover {
@apply border-blue-500;
}
}
.imageDelete{
@apply absolute flex items-center justify-center p-0.5 w-[22px] h-[22px] rounded-full border border-red-500 text-red-500 cursor-pointer z-10;
right:-10px;
top:-10px;
font-size: 14px;
&:hover{
@apply text-white bg-red-500;
}
}
.uploadImage { .uploadImage {
@apply flex justify-center items-center relative; @apply flex justify-center items-center relative h-[100px] text-gray-400;
img {
display: block; .uploadTips {
max-width: 100%; @apply absolute inset-0 cursor-pointer opacity-0 transition rounded flex items-center justify-center bg-black/20 text-white;
max-height: 200px;
} }
.uploadTips{
@apply absolute inset-0 cursor-pointer opacity-0 rounded flex items-center justify-center bg-black/50 text-white; .imagePlaceholder {
}
.imagePlaceholder{
@apply flex items-center justify-center; @apply flex items-center justify-center;
height: 100px; height: 100px;
} }
&:hover{
.uploadTips{ &:hover {
@apply bg-gray-100 cursor-pointer rounded text-blue-500;
.uploadTips {
@apply opacity-100; @apply opacity-100;
} }
} }
@ -62,10 +87,11 @@
.text { .text {
@apply border border-blue-200 overflow-hidden flex-1 rounded focus:border-blue-200 transition; @apply border border-blue-200 overflow-hidden flex-1 rounded focus:border-blue-200 transition;
&:hover{ &:hover {
@apply border-blue-500; @apply border-blue-500;
} }
&:focus-within{
&:focus-within {
@apply border-blue-500 shadow-md; @apply border-blue-500 shadow-md;
} }
} }

View File

@ -1,24 +1,56 @@
import React from "react"; import React from "react";
import clsx from "clsx"; import clsx from "clsx";
import {Popconfirm, Space} from "antd";
import {IconAdd, IconAddImage, IconAddText, IconDelete} from "@/components/icons"; import {IconAdd, IconDelete} from "@/components/icons";
import {BlockImage, BlockText} from "./item.tsx"; import ImageList from "@/components/article/list.tsx";
import { BlockText} from "./item.tsx";
import styles from './article.module.scss' import styles from './article.module.scss'
import {Button, Popconfirm} from "antd";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
index?:number; index?: number;
className?: string; className?: string;
blocks: BlockContent[]; blocks: BlockContent[];
editable?: boolean; editable?: boolean;
onChange?: (blocks: BlockContent[]) => void; onChange?: (blocks: BlockContent[]) => void;
onRemove?: () => void; onRemove?: () => void;
onAdd?: () => void; onAdd?: () => void;
errorMessage?: string;
} }
export default function ArticleBlock({className, blocks, editable, onRemove, onAdd, onChange,index}: Props) { function rebuildBlockArray(blocks: BlockContent[]) {
const textBlock: BlockContent = {
type: 'text',
content: ''
}
const _blocks: BlockContent[] = [textBlock];
const textArray: string[] = []
blocks.forEach(it => {
if (it.type == 'text') {
textArray.push(it.content)
} else {
_blocks.push(it)
}
})
textBlock.content = textArray.join('\n')
return _blocks
}
export default function ArticleBlock(
{
className,
blocks: defaultBlocks,
editable,
onRemove,
onAdd,
onChange,
index,
errorMessage
}: Props) {
const blocks = rebuildBlockArray(defaultBlocks)
const handleBlockRemove = (index: number) => { const handleBlockRemove = (index: number) => {
// 删除当前项 // 删除当前项
onChange?.(blocks.filter((_, idx) => index !== idx)) onChange?.(blocks.filter((_, idx) => index !== idx))
@ -43,72 +75,39 @@ export default function ArticleBlock({className, blocks, editable, onRemove, onA
} }
return <div className={styles.blockContainer}> return <div className={styles.blockContainer}>
<div className={clsx(className || '', styles.block,' hover:bg-blue-10')}> <div className={clsx(className || '', styles.block, index == 0 ? styles.blockFist : '', ' hover:bg-blue-10')}>
<div className={styles.blockBody}> <div className={styles.blockBody}>
{blocks.map((it, idx) => { <div>
const isFirstTextBlock = index == 0 && it.type ==='text' && firstTextBlockIndex == idx <div className={clsx(index == 0 ? '' : styles.blockItem, 'flex')}>
return (<div key={idx}> <BlockText
<div className={clsx(isFirstTextBlock?'':styles.blockItem, 'flex')}> isFirstBlock={true}
{ onChange={(block) => handleBlockChange(0, block)}
it.type === 'text' data={blocks[0]}
? <BlockText isFirstBlock={isFirstTextBlock} onChange={(block) => handleBlockChange(idx, block)} data={it} isFirstBlock={index == 0}
editable={editable}/> editable={editable}/>
: <BlockImage data={it} editable={editable}/> </div>
} {index == 0 && <div className="flex items-center text-red-500 justify-between text-sm mt-1">
{editable && <div className="create-container ml-2 flex flex-col justify-between"> <div>{errorMessage}</div>
{isFirstTextBlock?<span></span>:<Popconfirm <div></div>
title="提示" </div>}
description={<div style={{minWidth: 150}}> </div>
<span>{it.type === 'text' ? '文本' : '图片'}?</span> {index > 0 && <ImageList blocks={blocks} editable={editable} onChange={onChange}/>}
</div>}
onConfirm={() => handleBlockRemove(idx)}
okText="删除"
cancelText="取消"
>
<span
className="article-action-icon"
title={`删除此${it.type === 'text' ? '文本' : '图片'}`}>
<IconDelete style={{fontSize: 18}}/>
</span>
</Popconfirm>}
<div>
<span onClick={() => handleAddBlock('text', idx + 1)}
className="article-action-icon" title="新增文本"><IconAddText
style={{fontSize: 18}}/></span>
<span onClick={() => handleAddBlock('image', idx + 1)}
className="article-action-icon mt-1" title="新增图片"><IconAddImage
style={{fontSize: 16}}/></span>
</div>
</div>}
</div>
{isFirstTextBlock && <div className={'text-red-500 text-right pr-6 mt-1 text-sm'}></div>}
</div>)
}
)}
{editable && blocks.length == 0 &&
<div style={{minHeight: 80}} className="flex items-center justify-center">
<div className="flex gap-5">
<Button onClick={() => handleAddBlock('text')}></Button>
<Button onClick={() => handleAddBlock('image')}></Button>
</div>
</div>
}
</div> </div>
</div> </div>
{editable && <div className="ml-2 flex flex-col justify-between "> {editable && <div className="ml-2 flex flex-col justify-between ">
<Popconfirm {
title="提示" index > 0 ? <Popconfirm
description={<div style={{minWidth: 150}}> title={<div style={{minWidth: 150}}><span>?</span></div>}
<span>?</span> onConfirm={onRemove}
</div>} okText="删除"
onConfirm={onRemove} cancelText="取消"
okText="删除" >
cancelText="取消" <span className="article-action-icon" title="删除此分组">
> <IconDelete style={{fontSize: 24}}/>
<span className="article-action-icon" title="删除此分组"><IconDelete </span>
style={{fontSize: 24}}/></span> </Popconfirm> : <span></span>
</Popconfirm> }
<span onClick={onAdd} className="article-action-icon" title="新增分组"><IconAdd <span onClick={onAdd} className="article-action-icon" title="新增分组"><IconAdd
style={{fontSize: 24}}/></span> style={{fontSize: 24}}/></span>
</div>} </div>}

View File

@ -2,11 +2,13 @@ import {Input, Modal} from "antd";
import ArticleGroup from "@/components/article/group.tsx"; import ArticleGroup from "@/components/article/group.tsx";
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import {useSetState} from "ahooks"; import {useSetState} from "ahooks";
import {getById} from "@/service/api/article.ts"; import * as article from "@/service/api/article.ts";
import {regenerate} from "@/service/api/video.ts";
type Props = { type Props = {
id?: number; id?: number;
onClose?: () => void; type: 'news' | 'video';
onClose?: (saved?: boolean) => void;
} }
export default function ArticleEditModal(props: Props) { export default function ArticleEditModal(props: Props) {
@ -16,29 +18,59 @@ export default function ArticleEditModal(props: Props) {
const [state, setState] = useSetState({ const [state, setState] = useSetState({
loading: false, loading: false,
open: false open: false,
msgTitle: '',
msgGroup: '',
}) })
// 保存数据
const handleSave = () => { const handleSave = () => {
console.log(groups, title)
if (!title) {
// setState({msgTitle: '请输入标题内容'});
return;
}
if (groups.length == 0 || groups[0].length == 0 || !groups[0][0].content) {
// setState({msgGroup: '请输入正文文本内容'});
return;
}
const save = props.type == 'news' ? article.save : regenerate
setState({loading: true})
save(title, groups, props.id > 0 ? props.id : undefined).then(() => {
props.onClose?.(true)
}).finally(() => {
setState({loading: false})
});
props.onClose?.() props.onClose?.()
// if (props.onSave) {
// setState({loading: true})
// props.onSave?.().then(() => {
// setState({loading: false, open: false})
// })
// } else {
// console.log(groups)
// }
} }
useEffect(() => { useEffect(() => {
if(props.id){ if (props.id) {
if(props.id > 0){ if (props.id > 0) {
getById(props.id).then(res => { if (props.type == 'news') {
setGroups(res.content_group) article.getById(props.id).then(res => {
setTitle(res.title) setGroups(res.content_group)
}) setTitle(res.title)
}else{ })
setGroups([]) }
setTitle('') } else {
// 新增
setGroups([
[{
type: 'text',
content: '韩国国会当地时间14日16时举行全体会议就在野党阵营第二次提出的尹锡悦总统弹劾案进行表决。根据投票结果有204票赞成85票反对3票弃权8票无效弹劾案最终获得通过尹锡悦的总统职务立即停止。'
}],
[
{
type: 'text',
content: '韩国宪法法院将在180天内完成弹劾案审判程序。如果宪法法院作出弹劾案不成立的裁决尹锡悦将立即恢复总统职务如果宪法法院认可弹劾案成立尹锡悦将立即被罢免预计韩国将在明年4月至6月间举行大选。'
},
{
type: 'image',
content: 'https://zverse-on.oss-cn-shanghai.aliyuncs.com/metahuman/workbench/20241214/193c442df75.jpeg'
},
],
])
setTitle('韩国国会通过总统弹劾案 尹锡悦职务立即停止')
} }
} }
}, [props.id]) }, [props.id])
@ -49,7 +81,7 @@ export default function ArticleEditModal(props: Props) {
maskClosable={false} maskClosable={false}
keyboard={false} keyboard={false}
width={800} width={800}
onCancel={props.onClose} onCancel={()=>props.onClose?.()}
okButtonProps={{loading: state.loading}} okButtonProps={{loading: state.loading}}
onOk={handleSave} onOk={handleSave}
> >
@ -59,18 +91,26 @@ export default function ArticleEditModal(props: Props) {
<span className="require ml-1 font-bold text-red-500">*</span> <span className="require ml-1 font-bold text-red-500">*</span>
</div> </div>
<div className="box mt-1"> <div className="box mt-1">
<Input value={title} onChange={e => { <Input rootClassName={state.msgTitle ? 'border-red-500' : ''} value={title} onChange={e => {
setTitle(e.target.value) setTitle(e.target.value)
setState({msgTitle: e.target.value ? '' : '请输入标题内容'})
}} placeholder={'请输入文章标题'}/> }} placeholder={'请输入文章标题'}/>
</div> </div>
<div className="text-red-500">{state.msgTitle}</div>
</div> </div>
<div className="aricle-body mt-2"> <div className="aricle-body mt-3">
<div className="title"> <div className="title">
<span className="text text-base"></span> <span className="text text-base"></span>
<span className="require ml-1 font-bold text-red-500">*</span> <span className="require ml-1 font-bold text-red-500">*</span>
</div> </div>
<div className="box mt-1"> <div className="box mt-1">
<ArticleGroup editable groups={groups} onChange={list => setGroups(() => list)}/> <ArticleGroup
errorMessage={state.msgGroup} editable groups={groups}
onChange={list => {
setGroups(() => list)
setState({msgGroup: (list.length == 0 || list[0].length == 0 || !list[0][0].content) ? '请输入正文文本内容' : ''});
}}
/>
</div> </div>
</div> </div>
</Modal>); </Modal>);

View File

@ -1,22 +1,46 @@
import {message} from "antd" import {message} from "antd"
import ArticleBlock from "@/components/article/block.tsx"; import ArticleBlock from "@/components/article/block.tsx";
import styles from './article.module.scss' import styles from './article.module.scss'
import {showToast} from "@/components/message.ts";
type Props = { type Props = {
groups: BlockContent[][]; groups: BlockContent[][];
editable?: boolean; editable?: boolean;
onChange?: (groups: BlockContent[][]) => void; onChange?: (groups: BlockContent[][]) => void;
errorMessage?: string;
} }
export default function ArticleGroup({groups, editable, onChange}: Props) {
function rebuildGroups(groups: BlockContent[][]) {
if (groups.length < 2) {
Array(2 - groups.length).fill([{type: 'text', content: ''}]).forEach((it) => {
groups.push(it)
})
}
return groups;
}
export default function ArticleGroup({groups: _groups, editable, onChange,errorMessage}: Props) {
const groups = rebuildGroups(_groups)
/** /**
* *
* @param insertIndex -1 * @param insertIndex -1
*/ */
const handleAddGroup = ( insertIndex: number = -1) => { const handleAddGroup = (insertIndex: number = -1) => {
const newGroup: BlockContent[] = [] if (insertIndex !== -1 && insertIndex !== 1) {
const _groups = [...groups] const triggerGroup = insertIndex == -1 || insertIndex >= groups.length ? groups[groups.length - 1] : groups[insertIndex - 1];
// 判断
if (triggerGroup.length == 0 || triggerGroup.some(s => !s.content)) {
showToast('请先添加内容')
return;
}
}
const newGroup: BlockContent[] = [{type: 'text', content: ''}]
const _groups = [...groups];
if (insertIndex == -1 || insertIndex >= groups.length) { // -1或者越界表示新增 if (insertIndex == -1 || insertIndex >= groups.length) { // -1或者越界表示新增
_groups.push(newGroup) _groups.push(newGroup)
} else { } else {
@ -34,6 +58,7 @@ export default function ArticleGroup({groups, editable, onChange}: Props) {
groups[index] = blocks groups[index] = blocks
onChange?.([...groups]) onChange?.([...groups])
}} }}
errorMessage={errorMessage}
index={index} index={index}
onAdd={() => { onAdd={() => {
handleAddGroup?.(index + 1) handleAddGroup?.(index + 1)
@ -48,6 +73,7 @@ export default function ArticleGroup({groups, editable, onChange}: Props) {
/> />
))} ))}
{groups.length == 0 && editable && {groups.length == 0 && editable &&
<ArticleBlock editable onChange={blocks => onChange?.([blocks])} index={0} blocks={[{type:'text',content:''}]}/>} <ArticleBlock editable onChange={blocks => onChange?.([blocks])} index={0}
blocks={[{type: 'text', content: ''}]}/>}
</div> </div>
} }

View File

@ -1,10 +1,12 @@
import React, {useState} from "react"; import React, {useState} from "react";
import {Button, Input, Spin, Upload, UploadProps} from "antd"; import {Input, Popconfirm, Spin, Upload, UploadProps} from "antd";
import {CloseOutlined,CloudUploadOutlined} from "@ant-design/icons";
import {clsx} from "clsx";
import styles from './article.module.scss' import styles from './article.module.scss'
import {getOssPolicy} from "@/service/api/common.ts"; import {getOssPolicy} from "@/service/api/common.ts";
import {showToast} from "@/components/message.ts"; import {showToast} from "@/components/message.ts";
import {clsx} from "clsx"; import {IconAddImage} from "@/components/icons";
type Props = { type Props = {
children?: React.ReactNode; children?: React.ReactNode;
@ -14,11 +16,15 @@ type Props = {
onChange?: (data: BlockContent) => void; onChange?: (data: BlockContent) => void;
isFirstBlock?: boolean; isFirstBlock?: boolean;
} }
type ImageProps = {
onRemove?: () => void;
onlyUpload?: boolean;
} & Props;
const MimeTypes = ['image/jpeg', 'image/png', 'image/jpg'] const MimeTypes = ['image/jpeg', 'image/png', 'image/jpg']
const Data: { uploadConfig?: TOSSPolicy } = {} const Data: { uploadConfig?: TOSSPolicy } = {}
export function BlockImage({data, editable, onChange}: Props) { export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: ImageProps) {
const [loading, setLoading] = useState<number>(-1) const [loading, setLoading] = useState<number>(-1)
// oss上传文件所需的数据 // oss上传文件所需的数据
@ -48,7 +54,7 @@ export function BlockImage({data, editable, onChange}: Props) {
console.log('onChange', file); console.log('onChange', file);
if (file.status == 'done') { if (file.status == 'done') {
setLoading(-1) setLoading(-1)
onChange?.({type: 'image', content: Data.uploadConfig?.host + file.url}) onChange?.({type: 'image', content: Data.uploadConfig?.host + '/' + file.url})
} else if (file.status == 'error') { } else if (file.status == 'error') {
setLoading(-1) setLoading(-1)
showToast('上传图片失败,请重试', 'warning') showToast('上传图片失败,请重试', 'warning')
@ -58,7 +64,15 @@ export function BlockImage({data, editable, onChange}: Props) {
} }
// //
return <div className={styles.image}> return <div className={styles.image}>
{editable ? <div> {editable ? <div className={'relative'}>
{!onlyUpload && <Popconfirm
title={<div style={{minWidth: 150}}><span>?</span></div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
>
<span className={styles.imageDelete}><CloseOutlined/></span>
</Popconfirm>}
<Spin spinning={loading >= 0} percent={loading == 0 ? 'auto' : loading}> <Spin spinning={loading >= 0} percent={loading == 0 ? 'auto' : loading}>
<Upload <Upload
multiple={false} maxCount={1} data={getUploadData} multiple={false} maxCount={1} data={getUploadData}
@ -73,7 +87,10 @@ export function BlockImage({data, editable, onChange}: Props) {
<span></span> <span></span>
</div> </div>
</> : <div className={styles.imagePlaceholder}> </> : <div className={styles.imagePlaceholder}>
<Button></Button> <div className={'text-center'}>
<IconAddImage className={"text-4xl inline-block"} />
<div className={'text-sm'}></div>
</div>
</div>} </div>}
</div> </div>
</Upload> </Upload>
@ -84,13 +101,14 @@ export function BlockImage({data, editable, onChange}: Props) {
export function BlockText({data, editable, onChange, isFirstBlock}: Props) { export function BlockText({data, editable, onChange, isFirstBlock}: Props) {
return <div className='flex-1'> return <div className='flex-1'>
<div className={clsx(styles.text, isFirstBlock?'border-red-400 hover:border-red-500 focus-within:border-red-500':'')}> <div
className={clsx(styles.text, isFirstBlock ? 'border-red-400 hover:border-red-500 focus-within:border-red-500' : '')}>
{editable ? <div className="relative"> {editable ? <div className="relative">
<Input.TextArea <Input.TextArea
onChange={e => { onChange={e => {
onChange?.({type: 'text', content: e.target.value}) onChange?.({type: 'text', content: e.target.value})
}} }}
placeholder={'请输入文本'} value={data.content} autoSize={{minRows: 3, maxRows: 8}} placeholder={'请输入文本内容'} value={data.content} autoSize={{minRows: 3, maxRows: 8}}
variant={"borderless"}/> variant={"borderless"}/>
</div> : <p className="p-2">{data.content}</p>} </div> : <p className="p-2">{data.content}</p>}
</div> </div>

View File

@ -0,0 +1,42 @@
import React from "react";
import {BlockImage} from "@/components/article/item.tsx";
import styles from './article.module.scss'
export default function ImageList(props: {
blocks: BlockContent[];
editable?: boolean;
onChange?: (blocks: BlockContent[]) => void;
}) {
// 处理删除
const handleRemove = (index: number) => {
props.onChange?.(props.blocks.filter((_, idx) => index !== idx))
const newBlocks = [...props.blocks]
newBlocks.splice(index, 1)
props.onChange?.(newBlocks)
}
// 处理新增
const handleAdd = (data: BlockContent) => {
props.onChange?.([...props.blocks, data])
}
// 处理更新
const handleUpdate = (index: number, data: BlockContent) => {
props.onChange?.(props.blocks.map((it, idx) => idx === index ? data : it))
}
return (<div className={styles.imageList}>
{props.blocks.map((it, index) => (
it.type === 'image' ? <BlockImage
key={index} data={it} editable={props.editable}
onChange={(data) => handleUpdate(index, data)}
onRemove={() => handleRemove(index)}
/> : null
))}
{props.editable &&
<BlockImage onlyUpload onChange={handleAdd} data={{type: 'image', content: ''}} editable={true}/>}
</div>)
}

View File

@ -1,4 +1,5 @@
import {message} from "antd"; import {message} from "antd";
import {BizError} from "@/service/types.ts";
export function showToast(content: string, type?: 'success' | 'info' | 'warning' | 'error') { export function showToast(content: string, type?: 'success' | 'info' | 'warning' | 'error') {
@ -8,6 +9,10 @@ export function showToast(content: string, type?: 'success' | 'info' | 'warning'
className: 'aui-toast' className: 'aui-toast'
}).then(); }).then();
} }
export function showErrorToast(e:Error|BizError) {
showToast(String(((e instanceof BizError)?e.data:'') || e.message),'error')
}
export function showLoading(content = 'Loading...') { export function showLoading(content = 'Loading...') {
const key = 'globalLoading_' + (new Date().getTime()); const key = 'globalLoading_' + (new Date().getTime());

View File

@ -8,7 +8,7 @@ import {IconEdit, IconPlay} from "@/components/icons";
import {Popconfirm} from "antd"; import {Popconfirm} from "antd";
type Props = { type Props = {
video: VideoInfo, video: VideoInfo | LiveVideoInfo,
editable?: boolean; editable?: boolean;
sortable?: boolean; sortable?: boolean;
index?: number; index?: number;
@ -25,7 +25,6 @@ export const VideoListItem = (
{ {
index, id, video, onPlay, onRemove, checked, index, id, video, onPlay, onRemove, checked,
onCheckedChange, onEdit, active, editable, onCheckedChange, onEdit, active, editable,
}: Props) => { }: Props) => {
const { const {
attributes, listeners, attributes, listeners,
@ -42,14 +41,13 @@ export const VideoListItem = (
className={'video-item flex items-center gap-3 mb-5'} className={'video-item flex items-center gap-3 mb-5'}
ref={setNodeRef} style={{transform: `translateY(${transform?.y || 0}px)`,}}> ref={setNodeRef} style={{transform: `translateY(${transform?.y || 0}px)`,}}>
{index && index > 0 && <div className="flex items-center px-2"> {index && index > 0 && <div className="flex items-center px-2">
<div <div className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{index}</div>
className="index-value w-[40px] h-[40px] flex items-center justify-center bg-gray-100 rounded-3xl">{id}</div>
</div>} </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' : ''}`}> 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.title}</div> <div className={'video-title leading-7 flex-1'}>{video.title || video.video_title}</div>
<div className={'video-item-cover'}> <div className={'video-item-cover'}>
<img className="w-[100px] rounded-md" src={video.cover || ''} alt={video.title}/> <img className="w-[100px] rounded-md" src={video.cover_url || ''} alt={video.video_title}/>
</div> </div>
</div> </div>
{editable && {editable &&

View File

@ -17,9 +17,9 @@ export default function SearchForm({onSearch, onBtnStartClick}: Props) {
timeRange: string; timeRange: string;
keywords: string; keywords: string;
searching: boolean; searching: boolean;
time: string; time: number;
}>({ }>({
keywords: "", searching: false, timeRange: "", time: '-1' keywords: "", searching: false, timeRange: "", time: 0
}) })
const onFinish = (values: any) => { const onFinish = (values: any) => {
setState({searching: true}) setState({searching: true})
@ -52,12 +52,11 @@ export default function SearchForm({onSearch, onBtnStartClick}: Props) {
{/*<Form.Item label={'更新时间'} name="timeRange">*/} {/*<Form.Item label={'更新时间'} name="timeRange">*/}
{/* <DatePicker.RangePicker />*/} {/* <DatePicker.RangePicker />*/}
{/*</Form.Item>*/} {/*</Form.Item>*/}
{/*<Form.Item>*/} <Form.Item>
{/* <Space size={10}>*/} <Space size={10}>
{/* <Button type={'primary'} htmlType={'submit'}>搜索</Button>*/} <Button type={'primary'} htmlType={'submit'}></Button>
{/* <Button htmlType={'reset'}>重置</Button>*/} </Space>
{/* </Space>*/} </Form.Item>
{/*</Form.Item>*/}
</Form> </Form>
</div> </div>
<Space size={10}> <Space size={10}>

View File

@ -1,15 +1,99 @@
import React, {useState} from "react"; import React, {useEffect, useState} from "react";
import {Button, message, Modal} from "antd"; import {Button, message, Modal} from "antd";
import {SortableContext, arrayMove} from '@dnd-kit/sortable'; import {SortableContext, arrayMove} from '@dnd-kit/sortable';
import {DndContext} from "@dnd-kit/core"; import {DndContext} from "@dnd-kit/core";
import {VideoListItem} from "@/components/video/video-list-item.tsx"; import {VideoListItem} from "@/components/video/video-list-item.tsx";
import {getList} from "@/service/api/live.ts";
import styles from './style.module.scss'
export default function LiveIndex() { export default function LiveIndex() {
const [videoData, setVideoData] = useState<VideoInfo[]>() const [videoData, setVideoData] = useState<LiveVideoInfo[]>([])
const [modal, contextHolder] = Modal.useModal() const [modal, contextHolder] = Modal.useModal()
const [checkedIdArray, setCheckedIdArray] = useState<number[]>([]) const [checkedIdArray, setCheckedIdArray] = useState<number[]>([])
const [editable,setEditable] = useState<boolean>(false) const [editable, setEditable] = useState<boolean>(false)
const [state, setState] = useState({
activeId: -1,
})
useEffect(() => {
getList().then(res => {
setVideoData([
{
id: 1,
video_id: 1,
video_title: '333专家分析丨叙土边境曼比季地理位置为何如此重要',
cover_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.jpg',
video_oss_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.mp4',
video_duration: 100,
status: 1,
order_no: '1'
},
{
id: 2,
video_id: 1,
video_title: '333专家分析丨叙土边境曼比季地理位置为何如此重要',
cover_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.jpg',
video_oss_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.mp4',
video_duration: 100,
status: 1,
order_no: '1'
},
{
id: 3,
video_id: 1,
video_title: '333专家分析丨叙土边境曼比季地理位置为何如此重要',
cover_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.jpg',
video_oss_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.mp4',
video_duration: 100,
status: 1,
order_no: '1'
},
{
id: 4,
video_id: 1,
video_title: '333专家分析丨叙土边境曼比季地理位置为何如此重要',
cover_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.jpg',
video_oss_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.mp4',
video_duration: 100,
status: 1,
order_no: '1'
},
{
id: 5,
video_id: 1,
video_title: '333专家分析丨叙土边境曼比季地理位置为何如此重要',
cover_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.jpg',
video_oss_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.mp4',
video_duration: 100,
status: 1,
order_no: '1'
},
{
id: 6,
video_id: 1,
video_title: '333专家分析丨叙土边境曼比季地理位置为何如此重要',
cover_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.jpg',
video_oss_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.mp4',
video_duration: 100,
status: 1,
order_no: '1'
},
{
id: 7,
video_id: 1,
video_title: '333专家分析丨叙土边境曼比季地理位置为何如此重要',
cover_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.jpg',
video_oss_url: 'https://gachafun.oss-cn-beijing.aliyuncs.com/2021/08/13/1628840269744.mp4',
video_duration: 100,
status: 1,
order_no: '1'
}
])
})
}, [])
const processDeleteVideo = async (_idArray: number[]) => { const processDeleteVideo = async (_idArray: number[]) => {
message.info('删除成功!!!' + _idArray.join('')); message.info('删除成功!!!' + _idArray.join(''));
} }
@ -42,19 +126,19 @@ export default function LiveIndex() {
</div> </div>
</div> </div>
</div> </div>
<div className="video-list-container flex-1"> <div className="video-list-container flex-1 ">
<div className=" bg-white py-8 px-6 rounded py-1"> <div className=" bg-white py-8 px-6 rounded py-1">
<div className="live-control flex justify-between mb-8"> <div className="live-control flex justify-between mb-8">
{editable ?<> {editable ? <>
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="primary" onClick={handleConfirm}></Button> <Button type="primary" onClick={handleConfirm}></Button>
<Button onClick={()=>setEditable(false)}>退</Button> <Button onClick={() => setEditable(false)}>退</Button>
</div> </div>
<div> <div>
<span className="cursor-pointer" onClick={handleDeleteBatch}></span> <span className="cursor-pointer" onClick={handleDeleteBatch}></span>
</div> </div>
</>: <div> </> : <div>
<Button type="primary" onClick={()=>setEditable(true)}></Button> <Button type="primary" onClick={() => setEditable(true)}></Button>
</div>} </div>}
</div> </div>
@ -86,7 +170,7 @@ export default function LiveIndex() {
video={v} video={v}
index={index + 1} index={index + 1}
id={v.id} id={v.id}
active={index == 0} active={state.activeId == v.id}
key={index} key={index}
onCheckedChange={(checked) => { onCheckedChange={(checked) => {
setCheckedIdArray(idArray => { setCheckedIdArray(idArray => {

View File

@ -0,0 +1,3 @@
.videoListContainer{
}

View File

@ -1,26 +1,26 @@
import {Button, Modal} from "antd"; import {Button, Modal} from "antd";
import React, {useState} from "react"; import React, {useState} from "react";
import {showToast} from "@/components/message.ts"; import {showErrorToast, showToast} from "@/components/message.ts";
import {push2article} from "@/service/api/news.ts"; import {push2video} from "@/service/api/article.ts";
export default function ButtonPush2Video(props: { ids: Id[]}){ export default function ButtonPush2Video(props: { ids: Id[] }) {
const [loading,setLoading] = useState(false) const [loading, setLoading] = useState(false)
const handlePush = ()=>{ const handlePush = () => {
setLoading(true) setLoading(true)
push2article(props.ids).then(()=>{ push2video(props.ids).then(() => {
showToast('一键推流成功,已成功推入数字人视频生成,请前往数字人视频生成页面查看!', 'success') showToast('一键推流成功,已成功推入数字人视频生成,请前往数字人视频生成页面查看!', 'success')
}).finally(()=>{ }).catch(showErrorToast).finally(() => {
setLoading(false) setLoading(false)
}) })
} }
const onPushClick = ()=>{ const onPushClick = () => {
if (props.ids.length === 0) { if (props.ids.length === 0) {
showToast('请选择要开播的新闻', 'warning') showToast('请选择要开播的新闻', 'warning')
return return
} }
Modal.confirm({ Modal.confirm({
title:'操作提示', title: '操作提示',
content: '是否确定一键开播选中新闻?', content: '是否确定一键开播选中新闻?',
onOk: handlePush onOk: handlePush
}) })

View File

@ -14,12 +14,9 @@ export default function NewEdit() {
const [editId, setEditId] = useState(-1) const [editId, setEditId] = useState(-1)
const [selectedRowKeys, setSelectedRowKeys] = useState<Id[]>([]) const [selectedRowKeys, setSelectedRowKeys] = useState<Id[]>([])
const [params, setParams] = useState<ApiArticleSearchParams>({ const [params, setParams] = useState<ApiArticleSearchParams>({
pagination: { pagination: {page: 1, limit: 10}
page: 1,
limit: 10
}
}) })
const {data} = useRequest(() => getList(params), {refreshDeps: [params]}) const {data, refresh} = useRequest(() => getList(params), {refreshDeps: [params]})
const columns: TableColumnsType<ListArticleItem> = [ const columns: TableColumnsType<ListArticleItem> = [
{ {
@ -86,15 +83,20 @@ export default function NewEdit() {
showSizeChanger={false} showSizeChanger={false}
simple={true} simple={true}
rootClassName={'simple-pagination'} rootClassName={'simple-pagination'}
onChange={(page) => setParams(prev=>({ onChange={(page) => setParams(prev => ({
...prev, ...prev,
pagination: {page, limit: 10} pagination: {page, limit: 10}
}))} }))}
/> />
<ButtonPush2Video ids={selectedRowKeys} /> <ButtonPush2Video ids={selectedRowKeys}/>
</div>} </div>}
</div> </div>
<ArticleEditModal id={editId} onClose={() => setEditId(-1)}/> <ArticleEditModal
type="news" id={editId}
onClose={(saved) => {
setEditId(-1)
if (saved) refresh()
}}/>
</Card> </Card>
</div>) </div>)
} }

View File

@ -95,7 +95,7 @@ export default function NewsIndex() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="title text-lg cursor-pointer" onClick={() => { <div className="title text-lg cursor-pointer" onClick={() => {
handleViewNewsDetail(item.id) handleViewNewsDetail(item.id)
}}>{item.id}{item.title}</div> }}>{item.title}</div>
{item.internal_article_id > 0 && {item.internal_article_id > 0 &&
<div className="text-sm text-blue-500"></div>} <div className="text-sm text-blue-500"></div>}
</div> </div>

View File

@ -0,0 +1,31 @@
import {Button, Modal} from "antd";
import React, {useState} from "react";
import {showErrorToast, showToast} from "@/components/message.ts";
import {push2room} from "@/service/api/video.ts";
export default function ButtonPush2Room(props: { ids: Id[]}){
const [loading,setLoading] = useState(false)
const handlePush = ()=>{
setLoading(true)
push2room(props.ids).then(()=>{
showToast('一键推流成功,已推流至数字人直播间,请前往数字人直播间页面查看!', 'success')
}).catch(showErrorToast).finally(()=>{
setLoading(false)
})
}
const onPushClick = ()=>{
if (props.ids.length === 0) {
showToast('请选择要推流的新闻', 'warning')
return
}
Modal.confirm({
title:'操作提示',
content: '是否确定一键推流选中新闻视频??',
onOk: handlePush
})
}
return (
<Button type="primary" loading={loading} onClick={onPushClick}></Button>
)
}

View File

@ -1,7 +1,5 @@
import {Button, message, Modal} from "antd"; import {message, Modal} from "antd";
import React, {useEffect, useRef, useState} from "react"; import React, {useEffect, useMemo, useRef, useState} from "react";
import {ArticleGroupList, MockVideoDataList} from "@/_local/mock-data";
import {DndContext} from "@dnd-kit/core"; import {DndContext} from "@dnd-kit/core";
import {arrayMove, SortableContext} from "@dnd-kit/sortable"; import {arrayMove, SortableContext} from "@dnd-kit/sortable";
import {VideoListItem} from "@/components/video/video-list-item.tsx"; import {VideoListItem} from "@/components/video/video-list-item.tsx";
@ -10,19 +8,18 @@ import {useSetState} from "ahooks";
import {CheckCircleFilled} from "@ant-design/icons"; import {CheckCircleFilled} from "@ant-design/icons";
import {clsx} from "clsx"; import {clsx} from "clsx";
import {getList} from "@/service/api/video.ts"; import {getList} from "@/service/api/video.ts";
import {formatDuration} from "@/util/strings.ts";
import ButtonPush2Room from "@/pages/video/components/button-push2room.tsx";
export default function CreateIndex() { export default function VideoIndex() {
const [editNews, setEditNews] = useSetState<{ const [editId, setEditId] = useState(-1)
title?: string;
groups?: ArticleContentGroup[];
}>({})
const [videoData, setVideoData] = useState<VideoInfo[]>([]) const [videoData, setVideoData] = useState<VideoInfo[]>([])
useEffect(() => { useEffect(() => {
getList({}).then((ret) => { getList().then((ret) => {
setVideoData(ret.list) setVideoData(ret.list || [])
}) })
}, []) }, [])
@ -63,13 +60,19 @@ export default function CreateIndex() {
}) })
} }
const totalDuration = useMemo(() => {
if(!videoData || videoData.length == 0) return 0;
// 计算总时长
return videoData.reduce((sum, v) => sum + v.duration, 0);
}, [videoData])
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-list-container bg-white p-10 rounded flex-1">
<div className="live-control flex justify-between mb-8"> <div className="live-control flex justify-between mb-8">
<div className="pl-[70px]"> <div className="pl-[70px]">
<span>视频时长: 00:00:29</span> <span>: {formatDuration(totalDuration)}</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<span className="cursor-pointer" onClick={handleDeleteBatch}></span> <span className="cursor-pointer" onClick={handleDeleteBatch}></span>
@ -99,6 +102,7 @@ export default function CreateIndex() {
} }
}}> }}>
<SortableContext items={videoData}> <SortableContext items={videoData}>
{videoData.map((v, index) => ( {videoData.map((v, index) => (
<VideoListItem <VideoListItem
video={v} video={v}
@ -115,25 +119,25 @@ export default function CreateIndex() {
}} }}
onPlay={() => playVideo(v)} onPlay={() => playVideo(v)}
onEdit={() => { onEdit={() => {
setEditNews({title: v.title, groups: [...ArticleGroupList]}) setEditId(v.article_id)
}} }}
editable editable
/>))} />))}
</SortableContext> </SortableContext>
</DndContext> </DndContext>
<div className="text-right mt-10"> <div className="text-right mt-10">
<Button type="primary"></Button> <ButtonPush2Room ids={checkedIdArray}/>
</div> </div>
</div> </div>
<div className="video-player-container ml-8 w-[400px] flex flex-col"> <div className="video-player-container ml-6 w-[450px] flex flex-col">
<div className="text-center text-base mt-10"></div> <div className="text-center text-base mt-10"></div>
<div className="video-player flex items-center justify-center flex-1"> <div className="video-player flex items-center justify-center mt-20">
<div className=" rounded overflow-hidden"> <div className=" rounded overflow-hidden">
<video ref={videoRef} controls autoPlay className="w-full bg-white min-w-[360px]"></video> <video ref={videoRef} controls autoPlay className="w-full bg-white min-w-[360px]"></video>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ArticleEditModal title={editNews.title} groups={editNews.groups}/> <ArticleEditModal id={editId} onClose={() => setEditId(-1)}/>
</div>) </div>)
} }

View File

@ -1,7 +1,7 @@
import {RouteObject} from "react-router-dom"; import {RouteObject} from "react-router-dom";
import ErrorBoundary from "@/routes/error.tsx"; import ErrorBoundary from "@/routes/error.tsx";
import UserAuth from "@/pages/user"; import UserAuth from "@/pages/user";
import CreateIndex from "@/pages/create"; import CreateIndex from "../pages/video";
import LibraryIndex from "@/pages/library"; import LibraryIndex from "@/pages/library";
import LiveIndex from "@/pages/live"; import LiveIndex from "@/pages/live";
import NewsIndex from "@/pages/news"; import NewsIndex from "@/pages/news";

View File

@ -22,12 +22,13 @@ export function getById(id: Id) {
} }
export function save(title: string, content_group: BlockContent[][], id: number) { export function save(title: string, content_group: BlockContent[][], id: number) {
return post<{ content: string }>({ return post<{ content: string }>(id && id > 0 ? '/article/modify' : '/article/create/new', {
url: '/spider/article', title,
data: { content_group,
title, id
content_group,
id
}
}) })
}
export function push2video(article_ids: Id[]) {
return post('/article/push2video', {article_ids})
} }

20
src/service/api/live.ts Normal file
View File

@ -0,0 +1,20 @@
import {post} from "@/service/request.ts";
export function playState() {
return post<{
id: number;
start_time?: number;
}>({url: '/room/playing'})
}
export function getList() {
return post<DataList<LiveVideoInfo>>('/room/list')
}
export function modifyOrder(ids: Id[]) {
return post('/video/order', {ids})
}
export function deleteById(ids: Id[]) {
return post('/video/remove', {ids})
}

View File

@ -1,10 +1,7 @@
import {post} from "@/service/request.ts"; import {post} from "@/service/request.ts";
export function getList(data: { export function getList() {
title?: string, return post<DataList<VideoInfo>>('/video/list')
time_flag?: number;
}) {
return post<DataList<VideoInfo>>({url: '/video/list', data})
} }
/** /**
@ -13,7 +10,7 @@ export function getList(data: {
* @param content_group * @param content_group
* @param article_id * @param article_id
*/ */
export function regenerate(title: string, content_group: BlockContent[][], article_id: number) { export function regenerate(title: string, content_group: BlockContent[][], article_id?: Id) {
return post<{ content: string }>({ return post<{ content: string }>({
url: '/video/regenerate', url: '/video/regenerate',
data: { data: {
@ -31,9 +28,11 @@ export function getById(id: Id) {
export function deleteById(id: Id) { export function deleteById(id: Id) {
return post({url: '/video/detail/' + id}) return post({url: '/video/detail/' + id})
} }
export function modifyOrder(ids: Id[]) { export function modifyOrder(ids: Id[]) {
return post({url: ' /video/modifyorder',data:{ids}}) return post('/video/modifyorder', {ids})
} }
export function push2room(ids: Id[]) {
return post({url: ' /video/push2room',data:{ids}}) export function push2room(video_ids: Id[]) {
return post('/video/push2room', {video_ids})
} }

View File

@ -2,6 +2,7 @@ import axios from 'axios';
import {stringify} from 'qs' import {stringify} from 'qs'
import {BizError} from './types'; import {BizError} from './types';
import {getAuthToken} from "@/hooks/useAuth.ts"; import {getAuthToken} from "@/hooks/useAuth.ts";
import {showToast} from "@/components/message.ts";
const JSON_FORMAT: string = 'application/json'; const JSON_FORMAT: string = 'application/json';
const REQUEST_TIMEOUT = 300000; // 超时时长5min const REQUEST_TIMEOUT = 300000; // 超时时长5min
@ -23,6 +24,7 @@ Axios.interceptors.request.use(config => {
} }
return config return config
}, err => { }, err => {
console.log('请求拦截器报错',err)
return Promise.reject(err) return Promise.reject(err)
}) })
@ -46,11 +48,11 @@ export function request<T>(options: RequestOption) {
return; return;
} }
// const // const
const {code, message, data, request_id} = res.data const {code, msg, data, trace_id} = res.data
if (code == 0) { if (code == 0) {
resolve(data as unknown as T) resolve(data as unknown as T)
} else { } else {
reject(new BizError(message, code, request_id, data as unknown as AllType)) reject(new BizError(msg, code, trace_id, data as unknown as AllType))
} }
}).catch(e => { }).catch(e => {
reject(new BizError(e.message, 500)) reject(new BizError(e.message, 500))
@ -59,9 +61,13 @@ export function request<T>(options: RequestOption) {
} }
export function post<T>(params: RequestOption) { export function post<T>(params: RequestOption | string, _data?: AllType) {
const options = typeof params === 'string' ? {url: params} : params;
if (_data) {
options.data = _data
}
return request<T>({ return request<T>({
...params, ...options,
method: 'post' method: 'post'
}) })
} }

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

@ -89,3 +89,15 @@ declare interface VideoInfo {
article_id: number; article_id: number;
status: number; status: number;
} }
// room live
declare interface LiveVideoInfo {
id: number;
video_id: number;
video_title: string;
cover_url: string;
video_duration: number;
video_oss_url: string;
status: number;
order_no: string;
}

View File

@ -1,5 +1,6 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime" import relativeTime from "dayjs/plugin/relativeTime"
import {padStart} from "lodash";
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
@ -79,4 +80,14 @@ export function calcContentLengthLikeWord(str:string) {
} catch (e) { } catch (e) {
return str.length return str.length
} }
}
// 将时长转换成 时:分:秒
export function formatDuration(duration: number) {
const hour = Math.floor(duration / 3600);
const minute = Math.floor((duration - hour * 3600) / 60);
const second = duration - hour * 3600 - minute * 60;
// 需要补0
return padStart(hour.toString(), 2, '0') + ':' + padStart(minute.toString(), 2, '0') + ':' + padStart(second.toString(), 2, '0')
// return `${hour}:${minute}:${second}`
} }