Commit 195829ff authored by cwuyiqing's avatar cwuyiqing
Browse files

feat: 支持通过 CSV, JSON 导入数据

parent abbd10d3
Showing with 1228 additions and 57 deletions
+1228 -57
......@@ -99,6 +99,11 @@ const config: IConfig = {
access: 'canContent',
wrappers: ['../components/SecurityWrapper/index'],
routes: [
{
exact: true,
path: '/:projectId/content/migrate',
component: './project/migrate',
},
{
exact: true,
path: '/:projectId/content/:schemaId',
......
import React, { Suspense } from 'react'
import { Spin, Form, Input, Switch, Button, Col, Select, InputNumber } from 'antd'
import { Rule } from 'antd/es/form'
import { IConnectEditor, IDatePicker, IUploader } from '@/components/Fields'
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons'
const MarkdownEditor = React.lazy(() => import('@/components/Fields/Markdown'))
const RichTextEditor = React.lazy(() => import('@/components/Fields/RichText'))
const { TextArea } = Input
const { Option } = Select
const LazyMarkdownEditor: React.FC = (props: any) => (
<Suspense fallback={<Spin />}>
<MarkdownEditor {...props} />
</Suspense>
)
const LazyRichTextEditor: React.FC = (props: any) => (
<Suspense fallback={<Spin />}>
<RichTextEditor {...props} />
</Suspense>
)
/**
* 根据类型获取验证规则
*/
function getValidateRule(type: string) {
let rule: Rule | null
switch (type) {
case 'Url':
rule = { type: 'url', message: '请输入正确的网址' }
break
case 'Email':
rule = {
type: 'email',
message: '请输入正确的邮箱',
}
break
case 'Number':
rule = { type: 'number', message: '请输入正确的数字' }
break
case 'Tel':
rule = {
pattern: /^\d+$/,
message: '请输入正确的电话号码',
}
break
default:
rule = null
}
return rule
}
const getRules = (field: SchemaFieldV2): Rule[] => {
const { isRequired, displayName, min, max, type } = field
const rules: Rule[] = []
if (isRequired) {
rules.push({ required: isRequired, message: `${displayName} 字段是必须要的` })
}
if (min) {
const validType = type === 'String' || type === 'MultiLineString' ? 'string' : 'number'
rules.push({
min,
type: validType,
message: validType === 'string' ? `不能小于最小长度 ${min}` : `不能小于最小值 ${min}`,
})
}
if (max) {
const validType = type === 'String' || type === 'MultiLineString' ? 'string' : 'number'
rules.push({
max,
type: validType,
message: validType === 'string' ? `不能大于最大长度 ${max}` : `不能大于最大值 ${max}`,
})
}
const rule = getValidateRule(field.type)
rule && rules.push(rule)
return rules
}
/**
* 字段编辑器
*/
export function getFieldEditor(field: SchemaFieldV2, key: number) {
const { name, type, min, max, enumElements } = field
let FieldEditor: React.ReactNode
switch (type) {
case 'String':
FieldEditor = <Input type="text" />
break
case 'MultiLineString':
FieldEditor = <TextArea />
break
case 'Boolean':
FieldEditor = <Switch checkedChildren="True" unCheckedChildren="False" />
break
case 'Number':
FieldEditor = <InputNumber style={{ width: '100%' }} min={min} max={max} />
break
case 'Url':
FieldEditor = <Input />
break
case 'Email':
FieldEditor = <Input />
break
case 'Tel':
FieldEditor = <Input style={{ width: '100%' }} />
break
case 'Date':
FieldEditor = <IDatePicker type="Date" />
break
case 'DateTime':
FieldEditor = <IDatePicker type="DateTime" />
break
case 'Image':
FieldEditor = <IUploader type="image" />
break
case 'File':
FieldEditor = <IUploader type="file" />
break
case 'Enum':
FieldEditor = (
<Select>
{enumElements?.length ? (
enumElements?.map((ele, index) => (
<Option value={ele.value} key={index}>
{ele.label}
</Option>
))
) : (
<Option value="" disabled>
</Option>
)}
</Select>
)
break
case 'Array':
FieldEditor = (
<Form.List name={name}>
{(fields, { add, remove }) => {
return (
<div>
{fields?.map((field, index) => {
return (
<Form.Item key={index}>
<Form.Item {...field} noStyle validateTrigger={['onChange', 'onBlur']}>
<Input style={{ width: '60%' }} />
</Form.Item>
<MinusCircleOutlined
className="dynamic-delete-button"
style={{ margin: '0 8px' }}
onClick={() => {
remove(field.name)
}}
/>
</Form.Item>
)
})}
<Form.Item>
<Button
type="dashed"
onClick={() => {
add()
}}
style={{ width: '60%' }}
>
<PlusOutlined /> 添加字段
</Button>
</Form.Item>
</div>
)
}}
</Form.List>
)
break
case 'Markdown':
FieldEditor = <LazyMarkdownEditor key={key} />
break
case 'RichText':
FieldEditor = <LazyRichTextEditor key={String(key)} />
break
case 'Connect':
FieldEditor = <IConnectEditor field={field} />
break
default:
FieldEditor = <Input />
}
return FieldEditor
}
/**
* 字段编辑表单
*/
export function getFieldFormItem(field: SchemaFieldV2, key: number) {
const rules = getRules(field)
const { name, type, description, displayName } = field
let FieldEditor: any = getFieldEditor(field, key)
let FormItem
switch (type) {
case 'Boolean':
FormItem = (
<Form.Item
key={key}
name={name}
rules={rules}
label={displayName}
extra={description}
valuePropName="checked"
>
{FieldEditor}
</Form.Item>
)
break
default:
FormItem = (
<Form.Item key={key} name={name} rules={rules} label={displayName} extra={description}>
{FieldEditor}
</Form.Item>
)
}
// 弹性布局
if (type === 'Markdown' || type === 'RichText') {
return (
<Col xs={24} sm={24} md={24} lg={24} xl={24} key={key}>
{FormItem}
</Col>
)
}
return (
<Col xs={24} sm={24} md={12} lg={12} xl={12} xxl={12} key={key}>
{FormItem}
</Col>
)
}
import React from 'react'
import { Space, Tag, Typography } from 'antd'
import { IConnectRender, IFileRender, ILazyImage } from '@/components/Fields'
import { calculateFieldWidth } from './utils'
/**
* 根据类型获取展示字段组件
*/
export function getFieldRender(field: SchemaFieldV2) {
const { name, type } = field
const width = calculateFieldWidth(field)
switch (type) {
case 'String':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => (
<Typography.Text ellipsis style={{ width }}>
{text}
</Typography.Text>
)
case 'Text':
case 'MultiLineString':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => (
<Typography.Text ellipsis style={{ width }}>
{text}
</Typography.Text>
)
case 'Boolean':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => {
return <Typography.Text>{record[name] ? 'True' : 'False'}</Typography.Text>
}
case 'Number':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => {
const num = typeof record[name] === 'undefined' ? '-' : record[name]
return <Typography.Text>{num} </Typography.Text>
}
case 'Url':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => (
<Typography.Link href={record[name]} target="_blank">
{text}
</Typography.Link>
)
case 'Email':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => <Typography.Text>{text}</Typography.Text>
case 'Tel':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => <Typography.Text>{text}</Typography.Text>
case 'Date':
return undefined
case 'DateTime':
return undefined
case 'Image':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => {
const data = record[name]
return <ILazyImage src={data} />
}
case 'File':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => <IFileRender src={record[name]} />
case 'Array':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => {
if (!record[name]) {
return text
}
return (
<Space direction="vertical">
{record[name]?.map((val: string, index: number) => (
<Tag key={index}>{val}</Tag>
))}
</Space>
)
}
case 'Markdown':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => (
<Typography.Text ellipsis style={{ width }}>
{text}
</Typography.Text>
)
case 'RichText':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => (
<Typography.Text ellipsis style={{ width }}>
{text}
</Typography.Text>
)
case 'Connect':
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => (
<IConnectRender value={record[name]} field={field} />
)
default:
return (
text: React.ReactNode,
record: any,
index: number,
action: any
): React.ReactNode | React.ReactNode[] => text
}
}
import React, { useEffect, useState } from 'react'
import { Button, message, Space, Spin, Empty } from 'antd'
import { copyToClipboard, downloadFile, getTempFileURL } from '@/utils'
import { CopyTwoTone } from '@ant-design/icons'
import emptyImg from '@/assets/empty.svg'
/**
* 图片懒加载
*/
export const ILazyImage: React.FC<{ src: string }> = ({ src }) => {
if (!src) {
return <Empty image={emptyImg} imageStyle={{ height: '60px' }} description="未设定图片" />
}
if (!/^cloud:\/\/\S+/.test(src)) {
return <img style={{ maxHeight: '120px', maxWidth: '200px' }} src={src} />
}
const [imgUrl, setImgUrl] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
getTempFileURL(src)
.then((url) => {
setLoading(false)
setImgUrl(url)
})
.catch((e) => {
console.log(e)
console.log(e.message)
message.error(`获取图片链接失败 ${e.message}`)
setLoading(false)
})
}, [])
return loading ? (
<Spin />
) : (
<Space direction="vertical">
<img style={{ maxHeight: '120px', maxWidth: '200px' }} src={imgUrl} />
{imgUrl && (
<Space>
<Button
size="small"
onClick={() => {
downloadFile(src)
}}
>
下载图片
</Button>
<Button
size="small"
onClick={() => {
getTempFileURL(src)
.then((url) => {
copyToClipboard(url)
.then(() => {
message.success('复制到剪切板成功')
})
.catch(() => {
message.error('复制到剪切板成功')
})
})
.catch((e) => {
console.log(e)
console.log(e.message)
message.error(`获取图片链接失败 ${e.message}`)
})
}}
>
访问链接
<CopyTwoTone />
</Button>
</Space>
)}
</Space>
)
}
// 格式化搜索参数
export const formatSearchData = (schema: SchemaV2, params: Record<string, any>) => {
const { fields } = schema
return Object.keys(params).reduce((ret, key) => {
const field = fields.find((_) => _.name === key)
if (!field) {
return {
...ret,
[key]: params[key],
}
}
let value = params[key]
// 格式化字符串
if (field.type === 'Number') {
value = Number(value)
}
if (field.type === 'Boolean') {
value = Boolean(value)
}
return {
...ret,
[key]: value,
}
}, {})
}
export const calculateFieldWidth = (field: SchemaFieldV2) => {
const TypeWidthMap = {
String: 150,
MultiLineString: 150,
Number: 150,
Boolean: 100,
DateTime: 150,
File: 200,
Image: 200,
RichText: 150,
Markdown: 150,
Connect: 250,
}
const { displayName, type } = field
// 计算列宽度
const nameWidth = displayName.length * 25
let width
if (TypeWidthMap[type]) {
width = nameWidth > TypeWidthMap[type] ? nameWidth : TypeWidthMap[type]
} else {
width = nameWidth > 150 ? nameWidth : 150
}
return width
}
......@@ -26,7 +26,6 @@ import {
Popover,
Empty,
} from 'antd'
import moment from 'moment'
import { useConcent } from 'concent'
import { history, useRequest, useAccess } from 'umi'
import AvatarDropdown from '@/components/AvatarDropdown'
......@@ -34,6 +33,7 @@ import { getProjects, createProject } from '@/services/project'
import logo from '@/assets/logo.svg'
import { getCmsNotices } from '@/services/notice'
import './index.less'
import { getFullDate } from '@/utils'
setTwoToneColor('#0052d9')
......@@ -232,7 +232,7 @@ export const NoticeRender: React.FC = () => {
<Timeline mode="left">
{notices.map((notice: any) => (
<Timeline.Item key={notice._id} color="blue">
<h3>{moment(notice.noticeTime).format('YYYY-MM-DD')}</h3>
<h3>{getFullDate(notice.noticeTime)}</h3>
<h3>{notice.noticeTitle}</h3>
<p>{notice.noticeContent}</p>
</Timeline.Item>
......
import React from 'react'
import { useParams, useRequest, history } from 'umi'
import { useConcent } from 'concent'
import { Form, message, Space, Button, Row, Col, Input } from 'antd'
import { createContent, updateContent } from '@/services/content'
import { getFieldFormItem } from '@/components/Fields'
import ProCard from '@ant-design/pro-card'
import { PageContainer } from '@ant-design/pro-layout'
import { LeftCircleTwoTone } from '@ant-design/icons'
const ContentEditor: React.FC = () => {
const { schemaId, projectId } = useParams<any>()
const ctx = useConcent('content')
const { selectedContent, contentAction } = ctx.state
const {
state: { schemas },
} = ctx
const schema: SchemaV2 = schemas?.find((item: SchemaV2) => item._id === schemaId)
// 表单初始值
const initialValues = getInitialValues(contentAction, schema, selectedContent)
// 创建/更新内容
const { run, loading } = useRequest(
async (payload: any) => {
if (contentAction === 'create') {
await createContent(projectId, schema?.collectionName, payload)
}
if (contentAction === 'edit') {
await updateContent(projectId, schema?.collectionName, selectedContent._id, payload)
}
},
{
manual: true,
onError: () => {
message.error(`${contentAction === 'create' ? '新建' : '更新'}内容失败`)
},
onSuccess: () => {
message.success(`${contentAction === 'create' ? '新建' : '更新'}内容成功`)
// 返回
history.goBack()
},
}
)
return (
<PageContainer
title={`${contentAction === 'create' ? '创建' : '更新'}${schema?.displayName}】内容`}
>
<div style={{ cursor: 'pointer' }} onClick={() => history.goBack()}>
<Space align="center" style={{ marginBottom: '10px' }}>
<LeftCircleTwoTone style={{ fontSize: '20px' }} />
<h3 style={{ marginBottom: '0.25rem' }}>返回</h3>
</Space>
</div>
<ProCard>
<Form
name="basic"
layout="vertical"
initialValues={initialValues}
onFinish={(v = {}) => run(v)}
>
<Row gutter={[24, 24]}>
{contentAction === 'edit' && (
<Col xs={24} sm={24} md={12} lg={12} xl={12} xxl={12}>
<Form.Item label="文档 Id" name="_id">
<Input type="text" disabled />
</Form.Item>
</Col>
)}
{schema?.fields?.map((filed, index) => getFieldFormItem(filed, index))}
</Row>
<Form.Item>
<Row>
<Col flex="1 1 auto" style={{ textAlign: 'right' }}>
<Space size="large">
<Button
onClick={() => {
history.goBack()
}}
>
取消
</Button>
<Button type="primary" htmlType="submit" loading={loading}>
{contentAction === 'create' ? '创建' : '更新'}
</Button>
</Space>
</Col>
</Row>
</Form.Item>
</Form>
</ProCard>
</PageContainer>
)
}
const getInitialValues = (action: string, schema: SchemaV2, selectedContent: any) => {
const initialValues =
action === 'create'
? schema?.fields?.reduce((prev, field) => {
let { type, defaultValue } = field
// 布尔值默认为 false
if (type === 'Boolean' && typeof defaultValue !== 'boolean') {
defaultValue = false
}
return {
...prev,
[field.name]: defaultValue,
}
}, {})
: selectedContent
if (action === 'edit') {
schema?.fields?.forEach((field) => {
let { type, name } = field
// 布尔值默认为 false
if (type === 'Boolean' && typeof selectedContent[name] !== 'boolean') {
selectedContent[name] = false
}
})
}
return initialValues
}
export default ContentEditor
import React, { useRef, useCallback, useState, useMemo } from 'react'
import React, { useRef, useCallback, useState, useMemo, useEffect } from 'react'
import { useConcent } from 'concent'
import { useParams, history } from 'umi'
import ProTable from '@ant-design/pro-table'
......@@ -14,17 +14,27 @@ import {
Upload,
Progress,
Alert,
Typography,
Select,
} from 'antd'
import { PlusOutlined, DeleteOutlined, FilterOutlined, InboxOutlined } from '@ant-design/icons'
import { getContents, deleteContent, batchDeleteContent } from '@/services/content'
import {
getContents,
deleteContent,
batchDeleteContent,
createMigrateJobs,
} from '@/services/content'
import { random, uploadFile } from '@/utils'
import { getTableColumns } from './columns'
import { ContentTableSearch } from './SearchForm'
import './index.less'
import { getFullDate } from '@/utils/date'
// 不能支持搜索的类型
const negativeTypes = ['File', 'Image']
const { Dragger } = Upload
const { Title } = Typography
const { Option } = Select
/**
* 内容展示表格
......@@ -226,7 +236,7 @@ export const ContentTable: React.FC<{
>
新建
</Button>,
<DataImport key="import" />,
<DataImport key="import" collectionName={currentSchema.collectionName} />,
]}
/>
</>
......@@ -236,13 +246,19 @@ export const ContentTable: React.FC<{
/**
* 导入数据
*/
export const DataImport: React.FC<{}> = () => {
export const DataImport: React.FC<{ collectionName: string }> = ({ collectionName }) => {
const { projectId } = useParams<any>()
const [visible, setVisible] = useState(false)
const [dataType, setDataType] = useState<string>('')
const [fileList, setFileList] = useState<any[]>()
const [percent, setPercent] = useState(0)
const [uploading, setUploading] = useState(false)
const [dataType, setDataType] = useState<string>('')
const [conflictMode, setConflictMode] = useState('insert')
useEffect(() => {
if (!visible) {
setPercent(0)
}
}, [visible])
return (
<>
......@@ -250,12 +266,17 @@ export const DataImport: React.FC<{}> = () => {
overlay={
<Menu
onClick={({ key }) => {
if (key === 'record') {
history.push(`/${projectId}/content/migrate`)
return
}
setDataType(key as string)
setVisible(true)
}}
>
<Menu.Item key="csv">通过 CSV 导入</Menu.Item>
<Menu.Item key="json">通过 JSON 导入</Menu.Item>
<Menu.Item key="record">查看导入记录</Menu.Item>
</Menu>
}
key="search"
......@@ -263,53 +284,68 @@ export const DataImport: React.FC<{}> = () => {
<Button type="primary">导入数据</Button>
</Dropdown>
<Modal
destroyOnClose
width={600}
title="导入数据"
footer={null}
closable={true}
visible={visible}
onCancel={() => setVisible(false)}
>
<Alert
message="JSON 数据不是数组,而是类似 JSON Lines,即各个记录对象之间使用 \n 分隔,而非逗号"
style={{ marginBottom: '10px' }}
/>
<Alert message="CSV 格式的数据默认以第一行作为导入后的所有键名,余下的每一行则是与首行键名一一对应的键值记录" />
<Title level={4}>注意事项</Title>
{dataType === 'json' && (
<Alert
message="JSON 数据不是数组,而是类似 JSON Lines,即各个记录对象之间使用 \n 分隔,而非逗号"
style={{ marginBottom: '10px' }}
/>
)}
{dataType === 'csv' && (
<Alert message="CSV 格式的数据默认以第一行作为导入后的所有键名,余下的每一行则是与首行键名一一对应的键值记录" />
)}
<br />
<Title level={4}>冲突处理模式</Title>
<Select
defaultValue="insert"
onChange={setConflictMode}
style={{ width: '100%', marginBottom: '10px' }}
>
<Option value="insert">Insert(会在导入时总是插入新记录,出现 _id 冲突时会报错)</Option>
<Option value="upsert">
Upsert(会判断有无该条记录,如果有则更新记录,否则就插入一条新记录)
</Option>
</Select>
<Dragger
accept=".csv,.json"
fileList={fileList}
listType="picture"
beforeUpload={(file) => {
setUploading(true)
setPercent(0)
const fileName = `${random(32)}-${file.name}`
// 文件路径
const filePath = `data-import/${random(32)}-${file.name}`
// 上传文件
uploadFile(
file,
(percent) => {
setPercent(percent)
},
fileName
).then((fileUrl) => {
setFileList([
{
uid: fileUrl,
name: file.name,
status: 'done',
},
])
setVisible(false)
// TODO: 创建导入任务
console.log(fileName)
message.success('上传文件成功,数据导入中')
})
filePath
)
.then(() => createMigrateJobs(projectId, collectionName, filePath, conflictMode))
.then(() => {
setVisible(false)
message.success('上传文件成功,数据导入中')
})
.catch((e) => {
message.error(`导入文件失败:${e.message}`)
setVisible(false)
})
return false
}}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">点击或拖拽文件上传</p>
<p className="ant-upload-text">点击或拖拽上传文件,开始导入数据</p>
</Dragger>
{uploading && <Progress style={{ paddingTop: '10px' }} percent={percent} />}
</Modal>
......
import React from 'react'
import { Form, Space, Button, Row, Col, Input, Switch, InputNumber, Select } from 'antd'
import { DeleteTwoTone } from '@ant-design/icons'
import { IDatePicker, IConnectEditor } from '@/components/Fields'
import { calculateFieldWidth } from './utils'
const { Option } = Select
export const ContentTableSearch: React.FC<{
schema: SchemaV2
searchFields: SchemaFieldV2[]
searchValues: any
onSearch: (v: Record<string, any>) => void
setSearchFields: (fields: SchemaFieldV2[]) => void
}> = ({ schema, onSearch, searchFields, searchValues = {}, setSearchFields }) => {
// 删除字段
const deleteField = (field: SchemaFieldV2) => {
const index = searchFields.findIndex((_) => _.id === field.id)
const fields = searchFields.slice(0)
fields.splice(index, 1)
setSearchFields(fields)
}
return (
<div>
{searchFields.length ? (
<Form
name="basic"
layout="inline"
initialValues={searchValues}
onFinish={(v: any) => onSearch(v)}
style={{ marginTop: '15px' }}
>
<Row>
{searchFields.map((field, index) => (
<Space key={index} align="center" style={{ marginRight: '15px' }}>
{getSearchFieldItem(field, index)}
<DeleteTwoTone onClick={() => deleteField(field)} style={{ marginLeft: '-15px' }} />
</Space>
))}
</Row>
<Row>
<Col flex="1 1 auto" style={{ textAlign: 'right' }}>
<Form.Item>
<Space>
<Button
type="primary"
onClick={() => {
setSearchFields([])
onSearch({})
}}
>
重置
</Button>
<Button type="primary" htmlType="submit">
搜索
</Button>
</Space>
</Form.Item>
</Col>
</Row>
</Form>
) : null}
</div>
)
}
/**
* 字段编辑
*/
export function getSearchFieldItem(field: SchemaFieldV2, key: number) {
const { name, type, min, max, displayName, enumElements } = field
const width = calculateFieldWidth(field)
let FormItem
switch (type) {
case 'String':
case 'Url':
case 'Email':
case 'Tel':
case 'Markdown':
case 'RichText':
case 'MultiLineString':
FormItem = (
<Form.Item key={key} name={name} label={displayName}>
<Input type="text" style={{ width }} />
</Form.Item>
)
break
case 'Boolean':
FormItem = (
<Form.Item key={key} name={name} label={displayName} valuePropName="checked">
<Switch checkedChildren="True" unCheckedChildren="False" />
</Form.Item>
)
break
case 'Number':
FormItem = (
<Form.Item key={key} name={name} label={displayName}>
<InputNumber min={min} max={max} style={{ width }} />
</Form.Item>
)
break
case 'Date':
FormItem = (
<Form.Item key={key} name={name} label={displayName}>
<IDatePicker type="Date" />
</Form.Item>
)
break
case 'DateTime':
FormItem = (
<Form.Item key={key} name={name} label={displayName}>
<IDatePicker type="DateTime" />
</Form.Item>
)
break
case 'Enum':
FormItem = (
<Form.Item key={key} name={name} label={displayName}>
<Select mode="multiple" style={{ width }}>
{enumElements?.length ? (
enumElements?.map((ele, index) => (
<Option value={ele.value} key={index}>
{ele.label}
</Option>
))
) : (
<Option value="" disabled>
</Option>
)}
</Select>
</Form.Item>
)
break
case 'Connect':
FormItem = (
<Form.Item key={key} name={name} label={displayName}>
<IConnectEditor field={field} />
</Form.Item>
)
break
case 'Array':
FormItem = (
<Form.Item key={key} name={name} label={displayName}>
<Input placeholder="目前只支持单个值搜索" style={{ width }} />
</Form.Item>
)
break
default:
FormItem = (
<Form.Item key={key} name={name} label={displayName}>
<Input style={{ width }} />
</Form.Item>
)
}
return FormItem
}
import { useParams } from 'umi'
import React, { useRef } from 'react'
import { PageContainer } from '@ant-design/pro-layout'
import ProTable, { ProColumns } from '@ant-design/pro-table'
import { getMigrateJobs } from '@/services/content'
const StatusMap = {
waiting: '等待中',
reading: '',
writing: '',
migrating: '转移中',
success: '成功',
fail: '失败',
}
interface MigrateJobDto {
// 项目 Id
projectId: string
// 任务 Id
jobId: number
// 导入文件路径
filePath: string
// 导入冲突处理模式
conflictMode: 'upsert' | 'insert'
createTime: number
collectionName: string
// 任务状态
// waiting:等待中,reading:读,writing:写,migrating:转移中,success:成功,fail:失败
status: string
}
const MigrateJobColumns: ProColumns<MigrateJobDto>[] = [
{
title: 'JobId',
dataIndex: 'jobId',
},
{
title: '处理冲突模式',
dataIndex: 'conflictMode',
},
{
title: '数据集合',
dataIndex: 'collectionName',
},
{
width: '200px',
title: '创建时间',
dataIndex: 'createTime',
valueType: 'dateTime',
},
{
title: '状态',
dataIndex: 'collections',
render: (_, row) => StatusMap[row.status],
},
]
const columns: ProColumns<MigrateJobDto>[] = MigrateJobColumns.map((item) => ({
...item,
align: 'center',
}))
export default (): React.ReactNode => {
const { projectId } = useParams<any>()
const tableRef = useRef<{
reload: (resetPageIndex?: boolean) => void
reloadAndRest: () => void
fetchMore: () => void
reset: () => void
clearSelected: () => void
}>()
// 获取 jobs
const tableRequest = async (
params: { pageSize: number; current: number; [key: string]: any },
sort: {
[key: string]: 'ascend' | 'descend' | null
},
filter: {
[key: string]: React.ReactText[]
}
) => {
const { current, pageSize } = params
try {
const { data = [], total } = await getMigrateJobs(projectId, current, pageSize)
return {
data,
total,
success: true,
}
} catch (error) {
console.log(error)
return {
data: [],
total: 0,
success: true,
}
}
}
return (
<PageContainer title="数据迁移" className="page-container">
<ProTable
rowKey="_id"
search={false}
defaultData={[]}
actionRef={tableRef}
dateFormatter="string"
scroll={{ x: 1200 }}
request={tableRequest}
pagination={{
showSizeChanger: true,
}}
columns={columns}
/>
</PageContainer>
)
}
......@@ -105,3 +105,30 @@ export async function updateContent(
},
})
}
export async function getMigrateJobs(projectId: string, page = 1, pageSize = 10) {
return tcbRequest(`/projects/${projectId}/migrate`, {
method: 'GET',
params: {
page,
pageSize,
},
})
}
export async function createMigrateJobs(
projectId: string,
collectionName: string,
filePath: string,
conflictMode: string
) {
return tcbRequest(`/projects/${projectId}/migrate`, {
method: 'POST',
data: {
filePath,
projectId,
conflictMode,
collectionName,
},
})
}
import moment from 'moment'
import { request, history } from 'umi'
import { message, notification } from 'antd'
import { RequestOptionsInit } from 'umi-request'
import { codeMessage } from '@/constants'
import { isDevEnv, random } from './tool'
import defaultSettings from '../../config/defaultSettings'
import { getFullDate } from './date'
let app: any
let auth: any
......@@ -105,17 +105,17 @@ export async function tcbRequest<T = any>(
export async function uploadFile(
file: File,
onProgress: (v: number) => void,
fileName?: string
filePath?: string
): Promise<string> {
const app = await getCloudBaseApp()
const day = moment().format('YYYY-MM-DD')
const day = getFullDate()
// 文件名
const uploadFileName = fileName || `${random(32)}-${file.name}`
const uploadFilePath = filePath || `upload/${day}/${random(32)}-${file.name}`
const result = await app.uploadFile({
filePath: file,
cloudPath: `cloudbase-cms/upload/${day}/${uploadFileName}`,
cloudPath: `cloudbase-cms/${uploadFilePath}`,
onUploadProgress: (progressEvent: ProgressEvent) => {
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total)
onProgress(percentCompleted)
......
import moment from 'moment'
import 'moment/locale/zh-cn'
export const getFullDate = (v = Date.now()) => moment(v).format('YYYY-MM-DD')
export const getDateValue = (v?: string | number) => moment(v).valueOf()
export * from './tool'
export * from './copy'
export * from './cloudbase'
// 驼峰转换下划线
function humpToLine(name: string) {
return name.replace(/([A-Z])/g, '_$1').toLowerCase()
}
export function humpDataToLineData(rawData: Record<string, any>) {
const newData = {}
Object.keys(rawData).forEach((key: string) => {
if (typeof rawData[key] === 'object') {
newData[humpToLine(key)] = humpDataToLineData(rawData[key])
} else {
newData[humpToLine(key)] = rawData[key]
}
})
return newData
}
export * from './date'
import _ from 'lodash'
import {
Get,
Post,
Body,
Param,
Query,
Request,
UseGuards,
Controller,
UseInterceptors,
ClassSerializerInterceptor,
} from '@nestjs/common'
import { PermissionGuard } from '@/guards'
import { CloudBaseService } from '@/services'
import { IsNotEmpty } from 'class-validator'
import { getCloudBaseManager } from '@/utils'
import { CollectionV2 } from '@/constants'
class MigrateBody {
@IsNotEmpty()
filePath: string
@IsNotEmpty()
collectionName: string
@IsNotEmpty()
conflictMode: 'insert' | 'upsert'
}
interface MigrateJobDto {
// 项目 Id
projectId: string
// 任务 Id
jobId: number
// 导入文件路径
filePath: string
// 导入冲突处理模式
conflictMode: 'upsert' | 'insert'
createTime: number
collectionName: string
// 任务状态
// waiting:等待中,reading:读,writing:写,migrating:转移中,success:成功,fail:失败
status?: string
}
@UseGuards(PermissionGuard('content', ['administrator']))
@UseInterceptors(ClassSerializerInterceptor)
@Controller('projects/:projectId/migrate')
export class MigrateController {
constructor(public cloudbaseService: CloudBaseService) {}
@Get()
async listMigrateJobs(
@Param('projectId') projectId,
@Query() query: { page: number; pageSize: number }
) {
const { page = 1, pageSize = 10 } = query
const dbQuery = this.collection()
.where({
projectId,
})
.limit(Number(pageSize))
.skip(Number(page - 1) * pageSize)
const { total } = await dbQuery.count()
const { data } = await dbQuery.get()
const manager = getCloudBaseManager()
if (!data?.length) {
return {
total: 0,
data: [],
}
}
const requests = data.map(async (job: MigrateJobDto) => {
const { Status } = await manager.database.migrateStatus(job.jobId)
return {
...job,
status: Status,
}
})
const jobs = await Promise.all(requests)
return {
data: jobs,
total,
}
}
/**
* 创建迁移任务
*/
@Post()
async createMigrateJob(
@Param('projectId') projectId,
@Body() body: MigrateBody,
@Request() req: AuthRequest
) {
const { filePath, collectionName, conflictMode } = body
const manager = getCloudBaseManager()
const fileKey = `cloudbase-cms/${filePath}`
// 导入数据
const { JobId } = await manager.database.import(
collectionName,
{
ObjectKey: fileKey,
},
{
StopOnError: true,
ConflictMode: conflictMode,
}
)
const jobRecord: MigrateJobDto = {
projectId,
conflictMode,
collectionName,
jobId: JobId,
filePath: fileKey,
createTime: Date.now(),
}
// 添加记录
return this.collection().add(jobRecord)
}
collection(collection = CollectionV2.DataMigrateTasks) {
return this.cloudbaseService.collection(collection)
}
}
......@@ -64,7 +64,7 @@ export class ProjectsController {
}
const dbQuery = this.collection().where(filter)
const countRes = await dbQuery.count()
const { total } = await dbQuery.count()
const { data } = await dbQuery
.skip(Number(page - 1) * Number(pageSize))
......@@ -73,7 +73,7 @@ export class ProjectsController {
return {
data,
total: countRes.total,
total,
}
}
......
......@@ -6,9 +6,16 @@ import { SchemasController } from './schemas/schema.controller'
import { WebhooksController } from './webhooks/webhooks.controller'
import { ContentsService } from './contents/contents.service'
import { ContentsController } from './contents/contents.controller'
import { MigrateController } from './migrate/migrate.controller'
@Module({
controllers: [SchemasController, WebhooksController, ContentsController, ProjectsController],
controllers: [
SchemasController,
WebhooksController,
ContentsController,
ProjectsController,
MigrateController,
],
providers: [SchemasService, ContentsService, WebhooksService],
})
export class ProjectsModule {}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment