💄 update 新闻内容编辑器

This commit is contained in:
LittleBoy 2024-12-22 17:47:34 +08:00 committed by Coding
parent cb1eb51ba4
commit d4e9dde7df
10 changed files with 325 additions and 143 deletions

View File

@ -185,13 +185,17 @@
font-size: 24px;
}
}
.data-list-container {
height: calc(100vh - var(--app-header-header) - 200px);
.list-scroller-container{
overflow: auto;
margin-right: -20px;
padding-right: 16px;
scrollbar-gutter: stable;
}
.data-list-container {
@apply list-scroller-container;
height: calc(100vh - var(--app-header-header) - 200px);
.data-list-container-inner {
@ -229,8 +233,39 @@
}
.ant-modal-body {
padding: 20px;
padding: 10px 0;
}
.ant-modal-confirm-content{
color: #999;
}
.ant-modal-confirm-btns{
margin-top: 40px;
}
}
}
.article-edit-modal{
.ant-modal {
.ant-modal-content {
@apply bg-white p-0;
.ant-modal-body{
@apply p-0;
}
}
}
.article-title{
@apply px-6 pt-10 pb-6;
}
.article-body{
@apply p-6
}
.modal-control-footer{
@apply p-6
}
.input-box{
// focus-within:shadow
@apply bg-[#f8f8f8] border border-transparent w-full px-4 py-2 focus-within:bg-[#f0f0f0] focus-within:border-gray-300;
border-radius: 8px;
}
}
@ -238,28 +273,17 @@
.page-action {
@apply fixed right-10 bottom-10 flex flex-col gap-4;
button {
@apply border-0 min-w-[120px] h-[40px] rounded-3xl text-white bg-blue-500 pl-4;
@apply border-0 min-w-[120px] h-[40px] rounded-3xl text-white pr-4 flex items-center justify-between;
.text {
flex: 1;
}
&:hover {
@apply bg-blue-600;
}
&:active {
@apply bg-blue-700;
}
&:disabled {
@apply bg-gray-400;
}
&.btn-info {
@apply bg-info text-gray-800;
.svg-icon {
@apply text-gray-800;
}
}
}
}

View File

@ -1,10 +1,30 @@
.blockContainer {
@apply flex mb-5;
@apply relative;
:global{
.divider-container{
@apply absolute inset-x-2 z-10;
&.before{
top:-12px;
}
&.after{
bottom: -10px;
}
.ant-divider-horizontal{
margin: 0;
border-block-start: 1px rgba(5, 5, 5,0.1);
}
}
.article-action-add{
@apply text-gray-400 text-sm inline-block bg-[#cce2ff] w-[80px] justify-center flex rounded-xl cursor-pointer hover:bg-blue-300 hover:text-white;
}
}
}
.blockInner{
@apply flex px-4 py-10 hover:bg-[#e6ebf1] ;
}
.blockFooter{}
.block {
@apply border border-gray-300 border-dashed p-3 rounded flex-1;
@apply flex-1;
&:last-child {
@apply mb-0;
}
@ -23,10 +43,28 @@
}
.group {
@apply flex gap-4;
:global{
.area-title{
@apply text-gray-400 text-sm text-gray-800;
}
.digital-person{
width: 450px;
}
.panel{
@apply flex flex-col;
}
.panel-body{
@apply bg-[#f0f0f0] flex-1 rounded-xl mt-2;
}
}
}
.imagerOrText{
@apply bg-[#f8f8f8] rounded-xl py-2;
}
.imageList {
@apply grid grid-cols-4 gap-4 p-3 border border-blue-200;
@apply grid grid-cols-4 imagerOrText px-2 gap-2;
:global {
.ant-upload-wrapper {
display: block;
@ -57,19 +95,14 @@
}
}
.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;
}
@apply absolute flex items-center justify-center right-0 top-0 w-[22px] h-[22px] rounded-full cursor-pointer z-10 ;
font-size: 16px;
}
.uploadImage {
@apply flex justify-center items-center relative h-[100px] text-gray-400;
.uploadTips {
@apply absolute inset-0 cursor-pointer opacity-0 transition rounded flex items-center justify-center bg-black/20 text-white;
@apply absolute inset-0 cursor-pointer opacity-0 transition rounded flex items-center justify-center bg-black/50 text-white;
}
.imagePlaceholder {
@ -86,13 +119,14 @@
}
.text {
@apply border border-blue-200 overflow-hidden flex-1 rounded focus:border-blue-200 transition;
@apply imagerOrText overflow-hidden flex-1 rounded focus:border-blue-200 transition;
&:hover {
@apply border-blue-500;
}
&:focus-within {
@apply border-blue-500 shadow-md;
:global{
.ant-input{
@apply px-4;
}
}
}

View File

@ -1,8 +1,8 @@
import React from "react";
import clsx from "clsx";
import {Popconfirm} from "antd";
import {Divider, Popconfirm} from "antd";
import {IconAdd, IconDelete} from "@/components/icons";
import {IconAdd, IconAddCircle, IconDelete} from "@/components/icons";
import ImageList from "@/components/article/list.tsx";
import { BlockText} from "./item.tsx";
@ -48,7 +48,7 @@ export default function ArticleBlock(
onAdd,
onChange,
index,
errorMessage
errorMessage,
}: Props) {
const blocks = rebuildBlockArray(defaultBlocks)
@ -58,41 +58,43 @@ export default function ArticleBlock(
onChange?.(_blocks)
}
return <div className={styles.blockContainer}>
<div className={clsx(className || '', styles.block, index == 0 ? styles.blockFist : '', ' hover:bg-blue-10')}>
<div className={styles.blockBody}>
<div>
<div className={clsx(index == 0 ? '' : styles.blockItem, 'flex')}>
<BlockText
onChange={(block) => handleBlockChange(0, block)}
data={blocks[0]}
isFirstBlock={index == 0}
editable={editable}/>
return <div className={`${styles.blockContainer} group`}>
{editable && index == 1 && <div className={'divider-container before'}><Divider>
<span onClick={onAdd} className="article-action-add" title="新增分组"><IconAdd style={{fontSize: 24}}/></span>
</Divider></div> }
<div className={styles.blockInner}>
<div className={clsx(className || '', styles.block, index == 0 ? styles.blockFist : '', ' hover:bg-blue-10')}>
<div className={styles.blockBody}>
<div>
<div className={clsx(index == 0 ? '' : styles.blockItem, 'flex')}>
<BlockText
onChange={(block) => handleBlockChange(0, block)}
data={blocks[0]}
isFirstBlock={index == 0}
editable={editable}/>
</div>
</div>
{index == 0 && <div className="flex items-center text-red-500 justify-between text-sm mt-1">
<div>{errorMessage}</div>
<div></div>
</div>}
<ImageList blocks={blocks} editable={editable} onChange={onChange}/>
</div>
{index > 0 && <ImageList blocks={blocks} editable={editable} onChange={onChange}/>}
</div>
</div>
{editable && <div className="ml-2 flex flex-col justify-between ">
{
index > 0 ? <Popconfirm
title={<div style={{minWidth: 150}}><span>?</span></div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
>
<span className="article-action-icon" title="删除此分组">
{editable && <div className="ml-2 flex items-center">
{
index > 0 ? <Popconfirm
title={<div style={{minWidth: 150}}><span>?</span></div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
>
<span className="article-action-icon hidden group-hover:block" title="删除此分组">
<IconDelete style={{fontSize: 24}}/>
</span>
</Popconfirm> : <span></span>
}
<span onClick={onAdd} className="article-action-icon" title="新增分组"><IconAdd
style={{fontSize: 24}}/></span>
</div>}
</Popconfirm> : <span></span>
}
</div>}
</div>
{editable && <div className={'divider-container after'}><Divider>
<span onClick={onAdd} className="article-action-add" title="新增分组"><IconAdd style={{fontSize: 24}}/></span>
</Divider></div> }
</div>
}

View File

@ -1,4 +1,4 @@
import {Input, Modal} from "antd";
import {Input, Modal, Space} from "antd";
import ArticleGroup from "@/components/article/group.tsx";
import {useEffect, useState} from "react";
import {useSetState} from "ahooks";
@ -16,12 +16,12 @@ const DEFAULT_STATE = {
open: false,
msgTitle: '',
msgGroup: '',
error:''
error: ''
}
function pushBlocksToGroup(blocks: BlockContent[],groups: BlockContent[][]){
function pushBlocksToGroup(blocks: BlockContent[], groups: BlockContent[][]) {
const lastGroup = groups[groups.length - 1]
if (lastGroup && lastGroup.filter(s=>s.type == 'text').length == 0) {
if (lastGroup && lastGroup.filter(s => s.type == 'text').length == 0) {
// 如果上一个group中没有文本则直接合并
lastGroup.push(...blocks)
} else {
@ -32,21 +32,21 @@ function pushBlocksToGroup(blocks: BlockContent[],groups: BlockContent[][]){
function rebuildGroups(groups: BlockContent[][]) {
const _groups: BlockContent[][] = [];
if (!groups || groups.length == 0) return _groups;
groups.forEach((blocks,index) => {
if(!blocks) return;
blocks = blocks.filter(s=>!!s).sort((a,b) => {
if(a.type == 'text' && b.type == 'text') return 1;
groups.forEach((blocks, index) => {
if (!blocks) return;
blocks = blocks.filter(s => !!s).sort((a, b) => {
if (a.type == 'text' && b.type == 'text') return 1;
return a.type == 'text' ? -1 : 1
})
if (blocks.length == 1) {
if(index == 0) _groups.push(blocks)
else pushBlocksToGroup(blocks,_groups)
if (index == 0) _groups.push(blocks)
else pushBlocksToGroup(blocks, _groups)
} else {
if(index == 0){
if (index == 0) {
_groups.push([blocks[0]])
_groups.push(blocks.slice(1))
}else{
pushBlocksToGroup(blocks,_groups)
} else {
pushBlocksToGroup(blocks, _groups)
}
}
});
@ -60,6 +60,7 @@ function rebuildGroups(groups: BlockContent[][]) {
}
export default function ArticleEditModal(props: Props) {
const [groups, setGroups] = useState<BlockContent[][]>([]);
@ -83,7 +84,7 @@ export default function ArticleEditModal(props: Props) {
setState({loading: true})
save(title, groups, props.id && props.id > 0 ? props.id : undefined).then(() => {
props.onClose?.(true)
}).catch(e=>{
}).catch(e => {
setState({error: e.data || '保存失败,请重试!'})
}).finally(() => {
setState({loading: false})
@ -91,7 +92,7 @@ export default function ArticleEditModal(props: Props) {
}
useEffect(() => {
setState({...DEFAULT_STATE})
if (typeof(props.id) != 'undefined') {
if (typeof (props.id) != 'undefined') {
if (props.id > 0) {
article.getById(props.id).then(res => {
setGroups(rebuildGroups(res.content_group))
@ -106,34 +107,28 @@ export default function ArticleEditModal(props: Props) {
}, [props.id])
return (<Modal
title={'编辑文章'}
title={null}
centered={true}
rootClassName={"article-edit-modal"}
open={props.id != undefined && props.id >= 0}
maskClosable={false}
keyboard={false}
width={800}
onCancel={()=>props.onClose?.()}
width={'1200px'}
footer={null}
closeIcon={null}
onCancel={() => props.onClose?.()}
okButtonProps={{loading: state.loading}}
onOk={handleSave}
okText={props.type == 'news' ? '确定' : '重新生成'}
>
<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 rootClassName={state.msgTitle ? 'border-red-500' : ''} value={title} onChange={e => {
setTitle(e.target.value)
setState({msgTitle: e.target.value ? '' : '请输入标题内容'})
}} placeholder={'请输入文章标题'}/>
</div>
<input className={'input-box text-lg'} value={title} onChange={e => {
setTitle(e.target.value)
setState({msgTitle: e.target.value ? '' : '请输入标题内容'})
}} placeholder={'请输入文章标题'}/>
<div className="text-red-500">{state.msgTitle}</div>
</div>
<div className="aricle-body mt-3">
<div className="title">
<span className="text text-base"></span>
<span className="require ml-1 font-bold text-red-500">*</span>
</div>
<div className="article-body mt-3">
<div className="box mt-1">
<ArticleGroup
errorMessage={state.msgGroup} editable groups={groups}
@ -145,5 +140,12 @@ export default function ArticleEditModal(props: Props) {
</div>
{state.error && <div className="text-red-500">{state.error}</div>}
</div>
<div className="modal-control-footer flex justify-end">
<Space>
{props.type == 'news' ? <button></button> : null}
<button onClick={() => props.onClose?.()}></button>
<button>{props.type == 'news' ? '确定' : '重新生成'}</button>
</Space>
</div>
</Modal>);
}

View File

@ -1,8 +1,9 @@
import {message} from "antd"
import {Input, message} from "antd"
import ArticleBlock from "@/components/article/block.tsx";
import styles from './article.module.scss'
import {showToast} from "@/components/message.ts";
import React from "react";
type Props = {
groups: BlockContent[][];
@ -12,7 +13,6 @@ type Props = {
}
export default function ArticleGroup({groups, editable, onChange, errorMessage}: Props) {
// const groups = rebuildGroups(_groups)
/**
@ -38,30 +38,69 @@ export default function ArticleGroup({groups, editable, onChange, errorMessage}:
}
onChange?.(_groups)
}
const handleDigitalPersonContentChange = (content:string) => {
groups[0] = [{type: 'text', content}]
onChange?.([...groups])
}
return <div className={styles.group}>
{groups.map((g, index) => (
<ArticleBlock
editable={editable}
key={index}
blocks={g}
onChange={(blocks) => {
groups[index] = blocks
onChange?.([...groups])
}}
errorMessage={errorMessage}
index={index}
onAdd={() => {
handleAddGroup?.(index + 1)
}}
onRemove={async () => {
if (groups.length == 1) {
message.warning('至少保留一个内容块')
return;
}
onChange?.(groups.filter((_, idx) => index !== idx))
}}
/>
))}
<div className={'panel digital-person'}>
<div className="area-title">
<span className=""></span>
<span className="text-gray-400"></span>
</div>
<div className="panel-body p-3">
{/* value={groups || groups[0][0].content}*/}
<div className="h-[486px] pt-2 rounded-xl overflow-hidden bg-gray-50">
{editable ? <div className="relative">
<Input.TextArea
placeholder={'请输入文本内容'}
value={groups && groups.length > 0 ? groups[0][0].content : ''}
autoSize={{minRows: 20, maxRows: 21}}
variant={"borderless"}
onChange={e => {
handleDigitalPersonContentChange(e.target.value)
}}
/>
</div> : <p className="p-2">12123</p>}
</div>
</div>
</div>
<div className={"panel groups-list flex-1"}>
<div className={"area-title"}>
<span className=""></span>
<span className="text-gray-400"></span>
</div>
<div className="panel-body py-3">
<div className="max-h-[485px] overflow-auto py-4">
{groups.map((g, index) => (
index == 0 ? null : <ArticleBlock
editable={editable}
key={index}
blocks={g}
onChange={(blocks) => {
groups[index] = blocks
onChange?.([...groups])
}}
errorMessage={errorMessage}
index={index}
onAdd={() => {
handleAddGroup?.(index + 1)
}}
onRemove={async () => {
if (groups.length == 1) {
message.warning('至少保留一个内容块')
return;
}
onChange?.(groups.filter((_, idx) => index !== idx))
}}
/>
))}
</div>
</div>
</div>
{groups.length == 0 && editable &&
<ArticleBlock editable onChange={blocks => onChange?.([blocks])} index={0}
blocks={[{type: 'text', content: ''}]}/>}

View File

@ -64,15 +64,7 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
}
//
return <div className={styles.image}>
{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>}
{editable && onlyUpload ? <div className={'relative'}>
<Spin spinning={loading >= 0} percent={loading == 0 ? 'auto' : loading}>
<Upload
multiple={false} maxCount={1} data={getUploadData}
@ -84,7 +76,14 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
{data.content ? <>
<img src={data.content}/>
<div className={styles.uploadTips}>
<span></span>
{!onlyUpload && <Popconfirm
title={<div style={{minWidth: 150}}><span>?</span></div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
>
<span className={styles.imageDelete}><CloseOutlined/></span>
</Popconfirm>}
</div>
</> : <div className={styles.imagePlaceholder}>
<div className={'text-center'}>
@ -95,7 +94,19 @@ export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: Ima
</div>
</Upload>
</Spin>
</div> : <div className={styles.uploadImage}><img src={data.content}/></div>}
</div> : <div className={styles.uploadImage}>
<img src={data.content}/>
<div className={styles.uploadTips}>
{!onlyUpload && <Popconfirm
title={<div style={{minWidth: 150}}><span>?</span></div>}
onConfirm={onRemove}
okText="删除"
cancelText="取消"
>
<span className={styles.imageDelete}><CloseOutlined/></span>
</Popconfirm>}
</div>
</div>}
</div>
}
@ -108,7 +119,7 @@ export function BlockText({data, editable, onChange, isFirstBlock}: Props) {
onChange={e => {
onChange?.({type: 'text', content: e.target.value})
}}
placeholder={'请输入文本内容'} value={data.content} autoSize={{minRows: 3, maxRows: 8}}
placeholder={'请输入文本内容'} value={data.content} autoSize={{minRows: 4, maxRows: 5}}
variant={"borderless"}/>
</div> : <p className="p-2">{data.content}</p>}
</div>

View File

@ -97,7 +97,7 @@ export const IconAddImage = ({style, className}: IconProps) => (
</svg>
)
export const IconAdd = ({style, className}: IconProps) => (
export const IconAddCircle = ({style, className}: IconProps) => (
<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
@ -109,6 +109,17 @@ export const IconAdd = ({style, className}: IconProps) => (
fill="currentColor"/>
</svg>
)
export const IconAdd = ({style, className}: IconProps) => (
<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="M544 288v448c0 17.6-14.4 32-32 32s-32-14.4-32-32V288c0-17.6 14.4-32 32-32s32 14.4 32 32z"
fill="currentColor"/>
<path d="M736 544H288c-17.6 0-32-14.4-32-32s14.4-32 32-32h448c17.6 0 32 14.4 32 32s-14.4 32-32 32z"
fill="currentColor"/>
</svg>
)
export const IconPlay = ({style, className}: IconProps) => (
<svg className={`svg-icon ${className || ''} icon-delete`} style={style} xmlns="http://www.w3.org/2000/svg"

View File

@ -0,0 +1,47 @@
import {App, Button} from "antd";
import {showToast} from "@/components/message.ts";
import {useState} from "react";
import {push2article} from "@/service/api/news.ts";
import {IconArrowRight, IconDelete} from "@/components/icons";
import {useNavigate} from "react-router-dom";
export default function ButtonDeleteBatch(props: { ids: Id[]; }) {
const {modal} = App.useApp();
const [loading,setLoading] = useState(false)
const navigate = useNavigate();
const handlePush = () => {
setLoading(true)
push2article(props.ids).then(() => {
showToast('删除成功', 'success')
navigate('/edit')
}).catch(() => {
showToast('删除失败', 'error')
}).finally(() => {
setLoading(false)
})
}
const onPushClick = () => {
if (props.ids.length === 0) {
showToast('请选择要删除的新闻', 'warning')
return
}
modal.confirm({
title: '操作提示',
content: '是否确定删除选中的新闻?',
onOk: handlePush,
centered: true
})
}
return (
<div>
<button
loading={loading}
onClick={onPushClick}
className='bg-gray-400 hover:bg-gray-500'
>
<span className={'text'}></span>
<IconDelete className={'text-white'} />
</button>
</div>
)
}

View File

@ -2,9 +2,10 @@ import {Button, Modal} from "antd";
import React, {useState} from "react";
import {showErrorToast, showToast} from "@/components/message.ts";
import {push2video} from "@/service/api/article.ts";
import {IconArrowRight, IconDelete} from "@/components/icons";
export default function ButtonPush2Video(props: { ids: Id[];onSuccess?:()=>void; }) {
export default function ButtonPush2Video(props: { ids: Id[]; onSuccess?: () => void; }) {
const [loading, setLoading] = useState(false)
const handlePush = () => {
setLoading(true)
@ -27,6 +28,17 @@ export default function ButtonPush2Video(props: { ids: Id[];onSuccess?:()=>void;
})
}
return (
<Button type="primary" loading={loading} onClick={onPushClick}></Button>
<div>
<Button
type="primary"
loading={loading}
className='btn-action btn-gray-300'
icon={<IconArrowRight className={'text-white'}/>}
onClick={onPushClick}
iconPosition={'end'}
>
<span className={'text'}></span>
</Button>
</div>
)
}

View File

@ -13,6 +13,7 @@ import InfiniteScroller, {InfiniteScrollerRef} from "@/components/scoller/infini
import {IconDelete, IconEdit} from "@/components/icons";
import {clsx} from "clsx";
import ButtonToTop from "@/components/scoller/button-to-top.tsx";
import ButtonDeleteBatch from "@/pages/news/components/button-delete-batch.tsx";
export default function NewEdit() {
@ -127,9 +128,8 @@ export default function NewEdit() {
<div className="page-action">
<ButtonToTop visible={state.showToTop} onClick={()=>scrollerRef.current?.scrollToPosition(0)} />
<div>
<ButtonPush2Video ids={selectedRowKeys} onSuccess={refresh}/>
</div>
<ButtonDeleteBatch ids={selectedRowKeys} onSuccess={refresh}/>
<ButtonPush2Video ids={selectedRowKeys} onSuccess={refresh}/>
</div>
</div>
<ArticleEditModal