Files
oxygen-ui/src/pages/Tools/Edit.tsx

534 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Draggable from 'react-draggable'
import Icon from '@ant-design/icons'
import useStyles from '@/assets/css/pages/tools/edit.style'
import {
DATABASE_NO_RECORD_FOUND,
DATABASE_SELECT_SUCCESS,
DATABASE_UPDATE_SUCCESS,
TOOL_HAS_BEEN_PUBLISHED,
TOOL_UNDER_REVIEW
} from '@/constants/common.constants'
import {
addExtraCssVariables,
generateThemeCssVariables,
message,
removeUselessAttributes
} from '@/util/common'
import { navigateToRepository } from '@/util/navigation'
import editorExtraLibs from '@/util/editorExtraLibs'
import { r_tool_category_get, r_tool_detail, r_tool_update } from '@/services/tool'
import { AppContext } from '@/App'
import { IFiles, IImportMap, ITsconfig } from '@/components/Playground/shared'
import {
base64ToFiles,
base64ToStr,
filesToBase64,
IMPORT_MAP_FILE_NAME,
TS_CONFIG_FILE_NAME
} from '@/components/Playground/files'
import Playground from '@/components/Playground'
import FitFullscreen from '@/components/common/FitFullscreen'
import FlexBox from '@/components/common/FlexBox'
import LoadingMask from '@/components/common/LoadingMask'
import Card from '@/components/common/Card'
const Edit = () => {
const { styles, theme } = useStyles()
const { isDarkMode } = useContext(AppContext)
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
currentLocation.pathname !== nextLocation.pathname && hasEdited
)
const navigate = useNavigate()
const { toolId } = useParams()
const [searchParams] = useSearchParams({
platform: import.meta.env.VITE_PLATFORM
})
const [form] = AntdForm.useForm<ToolUpdateParam>()
const formValues = AntdForm.useWatch([], form)
const [isLoading, setIsLoading] = useState(false)
const [toolData, setToolData] = useState<ToolVo>()
const [files, setFiles] = useState<IFiles>({})
const [selectedFileName, setSelectedFileName] = useState('')
const [importMapRaw, setImportMapRaw] = useState<string>('')
const [importMap, setImportMap] = useState<IImportMap>()
const [tsconfigRaw, setTsconfigRaw] = useState<string>('')
const [tsconfig, setTsconfig] = useState<ITsconfig>()
const [entryPoint, setEntryPoint] = useState('')
const [baseDist, setBaseDist] = useState('')
const [isShowDraggableMask, setIsShowDraggableMask] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
const [isSubmittable, setIsSubmittable] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [hasEdited, setHasEdited] = useState(false)
const [categoryData, setCategoryData] = useState<ToolCategoryVo[]>()
const [isLoadingCategory, setIsLoadingCategory] = useState(false)
useBeforeUnload(
useCallback(
(event) => {
if (hasEdited) {
event.preventDefault()
event.returnValue = ''
}
},
[hasEdited]
),
{ capture: true }
)
const handleOnChangeFileContent = (content: string, fileName: string, files: IFiles) => {
setHasEdited(true)
if (fileName === IMPORT_MAP_FILE_NAME) {
setImportMapRaw(content)
return
}
if (fileName === TS_CONFIG_FILE_NAME) {
setTsconfigRaw(content)
return
}
setFiles(files)
}
const handleOnSetting = () => {
setIsDrawerOpen(true)
form.setFieldValue('icon', toolData?.icon)
form.setFieldValue('name', toolData?.name)
form.setFieldValue('description', toolData?.description)
form.setFieldValue('keywords', toolData?.keywords)
form.setFieldValue(
'categories',
toolData?.categories.map((value) => value.id)
)
if (!categoryData || !categoryData.length) {
getCategory()
}
void form.validateFields()
}
const handleOnSave = () => {
if (isSubmitting) {
return
}
setIsSubmitting(true)
void message.loading({ content: '保存中', key: 'SAVING', duration: 0 })
void r_tool_update({
id: toolData!.id,
source: filesToBase64(files)
})
.then((res) => {
const response = res.data
switch (response.code) {
case DATABASE_UPDATE_SUCCESS:
void message.success('保存成功')
getTool()
break
case TOOL_UNDER_REVIEW:
message.error('保存失败:工具审核中').then(() => {
navigateToRepository(navigate)
})
break
case TOOL_HAS_BEEN_PUBLISHED:
message.error('保存失败:工具已发布').then(() => {
navigateToRepository(navigate)
})
break
default:
void message.error('保存失败,请稍后重试')
}
})
.finally(() => {
setIsSubmitting(false)
message.destroy('SAVING')
})
}
const handleOnDrawerClose = () => {
setIsDrawerOpen(false)
}
const handleOnIconBeforeUpload = (
file: Parameters<_GetProp<_UploadProps, 'beforeUpload'>>[0]
) => {
if (file.type !== 'image/svg+xml') {
void message.error('仅支持 svg 文件')
return false
}
if (file.size / 1024 / 1024 > 2) {
void message.error('文件大小不能大于2MiB')
}
const reader = new FileReader()
reader.addEventListener('load', () => {
form.setFieldValue('icon', reader.result!.toString().split(',')[1])
void form.validateFields(['icon'])
})
reader.readAsDataURL(file)
return false
}
const handleOnSubmit = () => {
if (isSubmitting) {
return
}
setIsSubmitting(true)
void r_tool_update({
...formValues,
id: toolData!.id
})
.then((res) => {
const response = res.data
switch (response.code) {
case DATABASE_UPDATE_SUCCESS:
setIsDrawerOpen(false)
void message.success('保存成功')
getTool()
break
case TOOL_UNDER_REVIEW:
message.error('保存失败:工具审核中').then(() => {
navigateToRepository(navigate)
})
break
case TOOL_HAS_BEEN_PUBLISHED:
message.error('保存失败:工具已发布').then(() => {
navigateToRepository(navigate)
})
break
default:
void message.error('保存失败,请稍后重试')
}
})
.finally(() => {
setIsSubmitting(false)
})
}
const getCategory = () => {
if (isLoadingCategory) {
return
}
setIsLoadingCategory(true)
void r_tool_category_get()
.then((res) => {
const response = res.data
switch (response.code) {
case DATABASE_SELECT_SUCCESS:
setCategoryData(response.data!)
break
default:
void message.error('获取类别列表失败,请稍后重试')
}
})
.finally(() => {
setIsLoadingCategory(false)
})
}
const getTool = () => {
if (isLoading) {
return
}
setIsLoading(true)
void message.loading({ content: '加载中……', key: 'LOADING', duration: 0 })
void r_tool_detail('!', toolId!, 'latest', searchParams.get('platform') as Platform)
.then((res) => {
const response = res.data
switch (response.code) {
case DATABASE_SELECT_SUCCESS:
switch (response.data!.review) {
case 'NONE':
case 'REJECT':
setToolData(response.data!)
setHasEdited(false)
break
case 'PROCESSING':
message.warning('工具审核中,请勿修改').then(() => {
navigateToRepository(navigate)
})
break
default:
message.warning('请先创建新版本后编辑工具').then(() => {
navigateToRepository(navigate)
})
}
break
case DATABASE_NO_RECORD_FOUND:
message.error('未找到指定工具').then(() => {
navigateToRepository(navigate)
})
break
default:
void message.error('获取工具信息失败,请稍后重试')
}
})
.finally(() => {
setIsLoading(false)
message.destroy('LOADING')
})
}
useEffect(() => {
try {
setImportMap(JSON.parse(importMapRaw) as IImportMap)
} catch (e) {
/* empty */
}
}, [importMapRaw])
useEffect(() => {
setTimeout(() => {
try {
setTsconfig(JSON.parse(tsconfigRaw) as ITsconfig)
} catch (e) {
/* empty */
}
}, 1000)
}, [tsconfigRaw])
useEffect(() => {
if (!toolData) {
return
}
try {
setBaseDist(base64ToStr(toolData.base.dist.data!))
const files = base64ToFiles(toolData.source.data!)
setFiles(files)
setImportMapRaw(files[IMPORT_MAP_FILE_NAME].value)
setTsconfigRaw(files[TS_CONFIG_FILE_NAME].value)
setEntryPoint(toolData.entryPoint)
setTimeout(() => {
setSelectedFileName(toolData.entryPoint)
}, 500)
} catch (e) {
console.error(e)
void message.error('载入工具失败')
}
}, [toolData])
useEffect(() => {
form.validateFields({ validateOnly: true }).then(
() => {
setIsSubmittable(true)
},
() => {
setIsSubmittable(false)
}
)
}, [formValues])
useEffect(() => {
if (!['WEB', 'DESKTOP', 'ANDROID'].includes(searchParams.get('platform')!)) {
navigateToRepository(navigate)
return
}
getTool()
}, [toolId, searchParams])
const drawerToolbar = (
<AntdSpace>
<AntdTooltip title={'刷新类别列表'}>
<AntdButton onClick={getCategory} disabled={isSubmitting}>
<Icon component={IconOxygenRefresh} />
</AntdButton>
</AntdTooltip>
<AntdButton onClick={handleOnDrawerClose} disabled={isSubmitting}>
</AntdButton>
<AntdButton
type={'primary'}
disabled={!isSubmittable}
loading={isSubmitting}
onClick={handleOnSubmit}
>
</AntdButton>
</AntdSpace>
)
const editForm = (
<AntdForm form={form} disabled={isSubmitting} layout={'vertical'}>
<AntdForm.Item
label={'图标'}
name={'icon'}
rules={[
({ getFieldValue }) => ({
validator() {
if (!getFieldValue('icon')) {
return Promise.reject(new Error('请选择图标'))
}
return Promise.resolve()
}
})
]}
getValueFromEvent={() => {}}
>
<AntdUpload
listType={'picture-card'}
showUploadList={false}
beforeUpload={handleOnIconBeforeUpload}
accept={'image/svg+xml'}
>
{formValues?.icon ? (
<img
src={`data:image/svg+xml;base64,${formValues.icon}`}
alt={'icon'}
style={{ width: '100%' }}
/>
) : (
<Icon component={IconOxygenPlus} />
)}
</AntdUpload>
</AntdForm.Item>
<AntdForm.Item name={'icon'} hidden>
<AntdInput />
</AntdForm.Item>
<AntdForm.Item
label={'名称'}
name={'name'}
rules={[{ required: true, whitespace: true }]}
>
<AntdInput maxLength={20} showCount placeholder={'请输入名称'} />
</AntdForm.Item>
<AntdForm.Item label={'简介'} name={'description'}>
<AntdInput.TextArea
autoSize={{ minRows: 6, maxRows: 6 }}
maxLength={200}
showCount
placeholder={'请输入简介'}
/>
</AntdForm.Item>
<AntdForm.Item
label={'关键字'}
tooltip={'工具搜索每个不超过10个字符'}
name={'keywords'}
rules={[{ required: true }]}
>
<AntdSelect mode={'tags'} maxCount={20} placeholder={'请输入关键字'} />
</AntdForm.Item>
<AntdForm.Item
label={'类别'}
tooltip={'工具分类'}
name={'categories'}
rules={[{ required: true }]}
>
<AntdSelect
mode={'multiple'}
options={categoryData?.map((value) => ({
value: value.id,
label: value.name
}))}
loading={isLoadingCategory}
disabled={isLoadingCategory}
placeholder={'请选择类别'}
/>
</AntdForm.Item>
</AntdForm>
)
return (
<>
<FitFullscreen className={styles.root}>
<Card className={styles.rootBox}>
<FlexBox direction={'horizontal'} className={styles.content}>
<LoadingMask hidden={!isLoading}>
<Playground.CodeEditor
isDarkMode={isDarkMode}
tsconfig={tsconfig}
files={{
...files,
[IMPORT_MAP_FILE_NAME]: {
name: IMPORT_MAP_FILE_NAME,
language: 'json',
value: importMapRaw
},
[TS_CONFIG_FILE_NAME]: {
name: TS_CONFIG_FILE_NAME,
language: 'json',
value: tsconfigRaw
}
}}
notRemovable={[entryPoint]}
selectedFileName={selectedFileName}
onAddFile={(_, files) => setFiles(files)}
onRemoveFile={(_, files) => setFiles(files)}
onRenameFile={(_, __, files) => setFiles(files)}
onChangeFileContent={handleOnChangeFileContent}
onSelectedFileChange={setSelectedFileName}
extraLibs={editorExtraLibs}
onEditorDidMount={(_, monaco) => addExtraCssVariables(monaco)}
/>
<Playground.Output
isDarkMode={isDarkMode}
files={files}
selectedFileName={selectedFileName}
importMap={importMap!}
entryPoint={entryPoint}
postExpansionCode={baseDist}
mobileMode={toolData?.platform === 'ANDROID'}
globalJsVariables={{
OxygenTheme: { ...removeUselessAttributes(theme), isDarkMode }
}}
globalCssVariables={generateThemeCssVariables(theme).styles}
/>
</LoadingMask>
{isShowDraggableMask && <div className={styles.draggableMask} />}
</FlexBox>
</Card>
<Draggable
onStart={() => setIsShowDraggableMask(true)}
onStop={() => setIsShowDraggableMask(false)}
bounds={'#root'}
>
<div className={styles.draggableContent}>
{hasEdited ? (
<AntdFloatButton
type={'primary'}
icon={<Icon component={IconOxygenSave} />}
onClick={handleOnSave}
/>
) : (
<AntdFloatButton
icon={<Icon component={IconOxygenSetting} />}
onClick={handleOnSetting}
/>
)}
</div>
</Draggable>
</FitFullscreen>
<AntdDrawer
title={'配置工具'}
onClose={handleOnDrawerClose}
open={isDrawerOpen}
closable={!isSubmitting}
maskClosable={true}
extra={drawerToolbar}
>
{editForm}
</AntdDrawer>
<AntdModal
open={blocker.state === 'blocked'}
title={'未保存'}
onOk={() => blocker.proceed?.()}
onCancel={() => blocker.reset?.()}
footer={(_, { OkBtn, CancelBtn }) => (
<>
<OkBtn />
<CancelBtn />
</>
)}
>
</AntdModal>
</>
)
}
export default Edit