Feature: plugin-china-region (#66)

* feat: add core function for china region cascade select

* fix: test case

* refactor: use belongsToMany for region field and fix component

* fix: data import

* fix: remove sort field

* fix: clear on changeOnSelect is false
This commit is contained in:
Junyi 2021-02-22 15:08:35 +08:00 committed by GitHub
parent ce5690e25c
commit 662619b91c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 484 additions and 99 deletions

View File

@ -30,6 +30,7 @@
"@nocobase/database": "^0.3.0-alpha.0",
"@nocobase/father-build": "^0.3.0-alpha.0",
"@nocobase/plugin-action-logs": "^0.3.0-alpha.0",
"@nocobase/plugin-china-region": "^0.3.0-alpha.0",
"@nocobase/plugin-collections": "^0.3.0-alpha.0",
"@nocobase/plugin-pages": "^0.3.0-alpha.0",
"@nocobase/server": "^0.3.0-alpha.0",

View File

@ -68,5 +68,6 @@ api.registerPlugin('plugin-users', [path.resolve(__dirname, '../../../plugin-use
api.registerPlugin('plugin-file-manager', [path.resolve(__dirname, '../../../plugin-file-manager'), {}]);
api.registerPlugin('plugin-permissions', [path.resolve(__dirname, '../../../plugin-permissions'), {}]);
api.registerPlugin('plugin-automations', [path.resolve(__dirname, '../../../plugin-automations'), {}]);
api.registerPlugin('plugin-china-region', [path.resolve(__dirname, '../../../plugin-china-region'), {}]);
export default api;

View File

@ -0,0 +1,13 @@
import { init } from '@nocobase/plugin-china-region/src/db/seeders';
import api from '../app';
(async () => {
await api.loadPlugins();
await api.database.sync();
await init(api);
})().then(() => {
process.exit(0);
}).catch(err => {
console.error(err);
process.exit(1);
});

View File

@ -6,8 +6,9 @@ global.sync = {
},
};
import api from '../app';
import Database from '@nocobase/database';
import { init as chinaRegionSeederInit } from '@nocobase/plugin-china-region/src/db/seeders';
import api from '../app';
const data = [
{
@ -234,6 +235,9 @@ const data = [
await Action.bulkCreate([
]);
// 导入地域数据
await chinaRegionSeederInit(api);
await database.getModel('collections').import(require('./collections/example').default);
await database.getModel('collections').import(require('./collections/authors').default);
await database.getModel('collections').import(require('./collections/books').default);

View File

@ -0,0 +1,125 @@
import React, { useEffect, useState } from 'react';
import { connect } from '@formily/react-schema-renderer'
import { Cascader as AntdCascader } from 'antd'
import { useRequest } from 'umi';
import api from '@/api-client';
import {
transformDataSourceKey,
mapStyledProps,
mapTextComponent
} from '../shared'
function findTreeNode(tree, values, { value = 'value', children = 'children' }) {
let node, i;
for (node = tree, i = 0; node && values[i]; i++ ) {
node = (node[children] || []).find(item => item[value] === values[i][value]);
}
return node;
}
export const Cascader = connect({
getProps: mapStyledProps,
getComponent: mapTextComponent,
})(function (props) {
const {
disabled,
target,
labelField,
valueField = 'id',
parentField,
maxLevel,
changeOnSelect,
value = [],
onChange,
// TODO(feature): 增加静态数据支持
// dataSource: []
} = props;
const fieldNames = {
label: labelField,
value: valueField,
children: 'children'
};
const [options, setOptions] = useState([]);
const { loading, run } = useRequest(async (selectedOptions = []) => {
if (maxLevel != null && selectedOptions.length >= maxLevel) {
return;
}
const last = selectedOptions[selectedOptions.length - 1] || null;
if (last) {
if (last.isLeaf) {
return;
}
last.loading = true;
}
return api.resource(target).list({
filter: {
[parentField]: last && last[valueField]
},
perPage: -1,
sort: [valueField]
});
// TODO(bug): 关联资源加载问题较多,暂时先用 filter 解决
// return api.resource(`${target}.${target}`).list({
// associatedKey: last,
// perPage: -1
// });
}, {
manual: true,
onSuccess(result, [selectedOptions = []]) {
if (!result) {
return;
}
const data = result.map(item => ({
...item,
isLeaf: maxLevel != null && item.level >= maxLevel
}));
// 找到已有值指向的 options 节点
const root = { [fieldNames.children]: options };
const node = findTreeNode(root, selectedOptions, fieldNames);
if (node && node !== root) {
node.children = data;
node.loading = false;
// use spread array to avoid popup to be collapsed
setOptions([...options]);
} else {
setOptions(data);
}
}
});
// 根据 value 的值,按需预加载相应的数据
useEffect(() => {
if (value.length) {
value.reduce((promise, option, i) => promise.then(() => run(value.slice(0, i))), Promise.resolve());
} else {
run([]);
}
}, []);
return (
<AntdCascader
disabled={disabled}
options={options}
value={value.map(item => item[valueField])}
onChange={(v, selected) => {
if (maxLevel != null && v.length < maxLevel) {
run(selected);
}
if (changeOnSelect || !v.length || v.length >= maxLevel) {
onChange(selected);
}
}}
loadData={run}
changeOnSelect={changeOnSelect}
fieldNames={fieldNames}
/>
);
})
export default Cascader

View File

@ -0,0 +1 @@
import 'antd/lib/cascader/style/index'

View File

@ -17,6 +17,7 @@ import { Filter } from './filter'
import { RemoteSelect } from './remote-select'
import { DrawerSelect } from './drawer-select'
import { SubTable } from './sub-table'
import { Cascader } from './cascader'
import { Icon } from './icons'
import { ColorSelect } from './color-select'
import { Permissions } from './permissions'
@ -29,6 +30,7 @@ export const setup = () => {
time: TimePicker,
timerange: TimePicker.RangePicker,
transfer: Transfer,
cascader: Cascader,
boolean: Checkbox,
switch: Switch,
checkbox: Checkbox,

View File

@ -44,12 +44,12 @@ const createEnum = (enums: any) => {
}
export const Select: React.FC<SelectProps> = styled((props: SelectProps) => {
const { dataSource = [], onChange, value, ...others } = props
const { dataSource = [], onChange, value, ...others } = props;
const children = createEnum(dataSource).map(item => {
const { label, value, children = [], ...others } = item
const { label, value, key, children = [], ...others } = item;
if (children.length) {
return (
<AntSelect.OptGroup label={label}>
<AntSelect.OptGroup key={key} label={label}>
{children.map(({value, label, ...others}: any) => (
<AntSelect.Option
key={value}

View File

@ -361,20 +361,34 @@ export function AttachmentFieldItem(props: any) {
);
}
function LogField(props) {
export function LogField(props) {
const { value = {} } = props;
return (
<div>{value.title||value.name}</div>
)
}
function LogFieldValue(props) {
export function LogFieldValue(props) {
const { value, schema, data } = props;
return (
<div>{JSON.stringify(value)}</div>
)
}
export function ChinaRegion(props: any) {
const { schema, value } = props;
if (!value) {
return null;
}
const values = (Array.isArray(value) ? value : [value])
.sort((a, b) => a.level !== b.level ? a.level - b.level : a.sort - b.sort);
return (
<div className={'china-region-field'}>
{values.map(item => item.name).join('/')}
</div>
);
}
registerFieldComponents({
string: StringField,
textarea: TextareaField,
@ -396,6 +410,7 @@ registerFieldComponents({
attachment: AttachmentField,
'logs.field': LogField,
'logs.fieldValue': LogFieldValue,
chinaRegion: ChinaRegion,
});
export default function Field(props: any) {

View File

@ -0,0 +1,10 @@
{
"name": "@nocobase/plugin-china-region",
"version": "0.3.0-alpha.0",
"main": "lib/index.js",
"license": "MIT",
"dependencies": {
"@nocobase/database": "^0.3.0-alpha.0",
"china-division": "2.3.1"
}
}

View File

@ -0,0 +1,47 @@
import { TableOptions } from '@nocobase/database';
export default {
name: 'china_regions',
title: '中国行政区划',
internal: true,
developerMode: true,
fields: [
// 如使用代码作为 id 可能更节省,但由于代码数字最长为 12 字节,除非使用 bigint(64) 才够放置
{
name: 'code',
title: '代码',
interface: 'string',
type: 'string',
unique: true,
},
{
name: 'name',
title: '名称',
interface: 'string',
type: 'string',
},
{
name: 'parent',
title: '从属',
interface: 'linkTo',
type: 'belongsTo',
target: 'china_regions',
targetKey: 'code',
foreignKey: 'parent_code',
},
{
name: 'children',
title: '下辖',
interface: 'linkTo',
type: 'hasMany',
target: 'china_regions',
sourceKey: 'code',
foreignKey: 'parent_code'
},
{
name: 'level',
title: '层级',
type: 'integer'
}
]
} as TableOptions;

View File

@ -0,0 +1 @@
export { default as init } from './init';

View File

@ -0,0 +1,47 @@
import { provinces, cities, areas, streets, villages } from 'china-division';
export default async function({ database }) {
const Model = database.getModel('china_regions');
// TODO(feature): 可以考虑再加一级大区,编码规则应该是支持的
// 华北、东北、华东、华南、西南、西北、华中
const timer = Date.now();
await Model.bulkCreate(provinces.map(item => ({
code: item.code,
name: item.name,
level: 1
})));
await Model.bulkCreate(cities.map(item => ({
code: item.code,
name: item.name,
level: 2,
parent_code: item.provinceCode
})));
await Model.bulkCreate(areas.map(item => ({
code: item.code,
name: item.name,
level: 3,
parent_code: item.cityCode
})));
// 乡级数据 2856 条
await Model.bulkCreate(streets.map(item => ({
code: item.code,
name: item.name,
level: 4,
parent_code: item.areaCode
})));
// 村级数据 658001 条
await Model.bulkCreate(villages.map(item => ({
code: item.code,
name: item.name,
level: 5,
parent_code: item.streetCode
})));
const count = await Model.count();
console.log(`${count} rows of region data imported in ${(Date.now() - timer) / 1000}s`);
};

View File

@ -0,0 +1,18 @@
import path from 'path';
// import actions from '@nocobase/actions';
// 不从主入口导出,考虑到加载的数据量太大比较占内存,只在迁移时处理
// export * as seeders from './db/seeders';
export default async function (options = {}) {
const { database, resourcer } = this;
database.import({
directory: path.resolve(__dirname, 'collections'),
});
// resourcer.define({
// name: 'china_regions.china_regions',
// actions: actions.common
// });
}

View File

@ -1,7 +1,6 @@
import { Agent, getAgent, getApp } from '../';
import { Application } from '@nocobase/server';
import { options, types } from '../../interfaces';
import { FieldModel } from '../../models';
import { types } from '../../interfaces';
describe('models.field', () => {
let app: Application;

View File

@ -1,5 +1,5 @@
import { TableOptions } from '@nocobase/database';
import { options } from '../interfaces';
import { types, getOptions } from '../interfaces';
export default {
name: 'fields',
@ -57,7 +57,7 @@ export default {
name: 'interface',
title: '字段类型',
required: true,
dataSource: options,
dataSource: getOptions(),
createOnly: true,
component: {
type: 'select',
@ -65,6 +65,17 @@ export default {
showInDetail: true,
showInForm: true,
"x-linkages": [
// TODO(draft): 统一解决字段类型和配置参数联动的一种方式
// {
// type: 'value:schema',
// target: 'options',
// schema: {
// 'x-component-props': {
// fields: '{{ $self.values[1].fields || [] }}'
// }
// },
// condition: '{{ !!$self.value }}'
// },
{
"type": "value:visible",
"target": "precision",
@ -130,9 +141,31 @@ export default {
"target": "required",
"condition": "{{ ['createdAt', 'updatedAt', 'createdBy', 'updatedBy'].indexOf($self.value) === -1 }}"
},
{
"type": "value:visible",
"target": "maxLevel",
"condition": "{{ ['chinaRegion'].includes($self.value) }}"
},
{
"type": "value:visible",
"target": "incompletely",
"condition": "{{ ['chinaRegion'].includes($self.value) }}"
},
],
},
},
// TODO(draft): 将 options 作为集合字段开放出来,可以动态的解决字段参数的配置表单联动问题
// {
// interface: 'json',
// type: 'json',
// name: 'options',
// title: '配置信息',
// defaultValue: {},
// component: {
// type: 'subFields',
// showInForm: true,
// },
// },
{
interface: 'subTable',
type: 'virtual',
@ -250,6 +283,33 @@ export default {
default: 'HH:mm:ss',
},
},
// TODO(refactor): 此部分类型相关的参数,后期应拆分出去
{
name: 'maxLevel',
title: '可选层级',
interface: 'radio',
type: 'virtual',
dataSource: [
{ value: 1, label: '省' },
{ value: 2, label: '市' },
{ value: 3, label: '区/县' },
{ value: 4, label: '乡镇/街道' },
{ value: 5, label: '村/居委会' },
],
component: {
showInForm: true,
default: 3
}
},
{
name: 'incompletely',
title: '可部分选择',
interface: 'boolean',
type: 'virtual',
component: {
showInForm: true,
}
},
{
interface: 'linkTo',
multiple: false,

View File

@ -4,90 +4,44 @@
import * as types from './types';
export * as types from './types';
export const options = [
{
key: 'basic',
title: '基本类型',
children: [
types.string,
types.textarea,
types.phone,
types.email,
types.number,
types.percent,
],
},
{
key: 'media',
title: '多媒体类型',
children: [
types.wysiwyg,
types.attachment,
],
},
{
key: 'choices',
title: '选择类型',
children: [
types.boolean,
types.select,
types.multipleSelect,
types.radio,
types.checkboxes,
],
},
{
key: 'datetime',
title: '日期和时间',
children: [
types.datetime,
types.time,
],
},
{
key: 'relation',
title: '关系类型',
children: [
types.subTable,
types.linkTo,
],
},
{
key: 'systemInfo',
title: '系统信息',
children: [
types.createdAt,
types.updatedAt,
types.createdBy,
types.updatedBy,
],
},
{
key: 'developerMode',
title: '开发者模式',
children: [
types.primaryKey,
types.sort,
types.password,
types.json,
types.icon,
],
},
{
key: 'others',
title: '其他',
children: [
types.description,
],
}
].map(({key, title, children}: any) => ({
key,
label: title,
children: children.map(child => ({
label: child.title,
value: child.options.interface,
disabled: child.disabled,
})),
}));
export const groupLabelMap = {
basic: '基本类型',
media: '多媒体类型',
choices: '选择类型',
datetime: '日期和时间',
relation: '关系类型',
systemInfo: '关系类型',
developerMode: '开发者模式',
others: '其他'
};
export default options;
export function getOptions() {
return Object.keys(groupLabelMap).map(key => ({
key,
label: groupLabelMap[key],
children: Object.values(types)
.filter(type => type['group'] === key)
.map(type => ({
label: type.title,
value: type.options.interface,
// TODO(draft): 配置信息一并存到数据库方便字段配置时取出参与联动计算
// properties: type.properties,
disabled: type['disabled'],
}))
}));
}
export type interfaceType = {
title: string,
group?: string,
options: {
[key: string]: any
},
disabled?: boolean
};
// TODO(draft)
// 目前仅在内存中注册,应用启动时需要解决扩展字段读取并注册到内存
export function register(type: interfaceType) {
types[type.options.interface] = type;
}

View File

@ -7,6 +7,7 @@
// TODO: interface 的修改
export const string = {
title: '单行文本',
group: 'basic',
options: {
interface: 'string',
type: 'string',
@ -19,6 +20,7 @@ export const string = {
export const textarea = {
title: '多行文本',
group: 'basic',
options: {
interface: 'textarea',
type: 'text',
@ -31,6 +33,7 @@ export const textarea = {
export const phone = {
title: '手机号码',
group: 'basic',
options: {
interface: 'phone',
type: 'string',
@ -45,6 +48,7 @@ export const phone = {
export const email = {
title: '邮箱',
group: 'basic',
options: {
interface: 'email',
type: 'string',
@ -62,6 +66,7 @@ export const email = {
*/
export const number = {
title: '数字',
group: 'basic',
options: {
interface: 'number',
type: 'float',
@ -80,6 +85,7 @@ export const number = {
*/
export const percent = {
title: '百分比',
group: 'basic',
options: {
interface: 'percent',
type: 'float',
@ -94,6 +100,7 @@ export const percent = {
export const wysiwyg = {
title: '可视化编辑器',
group: 'media',
disabled: true,
options: {
interface: 'wysiwyg',
@ -109,6 +116,7 @@ export const wysiwyg = {
*/
export const attachment = {
title: '附件',
group: 'media',
// disabled: true,
options: {
interface: 'attachment',
@ -129,6 +137,7 @@ export const attachment = {
*/
export const select = {
title: '下拉选择(单选)',
group: 'choices',
options: {
interface: 'select',
type: 'string',
@ -152,6 +161,7 @@ export const select = {
*/
export const multipleSelect = {
title: '下拉选择(多选)',
group: 'choices',
options: {
interface: 'multipleSelect',
type: 'json', // json 过滤
@ -167,6 +177,7 @@ export const multipleSelect = {
export const radio = {
title: '单选框',
group: 'choices',
options: {
interface: 'radio',
type: 'string',
@ -180,6 +191,7 @@ export const radio = {
export const checkboxes = {
title: '多选框',
group: 'choices',
options: {
interface: 'checkboxes',
type: 'json',
@ -194,6 +206,7 @@ export const checkboxes = {
export const boolean = {
title: '是/否',
group: 'choices',
options: {
interface: 'boolean',
type: 'boolean',
@ -210,6 +223,7 @@ export const boolean = {
*/
export const datetime = {
title: '日期',
group: 'datetime',
options: {
interface: 'datetime',
type: 'date',
@ -222,10 +236,34 @@ export const datetime = {
type: 'date',
},
},
// TODO(draft): 配置参数的一种描述方式
// properties: [
// {
// name: 'showTime',
// title: '显示时间',
// interface: 'boolean',
// component: {
// 'x-linkages': [
// {
// type: 'value:visible',
// target: 'timeFormat',
// condition: '{{ $value }}'
// }
// ]
// }
// },
// {
// interface: 'string',
// name: 'timeFormat',
// title: '时间格式',
// filterable: false
// }
// ]
};
export const time = {
title: '时间',
group: 'datetime',
options: {
interface: 'time',
type: 'time',
@ -275,6 +313,7 @@ export const time = {
// });
export const subTable = {
title: '子表格',
group: 'relation',
// disabled: true,
options: {
interface: 'subTable',
@ -329,6 +368,7 @@ export const subTable = {
export const linkTo = {
title: '关联数据',
group: 'relation',
// disabled: true,
options: {
interface: 'linkTo',
@ -346,6 +386,7 @@ export const linkTo = {
export const createdAt = {
title: '创建时间',
group: 'systemInfo',
options: {
interface: 'createdAt',
type: 'date',
@ -365,6 +406,7 @@ export const createdAt = {
export const updatedAt = {
title: '修改时间',
group: 'systemInfo',
options: {
interface: 'updatedAt',
type: 'date',
@ -384,6 +426,7 @@ export const updatedAt = {
export const createdBy = {
title: '创建人',
group: 'systemInfo',
// disabled: true,
options: {
interface: 'createdBy',
@ -402,6 +445,7 @@ export const createdBy = {
export const updatedBy = {
title: '修改人',
group: 'systemInfo',
// disabled: true,
options: {
interface: 'updatedBy',
@ -438,6 +482,7 @@ export const group = {
export const description = {
title: '说明文字',
group: 'others',
options: {
interface: 'description',
// name: 'id',
@ -453,6 +498,7 @@ export const description = {
*/
export const primaryKey = {
title: '主键',
group: 'developerMode',
options: {
interface: 'primaryKey',
name: 'id',
@ -474,6 +520,7 @@ export const primaryKey = {
*/
export const sort = {
title: '排序',
group: 'developerMode',
options: {
interface: 'sort',
type: 'integer',
@ -487,6 +534,7 @@ export const sort = {
export const password = {
title: '密码',
group: 'developerMode',
options: {
interface: 'password',
type: 'password',
@ -499,6 +547,7 @@ export const password = {
export const json = {
title: 'JSON',
group: 'developerMode',
options: {
interface: 'json',
type: 'json',
@ -512,6 +561,7 @@ export const json = {
export const icon = {
title: '图标',
group: 'developerMode',
options: {
interface: 'icon',
type: 'string',
@ -520,3 +570,32 @@ export const icon = {
},
},
};
export const chinaRegion = {
title: '中国行政区划',
group: 'choices',
options: {
interface: 'chinaRegion',
type: 'belongsToMany',
// 数据来源的数据表,与 dataSource 不同,需要从表数据加载后转化成 dataSource
target: 'china_regions',
targetKey: 'code',
// 值字段
// valueField: 'code',
// 名称字段
labelField: 'name',
// TODO(refactor): 等 toWhere 重构完成后要改成 parent
// 上级字段名
parentField: 'parent_code',
// 深度限制,默认:-1代表不控制即如果是数据表则无限加载
// limit: -1,
// 可选层级,默认:-1代表可选的最深层级
// maxLevel: null,
// 是否可以不选择到最深一级
// 'x-component-props': { changeOnSelect: true }
incompletely: false,
component: {
type: 'cascader',
}
}
};

View File

@ -152,6 +152,14 @@ const transforms = {
if (['radio', 'select', 'multipleSelect', 'checkboxes'].includes(interfaceType)) {
prop.enum = field.get('dataSource');
}
if (interfaceType === 'chinaRegion') {
set(prop, 'x-component-props.target', field.get('target'));
set(prop, 'x-component-props.labelField', field.get('labelField'));
set(prop, 'x-component-props.valueField', field.get('targetKey'));
set(prop, 'x-component-props.parentField', field.get('parentField'));
set(prop, 'x-component-props.maxLevel', field.get('maxLevel'));
set(prop, 'x-component-props.changeOnSelect', field.get('incompletely'));
}
schema[field.name] = {
id: field.id,
...prop,
@ -362,7 +370,7 @@ export default async (ctx, next) => {
}
for (const field of fields) {
if (!['subTable', 'linkTo', 'attachment', 'createdBy', 'updatedBy'].includes(field.get('interface'))) {
if (!['subTable', 'linkTo', 'attachment', 'createdBy', 'updatedBy', 'chinaRegion'].includes(field.get('interface'))) {
continue;
}
let showInKey;