diff --git a/src/components/article/article.module.scss b/src/components/article/article.module.scss index 349a72a..1a06d04 100644 --- a/src/components/article/article.module.scss +++ b/src/components/article/article.module.scss @@ -1,5 +1,5 @@ .blockContainer { - @apply flex mb-5; + @apply flex mb-5; } .block { @@ -14,6 +14,10 @@ } } +.blockFist { + @apply p-0 border-0 !important; +} + .blockItem { } @@ -21,40 +25,61 @@ .group { } -.image { - @apply border border-blue-200 p-2 flex-1 rounded focus:border-blue-200; - min-height: 100px; - &:hover{ - @apply border-blue-500; - } - :global{ - .ant-upload-wrapper{ +.imageList { + @apply grid grid-cols-4 gap-4 p-3 border border-blue-200; + :global { + .ant-upload-wrapper { display: block; border: none; padding: 0; } - .ant-upload{ + + .ant-upload { 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 { - @apply flex justify-center items-center relative; - img { - display: block; - max-width: 100%; - max-height: 200px; + @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; } - .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; height: 100px; } - &:hover{ - .uploadTips{ + + &:hover { + @apply bg-gray-100 cursor-pointer rounded text-blue-500; + .uploadTips { @apply opacity-100; } } @@ -62,10 +87,11 @@ .text { @apply border border-blue-200 overflow-hidden flex-1 rounded focus:border-blue-200 transition; - &:hover{ + &:hover { @apply border-blue-500; } - &:focus-within{ + + &:focus-within { @apply border-blue-500 shadow-md; } } diff --git a/src/components/article/block.tsx b/src/components/article/block.tsx index 65882e6..1553d3e 100644 --- a/src/components/article/block.tsx +++ b/src/components/article/block.tsx @@ -1,24 +1,56 @@ import React from "react"; import clsx from "clsx"; +import {Popconfirm, Space} from "antd"; -import {IconAdd, IconAddImage, IconAddText, IconDelete} from "@/components/icons"; -import {BlockImage, BlockText} from "./item.tsx"; +import {IconAdd, IconDelete} from "@/components/icons"; +import ImageList from "@/components/article/list.tsx"; +import { BlockText} from "./item.tsx"; import styles from './article.module.scss' -import {Button, Popconfirm} from "antd"; type Props = { children?: React.ReactNode; - index?:number; + index?: number; className?: string; blocks: BlockContent[]; editable?: boolean; onChange?: (blocks: BlockContent[]) => void; onRemove?: () => 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) => { // 删除当前项 onChange?.(blocks.filter((_, idx) => index !== idx)) @@ -43,72 +75,39 @@ export default function ArticleBlock({className, blocks, editable, onRemove, onA } return
-
+
- {blocks.map((it, idx) => { - const isFirstTextBlock = index == 0 && it.type ==='text' && firstTextBlockIndex == idx - return (
-
- { - it.type === 'text' - ? handleBlockChange(idx, block)} data={it} - editable={editable}/> - : - } - {editable &&
- {isFirstTextBlock?: - 请确认删除此{it.type === 'text' ? '文本' : '图片'}? -
} - onConfirm={() => handleBlockRemove(idx)} - okText="删除" - cancelText="取消" - > - - - - } -
- handleAddBlock('text', idx + 1)} - className="article-action-icon" title="新增文本"> - handleAddBlock('image', idx + 1)} - className="article-action-icon mt-1" title="新增图片"> -
-
} -
- {isFirstTextBlock &&
该编辑框内容由数字人播报
} -
) - } - )} - {editable && blocks.length == 0 && -
-
- - -
-
- } +
+
+ handleBlockChange(0, block)} + data={blocks[0]} + isFirstBlock={index == 0} + editable={editable}/> +
+ {index == 0 &&
+
{errorMessage}
+
该编辑框内容由数字人播报
+
} +
+ {index > 0 && }
{editable &&
- - 请确认删除此删除此分组? -
} - onConfirm={onRemove} - okText="删除" - cancelText="取消" - > - - + { + index > 0 ? 请确认删除此删除此分组?
} + onConfirm={onRemove} + okText="删除" + cancelText="取消" + > + + + + : + } } diff --git a/src/components/article/edit-modal.tsx b/src/components/article/edit-modal.tsx index 6a731b6..c658da7 100644 --- a/src/components/article/edit-modal.tsx +++ b/src/components/article/edit-modal.tsx @@ -2,11 +2,13 @@ import {Input, Modal} from "antd"; import ArticleGroup from "@/components/article/group.tsx"; import {useEffect, useState} from "react"; 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 = { id?: number; - onClose?: () => void; + type: 'news' | 'video'; + onClose?: (saved?: boolean) => void; } export default function ArticleEditModal(props: Props) { @@ -16,29 +18,59 @@ export default function ArticleEditModal(props: Props) { const [state, setState] = useSetState({ loading: false, - open: false + open: false, + msgTitle: '', + msgGroup: '', }) + // 保存数据 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?.() - // if (props.onSave) { - // setState({loading: true}) - // props.onSave?.().then(() => { - // setState({loading: false, open: false}) - // }) - // } else { - // console.log(groups) - // } } useEffect(() => { - if(props.id){ - if(props.id > 0){ - getById(props.id).then(res => { - setGroups(res.content_group) - setTitle(res.title) - }) - }else{ - setGroups([]) - setTitle('') + if (props.id) { + if (props.id > 0) { + if (props.type == 'news') { + article.getById(props.id).then(res => { + setGroups(res.content_group) + setTitle(res.title) + }) + } + } 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]) @@ -49,7 +81,7 @@ export default function ArticleEditModal(props: Props) { maskClosable={false} keyboard={false} width={800} - onCancel={props.onClose} + onCancel={()=>props.onClose?.()} okButtonProps={{loading: state.loading}} onOk={handleSave} > @@ -59,18 +91,26 @@ export default function ArticleEditModal(props: Props) { *
- { + { setTitle(e.target.value) + setState({msgTitle: e.target.value ? '' : '请输入标题内容'}) }} placeholder={'请输入文章标题'}/>
+
{state.msgTitle}
-
+
正文 *
- setGroups(() => list)}/> + { + setGroups(() => list) + setState({msgGroup: (list.length == 0 || list[0].length == 0 || !list[0][0].content) ? '请输入正文文本内容' : ''}); + }} + />
); diff --git a/src/components/article/group.tsx b/src/components/article/group.tsx index 20963f8..cf2158d 100644 --- a/src/components/article/group.tsx +++ b/src/components/article/group.tsx @@ -1,22 +1,46 @@ - import {message} from "antd" import ArticleBlock from "@/components/article/block.tsx"; import styles from './article.module.scss' +import {showToast} from "@/components/message.ts"; type Props = { groups: BlockContent[][]; editable?: boolean; 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表示插入到末尾 */ - const handleAddGroup = ( insertIndex: number = -1) => { - const newGroup: BlockContent[] = [] - const _groups = [...groups] + const handleAddGroup = (insertIndex: number = -1) => { + if (insertIndex !== -1 && insertIndex !== 1) { + 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或者越界表示新增 _groups.push(newGroup) } else { @@ -34,6 +58,7 @@ export default function ArticleGroup({groups, editable, onChange}: Props) { groups[index] = blocks onChange?.([...groups]) }} + errorMessage={errorMessage} index={index} onAdd={() => { handleAddGroup?.(index + 1) @@ -48,6 +73,7 @@ export default function ArticleGroup({groups, editable, onChange}: Props) { /> ))} {groups.length == 0 && editable && - onChange?.([blocks])} index={0} blocks={[{type:'text',content:''}]}/>} + onChange?.([blocks])} index={0} + blocks={[{type: 'text', content: ''}]}/>}
} \ No newline at end of file diff --git a/src/components/article/item.tsx b/src/components/article/item.tsx index 963cfbf..a1f367d 100644 --- a/src/components/article/item.tsx +++ b/src/components/article/item.tsx @@ -1,10 +1,12 @@ 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 {getOssPolicy} from "@/service/api/common.ts"; import {showToast} from "@/components/message.ts"; -import {clsx} from "clsx"; +import {IconAddImage} from "@/components/icons"; type Props = { children?: React.ReactNode; @@ -14,11 +16,15 @@ type Props = { onChange?: (data: BlockContent) => void; isFirstBlock?: boolean; } +type ImageProps = { + onRemove?: () => void; + onlyUpload?: boolean; +} & Props; const MimeTypes = ['image/jpeg', 'image/png', 'image/jpg'] const Data: { uploadConfig?: TOSSPolicy } = {} -export function BlockImage({data, editable, onChange}: Props) { +export function BlockImage({data, editable, onChange, onlyUpload, onRemove}: ImageProps) { const [loading, setLoading] = useState(-1) // oss上传文件所需的数据 @@ -48,7 +54,7 @@ export function BlockImage({data, editable, onChange}: Props) { console.log('onChange', file); if (file.status == 'done') { 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') { setLoading(-1) showToast('上传图片失败,请重试', 'warning') @@ -58,7 +64,15 @@ export function BlockImage({data, editable, onChange}: Props) { } // return
- {editable ?
+ {editable ?
+ {!onlyUpload && 请确认删除此删除此图片?
} + onConfirm={onRemove} + okText="删除" + cancelText="取消" + > + + } = 0} percent={loading == 0 ? 'auto' : loading}> 更换图片
:
- +
+ +
上传图片
+
}
@@ -84,13 +101,14 @@ export function BlockImage({data, editable, onChange}: Props) { export function BlockText({data, editable, onChange, isFirstBlock}: Props) { return
-
+
{editable ?
{ 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"}/>
:

{data.content}

}
diff --git a/src/components/article/list.tsx b/src/components/article/list.tsx new file mode 100644 index 0000000..2a0bb6f --- /dev/null +++ b/src/components/article/list.tsx @@ -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 (
+ {props.blocks.map((it, index) => ( + it.type === 'image' ? handleUpdate(index, data)} + onRemove={() => handleRemove(index)} + /> : null + ))} + {props.editable && + } +
) +} + diff --git a/src/pages/news/edit.tsx b/src/pages/news/edit.tsx index 4baf032..924ff8b 100644 --- a/src/pages/news/edit.tsx +++ b/src/pages/news/edit.tsx @@ -14,12 +14,9 @@ export default function NewEdit() { const [editId, setEditId] = useState(-1) const [selectedRowKeys, setSelectedRowKeys] = useState([]) const [params, setParams] = useState({ - pagination: { - page: 1, - limit: 10 - } + pagination: {page: 1, limit: 10} }) - const {data} = useRequest(() => getList(params), {refreshDeps: [params]}) + const {data, refresh} = useRequest(() => getList(params), {refreshDeps: [params]}) const columns: TableColumnsType = [ { @@ -86,15 +83,20 @@ export default function NewEdit() { showSizeChanger={false} simple={true} rootClassName={'simple-pagination'} - onChange={(page) => setParams(prev=>({ + onChange={(page) => setParams(prev => ({ ...prev, pagination: {page, limit: 10} }))} /> - +
}
- setEditId(-1)}/> + { + setEditId(-1) + if (saved) refresh() + }}/> ) } \ No newline at end of file diff --git a/src/service/api/article.ts b/src/service/api/article.ts index 7fb948c..22d963a 100644 --- a/src/service/api/article.ts +++ b/src/service/api/article.ts @@ -21,9 +21,9 @@ export function getById(id: Id) { return post({url: '/article/detail/' + 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 }>({ - url: '/spider/article', + url: '/article/save', data: { title, content_group, diff --git a/src/service/api/video.ts b/src/service/api/video.ts index f8273a7..2069047 100644 --- a/src/service/api/video.ts +++ b/src/service/api/video.ts @@ -13,7 +13,7 @@ export function getList(data: { * @param content_group * @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 }>({ url: '/video/regenerate', data: {