feat: collection options & hooks (#21)
* feat: collection hooks * export action middlewares * add associated middleware * cleanup * add field interface options * 调整配置参数 * 补充字段类型 options * 继续调整配置参数 * 支持排序 * filterable & sortable & draggable * feat: add random name for creating table (#23) * feat: add random name for creating table * fix: random number * Feature: collections field (#24) * feat: add random name for field and update table options * fix: make field name required * fix: this declaration * showInXX 参数调整 * showInXX 放 component 里 * 继续调整参数 * 字段分组、pages 表配置参数等 * change date to datetime * 选择类型字段的 options 改为 dataSource * feat: refactor hooks initialization and add field options by interface (#25) * feat: refactor hooks initialization and add field options by interface * refactor: use model.set to build input values * refactor: extend setter/getter to adapt field options * fix: try to fix virtual field * refactor: setter/getter of FieldModel * 改进自定义 model 等细节 * 补充注释 * bugfix Co-authored-by: Junyi <mytharcher@users.noreply.github.com>
This commit is contained in:
parent
d9e6d2e614
commit
b5ddd6a6ba
@ -384,7 +384,7 @@ export async function sort(ctx: Context, next: Next) {
|
||||
const Model = ctx.db.getModel(resourceName);
|
||||
const table = ctx.db.getTable(resourceName);
|
||||
|
||||
if (!table.getOptions().sortable || !values.offset) {
|
||||
if (!values.offset) {
|
||||
return next();
|
||||
}
|
||||
const [primaryField] = Model.primaryKeyAttributes;
|
||||
|
@ -1,5 +1,5 @@
|
||||
import * as actions from './actions';
|
||||
|
||||
export * from './middleware';
|
||||
|
||||
export * as actions from './actions';
|
||||
export * as middlewares from './middlewares';
|
||||
export default actions;
|
||||
|
2
packages/actions/src/middlewares/index.ts
Normal file
2
packages/actions/src/middlewares/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './associated';
|
||||
export * from './json-reponse';
|
@ -1,31 +1,32 @@
|
||||
import { request } from 'umi';
|
||||
|
||||
interface ResourceProxyConstructor {
|
||||
new <T, H extends object>(target: T, handler: ProxyHandler<H>): H
|
||||
}
|
||||
|
||||
const ResourceProxy = Proxy as ResourceProxyConstructor;
|
||||
|
||||
interface Params {
|
||||
interface ActionParams {
|
||||
resourceKey?: string | number;
|
||||
// resourceName?: string;
|
||||
// associatedName?: string;
|
||||
associatedKey?: string | number;
|
||||
fields?: any;
|
||||
filter?: any;
|
||||
values?: any;
|
||||
page?: any;
|
||||
perPage?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface Handler {
|
||||
[name: string]: (params?: Params) => Promise<any>;
|
||||
interface Resource {
|
||||
get: (params?: ActionParams) => Promise<any>;
|
||||
list: (params?: ActionParams) => Promise<any>;
|
||||
create: (params?: ActionParams) => Promise<any>;
|
||||
update: (params?: ActionParams) => Promise<any>;
|
||||
destroy: (params?: ActionParams) => Promise<any>;
|
||||
[name: string]: (params?: ActionParams) => Promise<any>;
|
||||
}
|
||||
|
||||
class APIClient {
|
||||
resource(name: string) {
|
||||
return new ResourceProxy<object, Handler>({}, {
|
||||
class ApiClient {
|
||||
resource(name: string): Resource {
|
||||
const proxy: any = new Proxy({}, {
|
||||
get(target, method, receiver) {
|
||||
return (params: Params = {}) => {
|
||||
console.log(params);
|
||||
return (params: ActionParams = {}) => {
|
||||
const { associatedKey, resourceKey, ...restParams } = params;
|
||||
let url = `/${name}`;
|
||||
let options: any = {};
|
||||
@ -49,9 +50,10 @@ class APIClient {
|
||||
};
|
||||
}
|
||||
});
|
||||
return proxy;
|
||||
}
|
||||
}
|
||||
|
||||
const api = new APIClient();
|
||||
const api = new ApiClient();
|
||||
|
||||
export default api;
|
||||
|
@ -36,7 +36,7 @@ const api = Api.create({
|
||||
});
|
||||
|
||||
api.resourcer.use(associated);
|
||||
api.resourcer.registerHandlers(actions.associate);
|
||||
api.resourcer.registerHandlers({...actions.common, ...actions.associate});
|
||||
|
||||
const data = {
|
||||
title: '后台应用',
|
||||
@ -158,159 +158,21 @@ const data = {
|
||||
|
||||
const database: Database = api.database;
|
||||
|
||||
await database.sync();
|
||||
await database.sync({
|
||||
// tables: ['collections', 'fields', 'actions', 'views', 'tabs'],
|
||||
});
|
||||
|
||||
const Collection = database.getModel('collections');
|
||||
const tables = database.getTables([]);
|
||||
|
||||
for (let table of tables) {
|
||||
await Collection.import(table.getOptions(), { hooks: false });
|
||||
}
|
||||
|
||||
const Page = database.getModel('pages');
|
||||
const page = await Page.create(data);
|
||||
await page.updateAssociations(data);
|
||||
|
||||
const [Collection, View, Action, Tab] = database.getModels(['collections', 'views', 'actions', 'tabs']);
|
||||
const tables = database.getTables([]);
|
||||
|
||||
for (let table of tables) {
|
||||
const options = table.getOptions();
|
||||
const collection = await Collection.create(options);
|
||||
// console.log(options);
|
||||
const associations: any = {};
|
||||
if (options.fields) {
|
||||
associations['fields'] = options.fields.map((item, sort) => ({
|
||||
...item,
|
||||
options: item,
|
||||
sort,
|
||||
}))
|
||||
}
|
||||
if (options.tabs) {
|
||||
associations['tabs'] = options.tabs.map((item, sort) => ({
|
||||
...item,
|
||||
options: item,
|
||||
sort,
|
||||
}))
|
||||
}
|
||||
if (options.actions) {
|
||||
associations['actions'] = options.actions.map((item, sort) => ({
|
||||
...item,
|
||||
options: item,
|
||||
sort,
|
||||
}))
|
||||
}
|
||||
if (options.views) {
|
||||
associations['views'] = options.views.map((item, sort) => ({
|
||||
...item,
|
||||
options: item,
|
||||
sort,
|
||||
}))
|
||||
}
|
||||
await collection.updateAssociations(associations);
|
||||
}
|
||||
|
||||
// const actions = await Action.findAll();
|
||||
|
||||
// for (const action of actions) {
|
||||
// const viewName = action.options.viewName;
|
||||
// console.log({viewName});
|
||||
// if (viewName) {
|
||||
// const view = await View.findOne({
|
||||
// where: {
|
||||
// name: viewName,
|
||||
// collection_name: action.collection_name
|
||||
// },
|
||||
// });
|
||||
// if (view) {
|
||||
// action.options.viewId = view.id;
|
||||
// console.log(action.options);
|
||||
// action.setDataValue('options', action.options);
|
||||
// action.changed('options', true);
|
||||
// await action.save();
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// const tabs = await Tab.findAll();
|
||||
|
||||
// for (const tab of tabs) {
|
||||
// const viewName = tab.options.viewName;
|
||||
// if (!viewName) {
|
||||
// continue;
|
||||
// }
|
||||
// let view: any;
|
||||
// if (tab.type === 'association') {
|
||||
// view = await View.findOne({
|
||||
// where: {
|
||||
// name: viewName,
|
||||
// collection_name: tab.options.association,
|
||||
// },
|
||||
// });
|
||||
// } else {
|
||||
// view = await View.findOne({
|
||||
// where: {
|
||||
// name: viewName,
|
||||
// collection_name: tab.collection_name,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// if (view) {
|
||||
// tab.options.viewId = view.id;
|
||||
// tab.setDataValue('options', tab.options);
|
||||
// tab.changed('options', true);
|
||||
// await tab.save();
|
||||
// }
|
||||
// }
|
||||
// const views = await View.findAll();
|
||||
// for (const view of views) {
|
||||
// const detailsViewName = view.options.detailsViewName;
|
||||
// if (detailsViewName) {
|
||||
// const v = await View.findOne({
|
||||
// where: {
|
||||
// name: detailsViewName,
|
||||
// collection_name: view.collection_name
|
||||
// },
|
||||
// });
|
||||
// if (v) {
|
||||
// view.options.detailsViewId = v.id;
|
||||
// view.setDataValue('options', view.options);
|
||||
// view.changed('options', true);
|
||||
// await view.save();
|
||||
// }
|
||||
// }
|
||||
// const updateViewName = view.options.updateViewName;
|
||||
// if (updateViewName) {
|
||||
// const v = await View.findOne({
|
||||
// where: {
|
||||
// name: updateViewName,
|
||||
// collection_name: view.collection_name
|
||||
// },
|
||||
// });
|
||||
// if (v) {
|
||||
// view.options.updateViewId = v.id;
|
||||
// view.setDataValue('options', view.options);
|
||||
// view.changed('options', true);
|
||||
// await view.save();
|
||||
// }
|
||||
// }
|
||||
// console.log({detailsViewName, updateViewName});
|
||||
// }
|
||||
|
||||
// for (let table of tables) {
|
||||
// const options = table.getOptions();
|
||||
// const collection = await Collection.findOne({
|
||||
// where: {
|
||||
// name: options.name,
|
||||
// },
|
||||
// });
|
||||
// const tabs = await collection.getTabs() as Model[];
|
||||
// const actions = await collection.getActions() as Model[];
|
||||
// const views = await collection.getViews() as Model[];
|
||||
// for (const tab of tabs) {
|
||||
// tab.options.viewName;
|
||||
|
||||
// }
|
||||
// }
|
||||
|
||||
// const collections = await Collection.findAll();
|
||||
|
||||
// await Promise.all(collections.map(async (collection) => {
|
||||
// return await collection.modelInit();
|
||||
// }));
|
||||
|
||||
await Page.create({
|
||||
title: '登录页面',
|
||||
path: '/login',
|
||||
|
@ -20,10 +20,11 @@ export const setup = () => {
|
||||
timerange: TimePicker.RangePicker,
|
||||
transfer: Transfer,
|
||||
boolean: Switch,
|
||||
checkbox: Switch,
|
||||
array: ArrayCards,
|
||||
cards: ArrayCards,
|
||||
table: ArrayTable,
|
||||
checkbox: Checkbox.Group,
|
||||
checkboxes: Checkbox.Group,
|
||||
date: DatePicker,
|
||||
daterange: DatePicker.RangePicker,
|
||||
year: DatePicker.YearPicker,
|
||||
|
@ -29,9 +29,9 @@ export function SimpleTable(props: SimpleTableProps) {
|
||||
const { rowKey = 'id', fields = [], rowViewName, actions = [], paginated = true, defaultPageSize = 10 } = schema;
|
||||
const { sourceKey = 'id' } = activeTab.field||{};
|
||||
const drawerRef = useRef<any>();
|
||||
const { data, loading, pagination, mutate } = useRequest((params = {}) => {
|
||||
const name = associatedName ? `${associatedName}.${resourceName}` : resourceName;
|
||||
const { data, loading, pagination, mutate, refresh } = useRequest((params = {}) => {
|
||||
const { current, pageSize, ...restParams } = params;
|
||||
const name = associatedName ? `${associatedName}.${resourceName}` : resourceName;
|
||||
return api.resource(name).list({
|
||||
associatedKey,
|
||||
page: paginated ? current : 1,
|
||||
@ -75,7 +75,21 @@ export function SimpleTable(props: SimpleTableProps) {
|
||||
loading={loading}
|
||||
columns={fields2columns(fields)}
|
||||
dataSource={data?.list||(data as any)}
|
||||
components={components({data, mutate})}
|
||||
components={components({
|
||||
data,
|
||||
mutate,
|
||||
rowKey,
|
||||
onMoved: async ({resourceKey, offset}) => {
|
||||
await api.resource(name).sort({
|
||||
associatedKey,
|
||||
resourceKey,
|
||||
field: 'sort',
|
||||
offset,
|
||||
});
|
||||
await refresh();
|
||||
console.log({resourceKey, offset});
|
||||
}
|
||||
})}
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
drawerRef.current.setVisible(true);
|
||||
|
@ -14,20 +14,30 @@ export const DragHandle = sortableHandle(() => (
|
||||
<MenuOutlined style={{ cursor: 'pointer', color: '#999' }} />
|
||||
));
|
||||
|
||||
export const components = ({data = {}, mutate}: {data: any, mutate: any}) => {
|
||||
interface Props {
|
||||
data: any,
|
||||
mutate: any,
|
||||
rowKey: any,
|
||||
onMoved: any,
|
||||
}
|
||||
|
||||
export const components = ({data = {}, rowKey, mutate, onMoved}: Props) => {
|
||||
return {
|
||||
body: {
|
||||
wrapper: props => (
|
||||
<SortableContainer
|
||||
useDragHandle
|
||||
helperClass="row-dragging"
|
||||
onSortEnd={({ oldIndex, newIndex }) => {
|
||||
onSortEnd={async ({ oldIndex, newIndex, ...restProps }) => {
|
||||
if (oldIndex !== newIndex) {
|
||||
const list = arrayMove([].concat(data.list), oldIndex, newIndex).filter(el => !!el);
|
||||
console.log({oldIndex, newIndex, list});
|
||||
mutate({
|
||||
...data,
|
||||
list,
|
||||
});
|
||||
const resourceKey = get(list, [newIndex, rowKey]);
|
||||
await onMoved({resourceKey, offset: newIndex - oldIndex});
|
||||
}
|
||||
}}
|
||||
{...props}
|
||||
@ -35,7 +45,7 @@ export const components = ({data = {}, mutate}: {data: any, mutate: any}) => {
|
||||
),
|
||||
row: ({ className, style, ...restProps }) => {
|
||||
// function findIndex base on Table rowKey props and should always be a right array index
|
||||
const index = findIndex(data.list, (x: any) => x.id === restProps['data-row-key']);
|
||||
const index = findIndex(data.list, (x: any) => x[rowKey] === restProps['data-row-key']);
|
||||
return <SortableItem index={index} {...restProps} />;
|
||||
},
|
||||
},
|
||||
|
@ -42,13 +42,12 @@ export function Table(props: TableProps) {
|
||||
associatedKey,
|
||||
} = props;
|
||||
const { fields, defaultTabName, rowKey = 'id', actions = [], paginated = true, defaultPageSize = 10 } = schema;
|
||||
const name = associatedName ? `${associatedName}.${resourceName}` : resourceName;
|
||||
// const { data, mutate } = useRequest(() => api.resource(name).list({
|
||||
// associatedKey,
|
||||
// }));
|
||||
const { data, loading, pagination, mutate } = useRequest((params = {}) => {
|
||||
const name = associatedName ? `${associatedName}.${resourceName}` : resourceName;
|
||||
const { data, loading, pagination, mutate, refresh } = useRequest((params = {}) => {
|
||||
const { current, pageSize, ...restParams } = params;
|
||||
const name = associatedName ? `${associatedName}.${resourceName}` : resourceName;
|
||||
return api.resource(name).list({
|
||||
associatedKey,
|
||||
page: paginated ? current : 1,
|
||||
@ -86,7 +85,21 @@ export function Table(props: TableProps) {
|
||||
rowKey={rowKey}
|
||||
columns={fields2columns(fields)}
|
||||
dataSource={data?.list||(data as any)}
|
||||
components={components({data, mutate})}
|
||||
components={components({
|
||||
data,
|
||||
mutate,
|
||||
rowKey,
|
||||
onMoved: async ({resourceKey, offset}) => {
|
||||
await api.resource(name).sort({
|
||||
associatedKey,
|
||||
resourceKey,
|
||||
field: 'sort',
|
||||
offset,
|
||||
});
|
||||
await refresh();
|
||||
console.log({resourceKey, offset});
|
||||
}
|
||||
})}
|
||||
onRow={(data) => ({
|
||||
onClick: () => {
|
||||
redirectTo({
|
||||
|
23
packages/database/src/__tests__/model/custom.test.ts
Normal file
23
packages/database/src/__tests__/model/custom.test.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { registerModels } from '../..';
|
||||
import { getDatabase } from '../';
|
||||
import Model from '../../model';
|
||||
|
||||
describe('custom model', () => {
|
||||
it('custom model', async () => {
|
||||
class BaseModel extends Model {};
|
||||
const database = getDatabase();
|
||||
registerModels({BaseModel});
|
||||
database.table({
|
||||
name: 'tests',
|
||||
model: 'BaseModel',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
});
|
||||
await database.sync();
|
||||
await database.close();
|
||||
});
|
||||
});
|
@ -16,6 +16,28 @@ import {
|
||||
import Database from './database';
|
||||
import { Model, ModelCtor } from './model';
|
||||
|
||||
const registeredModels = new Map<string, any>();
|
||||
|
||||
export function registerModel(key: string, model: any) {
|
||||
registeredModels.set(key, model);
|
||||
}
|
||||
|
||||
export function registerModels(models) {
|
||||
for (const key in models) {
|
||||
if (models.hasOwnProperty(key)) {
|
||||
registerModel(key, models[key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: 判断如果 key 是 model 直接返回
|
||||
export function getRegisteredModel(key) {
|
||||
if (typeof key === 'string') {
|
||||
return registeredModels.get(key);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
export interface TableOptions extends Omit<ModelOptions<Model>, 'name'|'modelName'> {
|
||||
|
||||
/**
|
||||
@ -32,7 +54,7 @@ export interface TableOptions extends Omit<ModelOptions<Model>, 'name'|'modelNam
|
||||
/**
|
||||
* 自定义 model
|
||||
*/
|
||||
model?: ModelCtor<Model>;
|
||||
model?: ModelCtor<Model> | string;
|
||||
|
||||
/**
|
||||
* 字段配置
|
||||
@ -95,6 +117,8 @@ export class Table {
|
||||
|
||||
protected Model: ModelCtor<Model>;
|
||||
|
||||
protected defaultModel: ModelCtor<Model>;
|
||||
|
||||
/**
|
||||
* 是否是中间表
|
||||
*/
|
||||
@ -108,6 +132,7 @@ export class Table {
|
||||
name,
|
||||
fields = [],
|
||||
indexes = [],
|
||||
model,
|
||||
...restOptions
|
||||
} = options;
|
||||
this.options = options;
|
||||
@ -118,6 +143,8 @@ export class Table {
|
||||
sequelize: database.sequelize,
|
||||
...restOptions,
|
||||
};
|
||||
// 初始化的时候获取
|
||||
this.defaultModel = getRegisteredModel(model);
|
||||
this.modelAttributes = {};
|
||||
// 在 set fields 之前 model init 的原因是因为关系字段可能需要用到 model 的相关配置
|
||||
this.addIndexes(indexes, 'modelOnly');
|
||||
@ -127,7 +154,7 @@ export class Table {
|
||||
|
||||
public modelInit(reinitialize: Reinitialize = false) {
|
||||
if (reinitialize || !this.Model) {
|
||||
this.Model = this.options.model || class extends Model {};
|
||||
this.Model = this.defaultModel || class extends Model {};
|
||||
this.Model.database = this.database;
|
||||
// 关系的建立是在 model.init 之后,在配置中表字段(Column)和关系(Relation)都在 fields,
|
||||
// 所以需要单独提炼出 associations 字段,并在 Model.init 之后执行 Model.associate
|
||||
@ -250,7 +277,17 @@ export class Table {
|
||||
sourceTable: this,
|
||||
database: this.database,
|
||||
});
|
||||
// 添加字段后 table.options 中的 fields 并不会更新,这导致 table.getOptions() 拿不到最新的字段配置
|
||||
// 所以在同时更新 table.options.fields 数组
|
||||
const existIndex = this.options.fields.findIndex(field => field.name === name);
|
||||
if (existIndex !== -1) {
|
||||
this.options.fields.splice(existIndex, 1, options);
|
||||
} else {
|
||||
this.options.fields.push(options);
|
||||
}
|
||||
|
||||
this.fields.set(name, field);
|
||||
|
||||
if (field instanceof Relation) {
|
||||
// 关系字段先放到 associating 里待处理,等相关 target model 初始化之后,再通过 associate 建立关系
|
||||
this.associating.set(name, field);
|
||||
|
@ -4,8 +4,11 @@
|
||||
"main": "lib/index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@nocobase/server": "^0.3.0-alpha.0",
|
||||
"@nocobase/database": "^0.3.0-alpha.0",
|
||||
"@nocobase/resourcer": "^0.3.0-alpha.0"
|
||||
"@nocobase/resourcer": "^0.3.0-alpha.0",
|
||||
"@nocobase/server": "^0.3.0-alpha.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nocobase/actions": "^0.3.0-alpha.0"
|
||||
}
|
||||
}
|
||||
|
146
packages/plugin-collections/src/__tests__/base-model.test.ts
Normal file
146
packages/plugin-collections/src/__tests__/base-model.test.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import Database, { ModelCtor } from '@nocobase/database';
|
||||
import { getDatabase } from '.';
|
||||
import BaseModel from '../models/base';
|
||||
|
||||
describe('base model', () => {
|
||||
let database: Database;
|
||||
let TestModel: ModelCtor<BaseModel>;
|
||||
let test: BaseModel;
|
||||
beforeEach(async () => {
|
||||
database = getDatabase();
|
||||
database.table({
|
||||
name: 'tests',
|
||||
model: BaseModel,
|
||||
additionalAttribute: 'options',
|
||||
fields: [
|
||||
{
|
||||
name: 'name',
|
||||
type: 'string',
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'component',
|
||||
defaultValue: {},
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
defaultValue: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
await database.sync();
|
||||
TestModel = database.getModel('tests') as ModelCtor<BaseModel>;
|
||||
test = await TestModel.create({
|
||||
name: '123',
|
||||
abc: {aa: 'aa'},
|
||||
'abc.bb': 'bb',
|
||||
component: {
|
||||
a: 'a',
|
||||
},
|
||||
'component.b': 'b',
|
||||
options: {
|
||||
bcd: 'bbb',
|
||||
},
|
||||
arr: [{a: 'a'}, {b: 'b'}],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => database.close());
|
||||
|
||||
it('get all attribute', async () => {
|
||||
// 获取所有字段
|
||||
expect(test.get()).toMatchObject({
|
||||
abc: { aa: 'aa', bb: 'bb' },
|
||||
bcd: 'bbb',
|
||||
name: '123',
|
||||
component: { a: 'a', b: 'b' },
|
||||
arr: [{a: 'a'}, {b: 'b'}],
|
||||
});
|
||||
});
|
||||
|
||||
it('get options attribute', async () => {
|
||||
// 直接取 options 字段
|
||||
expect(test.get('options')).toEqual({
|
||||
abc: {
|
||||
aa: 'aa',
|
||||
bb: 'bb',
|
||||
},
|
||||
bcd: 'bbb',
|
||||
arr: [{a: 'a'}, {b: 'b'}],
|
||||
});
|
||||
});
|
||||
|
||||
it('get component attribute', async () => {
|
||||
expect(test.get('component')).toEqual({ a: 'a', b: 'b' });
|
||||
});
|
||||
|
||||
it('set component attribute with dot key', async () => {
|
||||
test.set('component.c', 'c');
|
||||
await test.save();
|
||||
expect(test.get()).toMatchObject({
|
||||
abc: { aa: 'aa', bb: 'bb' },
|
||||
bcd: 'bbb',
|
||||
name: '123',
|
||||
component: { a: 'a', b: 'b' },
|
||||
arr: [{a: 'a'}, {b: 'b'}],
|
||||
});
|
||||
expect(test.get('component')).toEqual({ a: 'a', b: 'b', c: 'c' });
|
||||
});
|
||||
|
||||
it('set options attribute with dot key', async () => {
|
||||
test.set('options.cccc', 'cccc');
|
||||
await test.save();
|
||||
expect(test.get()).toMatchObject({
|
||||
abc: { aa: 'aa', bb: 'bb' },
|
||||
bcd: 'bbb',
|
||||
name: '123',
|
||||
cccc: 'cccc',
|
||||
component: { a: 'a', b: 'b' },
|
||||
arr: [{a: 'a'}, {b: 'b'}],
|
||||
});
|
||||
});
|
||||
|
||||
it('set options attribute without options prefix', async () => {
|
||||
test.set('dddd', 'dddd');
|
||||
await test.save();
|
||||
expect(test.get()).toMatchObject({
|
||||
abc: { aa: 'aa', bb: 'bb' },
|
||||
bcd: 'bbb',
|
||||
name: '123',
|
||||
dddd: 'dddd',
|
||||
component: { a: 'a', b: 'b' },
|
||||
arr: [{a: 'a'}, {b: 'b'}],
|
||||
});
|
||||
});
|
||||
|
||||
it('refind', async () => {
|
||||
test.set('component.c', 'c');
|
||||
await test.save();
|
||||
// 重新查询
|
||||
const test2 = await TestModel.findByPk(test.id);
|
||||
expect(test2.get()).toMatchObject({
|
||||
abc: { aa: 'aa', bb: 'bb' },
|
||||
bcd: 'bbb',
|
||||
name: '123',
|
||||
component: { a: 'a', b: 'b', c: 'c' },
|
||||
arr: [{a: 'a'}, {b: 'b'}],
|
||||
});
|
||||
expect(test2.get('component')).toEqual({ a: 'a', b: 'b', c: 'c' });
|
||||
});
|
||||
|
||||
it('update', async () => {
|
||||
await test.update({
|
||||
'name123': 'xxx',
|
||||
'component.d': 'd',
|
||||
});
|
||||
expect(test.get()).toMatchObject({
|
||||
abc: { aa: 'aa', bb: 'bb' },
|
||||
bcd: 'bbb',
|
||||
name: '123',
|
||||
name123: 'xxx',
|
||||
component: { a: 'a', b: 'b', d: 'd' },
|
||||
arr: [{a: 'a'}, {b: 'b'}],
|
||||
});
|
||||
});
|
||||
});
|
159
packages/plugin-collections/src/__tests__/collections.test.ts
Normal file
159
packages/plugin-collections/src/__tests__/collections.test.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import { Agent, getAgent, getApp } from '.';
|
||||
import { Application } from '@nocobase/server';
|
||||
import * as types from '../interfaces/types';
|
||||
|
||||
describe('collection hooks', () => {
|
||||
let app: Application;
|
||||
let agent: Agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
app = await getApp();
|
||||
agent = getAgent(app);
|
||||
});
|
||||
|
||||
afterEach(() => app.database.close());
|
||||
|
||||
it('create table', async () => {
|
||||
const response = await agent.resource('collections').create({
|
||||
values: {
|
||||
name: 'tests',
|
||||
title: 'tests',
|
||||
},
|
||||
});
|
||||
|
||||
const table = app.database.getTable('tests');
|
||||
expect(table).toBeDefined();
|
||||
});
|
||||
|
||||
it('create table without name', async () => {
|
||||
const created = await agent.resource('collections').create({
|
||||
values: {
|
||||
title: 'tests',
|
||||
},
|
||||
});
|
||||
|
||||
const { name } = created.body;
|
||||
const table = app.database.getTable(name);
|
||||
expect(table).toBeDefined();
|
||||
expect(table.getOptions().title).toBe('tests');
|
||||
|
||||
const list = await agent.resource('collections').list();
|
||||
expect(list.body.rows.length).toBe(1);
|
||||
|
||||
await table.getModel().drop();
|
||||
});
|
||||
|
||||
it('list fields', async () => {
|
||||
const response = await agent.resource('collections.fields').list({
|
||||
associatedKey: 'tests',
|
||||
// values: {
|
||||
// type: 'string',
|
||||
// name: 'title',
|
||||
// title: '标题',
|
||||
// },
|
||||
});
|
||||
// console.log(response.body);
|
||||
});
|
||||
|
||||
it('create field', async () => {
|
||||
await agent.resource('collections').create({
|
||||
values: {
|
||||
name: 'tests',
|
||||
title: 'tests',
|
||||
},
|
||||
});
|
||||
|
||||
await agent.resource('collections.fields').create({
|
||||
associatedKey: 'tests',
|
||||
values: {
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
options: {
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const table = app.database.getTable('tests');
|
||||
expect(table.getField('name')).toBeDefined();
|
||||
|
||||
const { body } = await agent.resource('tests').create({
|
||||
values: { name: 'a' }
|
||||
});
|
||||
|
||||
expect(body.name).toBe('a');
|
||||
});
|
||||
|
||||
it('create field without name', async () => {
|
||||
await agent.resource('collections').create({
|
||||
values: {
|
||||
name: 'tests',
|
||||
title: 'tests',
|
||||
},
|
||||
});
|
||||
|
||||
const createdField = await agent.resource('collections.fields').create({
|
||||
associatedKey: 'tests',
|
||||
values: {
|
||||
type: 'string',
|
||||
},
|
||||
});
|
||||
const { name: createdFieldName } = createdField.body;
|
||||
|
||||
const table = app.database.getTable('tests');
|
||||
expect(table.getField(createdFieldName)).toBeDefined();
|
||||
|
||||
const createdRow = await agent.resource('tests').create({
|
||||
values: { [createdFieldName]: 'a' }
|
||||
});
|
||||
|
||||
expect(createdRow.body[createdFieldName]).toBe('a');
|
||||
});
|
||||
|
||||
it('create string field by interface', async () => {
|
||||
await agent.resource('collections').create({
|
||||
values: {
|
||||
name: 'tests',
|
||||
title: 'tests',
|
||||
},
|
||||
});
|
||||
|
||||
const values = {
|
||||
interface: 'string',
|
||||
title: '名称',
|
||||
name: 'name',
|
||||
required: true,
|
||||
viewable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
'component.tooltip': 'test'
|
||||
}
|
||||
|
||||
const createdField = await agent.resource('collections.fields').create({
|
||||
associatedKey: 'tests',
|
||||
values,
|
||||
});
|
||||
|
||||
expect(createdField.body).toMatchObject({
|
||||
...{
|
||||
interface: 'string',
|
||||
title: '名称',
|
||||
name: 'name',
|
||||
required: true,
|
||||
viewable: true,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
},
|
||||
...types['string'].options,
|
||||
sort: 1,
|
||||
collection_name: 'tests',
|
||||
});
|
||||
|
||||
const gotField = await agent.resource('fields').get({
|
||||
resourceKey: createdField.body.id
|
||||
});
|
||||
|
||||
expect(gotField.body).toEqual(createdField.body);
|
||||
});
|
||||
});
|
144
packages/plugin-collections/src/__tests__/index.ts
Normal file
144
packages/plugin-collections/src/__tests__/index.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import qs from 'qs';
|
||||
import plugin from '../server';
|
||||
import supertest from 'supertest';
|
||||
import bodyParser from 'koa-bodyparser';
|
||||
import { Dialect } from 'sequelize';
|
||||
import Database from '@nocobase/database';
|
||||
import { actions, middlewares } from '@nocobase/actions';
|
||||
import { Application, middleware } from '@nocobase/server';
|
||||
|
||||
function getTestKey() {
|
||||
const { id } = require.main;
|
||||
const key = id
|
||||
.replace(`${process.env.PWD}/packages`, '')
|
||||
.replace('.test.ts', '')
|
||||
.replace(/[^\w]/g, '_')
|
||||
.replace(/_+/g, '_');
|
||||
return key
|
||||
}
|
||||
|
||||
const config = {
|
||||
username: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_DATABASE,
|
||||
host: process.env.DB_HOST,
|
||||
port: Number.parseInt(process.env.DB_PORT, 10),
|
||||
dialect: process.env.DB_DIALECT as Dialect,
|
||||
define: {
|
||||
hooks: {
|
||||
beforeCreate(model, options) {
|
||||
|
||||
},
|
||||
},
|
||||
},
|
||||
logging: false,
|
||||
sync: {
|
||||
force: true,
|
||||
alter: {
|
||||
drop: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function getApp() {
|
||||
const app = new Application({
|
||||
database: {
|
||||
...config,
|
||||
hooks: {
|
||||
beforeDefine(columns, model) {
|
||||
model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`;
|
||||
}
|
||||
},
|
||||
},
|
||||
resourcer: {
|
||||
prefix: '/api',
|
||||
},
|
||||
});
|
||||
app.resourcer.use(middlewares.associated);
|
||||
app.resourcer.registerHandlers({...actions.associate, ...actions.common});
|
||||
await app.plugins([plugin]);
|
||||
await app.database.sync({
|
||||
force: true,
|
||||
});
|
||||
// 表配置信息存到数据库里
|
||||
// const tables = app.database.getTables([]);
|
||||
// for (const table of tables) {
|
||||
// const Collection = app.database.getModel('collections');
|
||||
// await Collection.import(table.getOptions(), { hooks: false });
|
||||
// }
|
||||
app.use(async (ctx, next) => {
|
||||
ctx.db = app.database;
|
||||
await next();
|
||||
});
|
||||
app.use(bodyParser());
|
||||
app.use(middleware({
|
||||
prefix: '/api',
|
||||
resourcer: app.resourcer,
|
||||
database: app.database,
|
||||
}));
|
||||
return app;
|
||||
}
|
||||
|
||||
interface ActionParams {
|
||||
resourceKey?: string | number;
|
||||
// resourceName?: string;
|
||||
// associatedName?: string;
|
||||
associatedKey?: string | number;
|
||||
fields?: any;
|
||||
filter?: any;
|
||||
values?: any;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface Handler {
|
||||
get: (params?: ActionParams) => Promise<supertest.Response>;
|
||||
list: (params?: ActionParams) => Promise<supertest.Response>;
|
||||
create: (params?: ActionParams) => Promise<supertest.Response>;
|
||||
update: (params?: ActionParams) => Promise<supertest.Response>;
|
||||
destroy: (params?: ActionParams) => Promise<supertest.Response>;
|
||||
[name: string]: (params?: ActionParams) => Promise<supertest.Response>;
|
||||
}
|
||||
|
||||
export interface Agent {
|
||||
resource: (name: string) => Handler;
|
||||
}
|
||||
|
||||
export function getAgent(app: Application): Agent {
|
||||
const agent = supertest.agent(app.callback());
|
||||
return {
|
||||
resource(name: string): any {
|
||||
return new Proxy({}, {
|
||||
get(target, method, receiver) {
|
||||
return (params: ActionParams = {}) => {
|
||||
const { associatedKey, resourceKey, values = {}, ...restParams } = params;
|
||||
let url = `/api/${name}`;
|
||||
if (associatedKey) {
|
||||
url = `/api/${name.split('.').join(`/${associatedKey}/`)}`;
|
||||
}
|
||||
url += `:${method as string}`;
|
||||
if (resourceKey) {
|
||||
url += `/${resourceKey}`;
|
||||
}
|
||||
console.log(url);
|
||||
if (['list', 'get'].indexOf(method as string) !== -1) {
|
||||
return agent.get(`${url}?${qs.stringify(restParams)}`);
|
||||
} else {
|
||||
return agent.post(`${url}?${qs.stringify(restParams)}`).send(values);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function getDatabase() {
|
||||
return new Database({
|
||||
...config,
|
||||
hooks: {
|
||||
beforeDefine(columns, model) {
|
||||
model.tableName = `${getTestKey()}_${model.tableName || model.name.plural}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
@ -3,8 +3,11 @@ import { TableOptions } from '@nocobase/database';
|
||||
export default {
|
||||
name: 'actions',
|
||||
title: '操作配置',
|
||||
draggable: true,
|
||||
model: 'ActionModel',
|
||||
fields: [
|
||||
{
|
||||
interface: 'sort',
|
||||
type: 'integer',
|
||||
name: 'sort',
|
||||
title: '排序',
|
||||
@ -12,36 +15,66 @@ export default {
|
||||
type: 'sort',
|
||||
className: 'drag-visible',
|
||||
width: 60,
|
||||
showInTable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
title: '名称',
|
||||
component: {
|
||||
type: 'string',
|
||||
className: 'drag-visible',
|
||||
showInForm: true,
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
title: '标识',
|
||||
component: {
|
||||
type: 'string',
|
||||
showInForm: true,
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'type',
|
||||
title: '类型',
|
||||
component: {
|
||||
type: 'string',
|
||||
className: 'drag-visible',
|
||||
showInForm: true,
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
title: '标识',
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
title: '名称',
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
type: 'belongsTo',
|
||||
name: 'collection',
|
||||
title: '所属数据表',
|
||||
target: 'collections',
|
||||
targetKey: 'name',
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'json',
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
title: '配置信息',
|
||||
defaultValue: {},
|
||||
component: {
|
||||
type: 'hidden',
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
|
@ -1,12 +1,14 @@
|
||||
import { TableOptions } from '@nocobase/database';
|
||||
import CollectionModel from '../models/collection';
|
||||
|
||||
export default {
|
||||
name: 'collections',
|
||||
title: '数据表配置',
|
||||
model: CollectionModel,
|
||||
sortable: true,
|
||||
draggable: true,
|
||||
model: 'CollectionModel',
|
||||
fields: [
|
||||
{
|
||||
interface: 'sort',
|
||||
type: 'integer',
|
||||
name: 'sort',
|
||||
title: '排序',
|
||||
@ -14,20 +16,25 @@ export default {
|
||||
type: 'sort',
|
||||
className: 'drag-visible',
|
||||
width: 60,
|
||||
showInTable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
title: '名称',
|
||||
showInTable: true,
|
||||
title: '数据表名称',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'string',
|
||||
className: 'drag-visible',
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
title: '标识',
|
||||
@ -35,65 +42,175 @@ export default {
|
||||
required: true,
|
||||
component: {
|
||||
type: 'string',
|
||||
'x-rules': [
|
||||
{
|
||||
format: 'slug',
|
||||
message: '只允许英文数字和下划线',
|
||||
},
|
||||
],
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'description',
|
||||
title: '描述',
|
||||
interface: 'string',
|
||||
type: 'virtual',
|
||||
name: 'options.icon',
|
||||
title: '图标',
|
||||
component: {
|
||||
type: 'textarea',
|
||||
type: 'string',
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'radio',
|
||||
type: 'virtual',
|
||||
name: 'options.defaultView',
|
||||
title: '默认视图',
|
||||
defaultValue: 'table',
|
||||
dataSource: [
|
||||
{label: '表格', value: 'table'},
|
||||
{label: '看板', value: 'kanban', disabled: true},
|
||||
{label: '日历', value: 'calendar', disabled: true},
|
||||
],
|
||||
component: {
|
||||
type: 'radio',
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'radio',
|
||||
type: 'virtual',
|
||||
name: 'options.mode',
|
||||
title: '表格模式',
|
||||
defaultValue: 'default',
|
||||
dataSource: [
|
||||
{label: '常规模式', value: 'default'},
|
||||
{label: '简易模式', value: 'simple'},
|
||||
],
|
||||
component: {
|
||||
type: 'radio',
|
||||
tooltip: `
|
||||
<p>常规模式:点击数据进入详情页进行各项查看和操作;<br/>简易模式:点击数据直接打开编辑表单</p>
|
||||
`,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'radio',
|
||||
type: 'virtual',
|
||||
name: 'options.defaultPerPage',
|
||||
title: '每页显示几行数据',
|
||||
defaultValue: 50,
|
||||
dataSource: [
|
||||
{label: '20', value: 20},
|
||||
{label: '50', value: 50},
|
||||
{label: '100', value: 100},
|
||||
],
|
||||
component: {
|
||||
type: 'radio',
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'boolean',
|
||||
type: 'virtual',
|
||||
name: 'options.draggable',
|
||||
title: '支持拖拽数据排序',
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
name: 'showInDataMenu',
|
||||
title: '显示在“数据”菜单里',
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'json',
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
title: '配置信息',
|
||||
defaultValue: {},
|
||||
component: {
|
||||
type: 'hidden',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
type: 'hasMany',
|
||||
name: 'fields',
|
||||
title: '字段',
|
||||
sourceKey: 'name',
|
||||
draggable: true,
|
||||
actions: {
|
||||
list: {
|
||||
sort: 'sort',
|
||||
},
|
||||
},
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
type: 'hasMany',
|
||||
name: 'actions',
|
||||
title: '动作',
|
||||
sourceKey: 'name',
|
||||
draggable: true,
|
||||
actions: {
|
||||
list: {
|
||||
sort: 'sort',
|
||||
},
|
||||
},
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
type: 'hasMany',
|
||||
name: 'tabs',
|
||||
title: '标签页',
|
||||
sourceKey: 'name',
|
||||
draggable: true,
|
||||
actions: {
|
||||
list: {
|
||||
sort: 'sort',
|
||||
},
|
||||
},
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
type: 'hasMany',
|
||||
name: 'views',
|
||||
title: '视图',
|
||||
sourceKey: 'name',
|
||||
draggable: true,
|
||||
actions: {
|
||||
list: {
|
||||
sort: 'sort',
|
||||
},
|
||||
},
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
|
@ -1,58 +1,230 @@
|
||||
import { TableOptions } from '@nocobase/database';
|
||||
import { options } from '../interfaces';
|
||||
|
||||
export default {
|
||||
name: 'fields',
|
||||
title: '字段配置',
|
||||
draggable: true,
|
||||
model: 'FieldModel',
|
||||
fields: [
|
||||
{
|
||||
interface: 'sort',
|
||||
type: 'integer',
|
||||
name: 'sort',
|
||||
title: '排序',
|
||||
defaultValue: 1,
|
||||
component: {
|
||||
type: 'sort',
|
||||
className: 'drag-visible',
|
||||
width: 60,
|
||||
showInTable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'type',
|
||||
title: '类型',
|
||||
name: 'title',
|
||||
title: '字段名称',
|
||||
component: {
|
||||
type: 'string',
|
||||
className: 'drag-visible',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
title: '标识',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'string',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'select',
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
title: '名称',
|
||||
name: 'interface',
|
||||
title: '字段类型',
|
||||
dataSource: options,
|
||||
component: {
|
||||
type: 'select',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'subTable',
|
||||
type: 'virtual',
|
||||
name: 'options.dataSource',
|
||||
title: '可选项',
|
||||
component: {
|
||||
type: 'table',
|
||||
// showInTable: true,
|
||||
// showInDetail: true,
|
||||
showInForm: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
value: {
|
||||
type: "string",
|
||||
title: "值",
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: "string",
|
||||
title: "选项",
|
||||
required: true
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'type',
|
||||
title: '数据类型',
|
||||
component: {
|
||||
type: 'string',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'number',
|
||||
type: 'integer',
|
||||
name: 'parent_id',
|
||||
title: '所属分组',
|
||||
component: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
multiple: false,
|
||||
type: 'belongsTo',
|
||||
name: 'parent',
|
||||
title: '所属分组',
|
||||
target: 'fields',
|
||||
foreignKey: 'parent_id',
|
||||
targetKey: 'id',
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
multiple: true,
|
||||
type: 'hasMany',
|
||||
name: 'children',
|
||||
title: '子字段',
|
||||
target: 'fields',
|
||||
foreignKey: 'parent_id',
|
||||
sourceKey: 'id',
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'virtual',
|
||||
name: 'component.tooltip',
|
||||
title: '提示信息',
|
||||
component: {
|
||||
type: 'string',
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
name: 'showInListAction',
|
||||
title: '显示在表格里',
|
||||
name: 'required',
|
||||
title: '必填项',
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'boolean',
|
||||
name: 'showInGetAction',
|
||||
title: '显示在详情里',
|
||||
interface: 'boolean',
|
||||
type: 'virtual',
|
||||
name: 'component.showInTable',
|
||||
title: '显示在表格中',
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
tooltip: '若勾选,该字段将作为一列显示在数据表里',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
interface: 'boolean',
|
||||
type: 'virtual',
|
||||
name: 'component.showInForm',
|
||||
title: '显示在表单中',
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
tooltip: '若勾选,该字段将出现在表单中',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'boolean',
|
||||
type: 'virtual',
|
||||
name: 'component.showInDetail',
|
||||
title: '显示在详情中',
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
tooltip: '若勾选,该字段将出现在详情中',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
type: 'belongsTo',
|
||||
name: 'collection',
|
||||
title: '所属数据表',
|
||||
target: 'collections',
|
||||
targetKey: 'name',
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'json',
|
||||
type: 'json',
|
||||
name: 'component',
|
||||
title: '前端组件',
|
||||
defaultValue: {},
|
||||
component: {
|
||||
type: 'hidden',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'json',
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
title: '配置信息',
|
||||
defaultValue: {},
|
||||
component: {
|
||||
type: 'hidden',
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
|
@ -3,8 +3,11 @@ import { TableOptions } from '@nocobase/database';
|
||||
export default {
|
||||
name: 'tabs',
|
||||
title: '标签配置',
|
||||
sortable: true,
|
||||
model: 'TabModel',
|
||||
fields: [
|
||||
{
|
||||
interface: 'sort',
|
||||
type: 'integer',
|
||||
name: 'sort',
|
||||
title: '排序',
|
||||
@ -12,42 +15,108 @@ export default {
|
||||
type: 'sort',
|
||||
className: 'drag-visible',
|
||||
width: 60,
|
||||
showInTable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'type',
|
||||
title: '类型',
|
||||
component: {
|
||||
type: 'string',
|
||||
className: 'drag-visible',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
title: '标识',
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
title: '名称',
|
||||
component: {
|
||||
type: 'string',
|
||||
className: 'drag-visible',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
title: '标识',
|
||||
component: {
|
||||
type: 'string',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'radio',
|
||||
type: 'string',
|
||||
name: 'type',
|
||||
title: '类型',
|
||||
dataSource: [
|
||||
{ label: '详情数据', value: 'details' },
|
||||
{ label: '相关数据', value: 'association' },
|
||||
{ label: '模块组合', value: 'module' },
|
||||
],
|
||||
component: {
|
||||
type: 'radio',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'virtual',
|
||||
name: 'options.association',
|
||||
title: '相关数据表',
|
||||
component: {
|
||||
type: 'string',
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
name: 'default',
|
||||
title: '默认标签页',
|
||||
defaultValue: false,
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
name: 'enabled',
|
||||
title: '启动',
|
||||
defaultValue: false,
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
type: 'belongsTo',
|
||||
name: 'collection',
|
||||
title: '所属数据表',
|
||||
target: 'collections',
|
||||
targetKey: 'name',
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'json',
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
title: '配置信息',
|
||||
defaultValue: {},
|
||||
component: {
|
||||
type: 'hidden',
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
|
@ -3,8 +3,11 @@ import { TableOptions } from '@nocobase/database';
|
||||
export default {
|
||||
name: 'views',
|
||||
title: '视图配置',
|
||||
sortable: true,
|
||||
model: 'ViewModel',
|
||||
fields: [
|
||||
{
|
||||
interface: 'sort',
|
||||
type: 'integer',
|
||||
name: 'sort',
|
||||
title: '排序',
|
||||
@ -12,56 +15,126 @@ export default {
|
||||
type: 'sort',
|
||||
className: 'drag-visible',
|
||||
width: 60,
|
||||
showInTable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'type',
|
||||
title: '类型',
|
||||
name: 'title',
|
||||
title: '视图名称',
|
||||
component: {
|
||||
type: 'string',
|
||||
className: 'drag-visible',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'name',
|
||||
title: '标识',
|
||||
component: {
|
||||
type: 'string',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'radio',
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
title: '名称',
|
||||
name: 'type',
|
||||
title: '视图类型',
|
||||
dataSource: [
|
||||
{ label: '表格', value: 'table' },
|
||||
{ label: '看板', value: 'kanban', disabled: true },
|
||||
{ label: '日历', value: 'calendar', disabled: true },
|
||||
{ label: '地图', value: 'map', disabled: true },
|
||||
],
|
||||
component: {
|
||||
type: 'radio',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'template',
|
||||
title: '模板',
|
||||
component: {
|
||||
type: 'string',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
name: 'default',
|
||||
title: '默认视图',
|
||||
defaultValue: false,
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
name: 'showInDataMenu',
|
||||
title: '作为数据表子菜单',
|
||||
defaultValue: false,
|
||||
component: {
|
||||
type: 'checkbox',
|
||||
showInTable: true,
|
||||
showInDetail: true,
|
||||
showInForm: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
type: 'belongsTo',
|
||||
name: 'collection',
|
||||
title: '所属数据表',
|
||||
target: 'collections',
|
||||
targetKey: 'name',
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'belongsToMany',
|
||||
name: 'fields',
|
||||
},
|
||||
{
|
||||
type: 'belongsToMany',
|
||||
name: 'actions',
|
||||
interface: 'json',
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
title: '配置信息',
|
||||
defaultValue: {},
|
||||
component: {
|
||||
type: 'hidden',
|
||||
},
|
||||
},
|
||||
// 以下暂不考虑
|
||||
// {
|
||||
// type: 'belongsToMany',
|
||||
// name: 'fields',
|
||||
// component: {
|
||||
// type: 'drawerSelect',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// type: 'belongsToMany',
|
||||
// name: 'actions',
|
||||
// component: {
|
||||
// type: 'drawerSelect',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
|
@ -0,0 +1,5 @@
|
||||
import CollectionModel from '../models/collection';
|
||||
|
||||
export default async function (model: CollectionModel) {
|
||||
await model.migrate();
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
import CollectionModel from '../models/collection';
|
||||
|
||||
export default async function (model: CollectionModel) {
|
||||
if (!model.get('name')) {
|
||||
model.setDataValue('name', this.generateName());
|
||||
}
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
import FieldModel from '../models/field';
|
||||
|
||||
export default async function (model: FieldModel) {
|
||||
// console.log('afterCreate', model.toJSON());
|
||||
await model.migrate();
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import FieldModel from '../models/field';
|
||||
import * as types from '../interfaces/types';
|
||||
|
||||
export default async function (model: FieldModel) {
|
||||
const values = model.get();
|
||||
if (!values.name) {
|
||||
values.name = this.generateName();
|
||||
}
|
||||
if (values.interface) {
|
||||
const { options } = types[values.interface];
|
||||
Object.keys(options).forEach(key => {
|
||||
switch (typeof values[key]) {
|
||||
case 'undefined':
|
||||
values[key] = options[key];
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
values[key] = {
|
||||
...options[key],
|
||||
...values[key]
|
||||
};
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
model.set(values, { raw: true });
|
||||
}
|
16
packages/plugin-collections/src/hooks/index.ts
Normal file
16
packages/plugin-collections/src/hooks/index.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import collectionsBeforeValidate from './collections-before-validate';
|
||||
import collectionsAfterCreate from './collections-after-create';
|
||||
|
||||
import fieldsBeforeValidate from './fields-before-validate';
|
||||
import fieldsAfterCreate from './fields-after-create';
|
||||
|
||||
export default {
|
||||
collections: {
|
||||
beforeValidate: collectionsBeforeValidate,
|
||||
afterCreate: collectionsAfterCreate,
|
||||
},
|
||||
fields: {
|
||||
beforeValidate: fieldsBeforeValidate,
|
||||
afterCreate: fieldsAfterCreate
|
||||
}
|
||||
};
|
76
packages/plugin-collections/src/interfaces/index.ts
Normal file
76
packages/plugin-collections/src/interfaces/index.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 考虑到 Interface 的参数模板还不固定,暂时先放这里了,便于后续修改
|
||||
*/
|
||||
import * as types from './types';
|
||||
export * as types from './types';
|
||||
|
||||
export const options = [
|
||||
{
|
||||
title: '基本类型',
|
||||
children: [
|
||||
types.string,
|
||||
types.textarea,
|
||||
types.phone,
|
||||
types.email,
|
||||
types.number,
|
||||
types.percent,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '多媒体类型',
|
||||
children: [
|
||||
types.wysiwyg,
|
||||
types.attachment,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '选择类型',
|
||||
children: [
|
||||
types.boolean,
|
||||
types.select,
|
||||
types.multipleSelect,
|
||||
types.radio,
|
||||
types.checkboxes,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '日期和时间',
|
||||
children: [
|
||||
types.datetime,
|
||||
types.time,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '关系类型',
|
||||
children: [
|
||||
types.subTable,
|
||||
types.linkTo,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '系统信息',
|
||||
children: [
|
||||
types.createdAt,
|
||||
types.createdBy,
|
||||
types.updatedAt,
|
||||
types.updatedBy,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '开发者模式',
|
||||
children: [
|
||||
types.primaryKey,
|
||||
types.sort,
|
||||
types.password,
|
||||
types.json,
|
||||
],
|
||||
}
|
||||
].map(({title, children}) => ({
|
||||
label: title,
|
||||
children: children.map(child => ({
|
||||
label: child.title,
|
||||
value: child.options.interface,
|
||||
})),
|
||||
}));
|
||||
|
||||
export default options;
|
460
packages/plugin-collections/src/interfaces/types.ts
Normal file
460
packages/plugin-collections/src/interfaces/types.ts
Normal file
@ -0,0 +1,460 @@
|
||||
// merge:interface 模板,旧数据,用户数据
|
||||
// TODO: 删除的情况怎么处理
|
||||
// 联动的原则:尽量减少干预,尤其是尽量少改动 type,type 兼容
|
||||
// 参数的优先级:
|
||||
// 1、interface,type 尽量只随 interface 变动,而不受别的字段影响(特殊情况除外)
|
||||
// 2、
|
||||
// TODO: interface 的修改
|
||||
export const string = {
|
||||
title: '单行文本',
|
||||
options: {
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
component: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const textarea = {
|
||||
title: '多行文本',
|
||||
options: {
|
||||
interface: 'textarea',
|
||||
type: 'text',
|
||||
filterable: true,
|
||||
component: {
|
||||
type: 'textarea',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export const phone = {
|
||||
title: '手机号码',
|
||||
options: {
|
||||
interface: 'phone',
|
||||
type: 'string',
|
||||
filterable: true,
|
||||
format: 'phone', // 验证的问题
|
||||
component: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const email = {
|
||||
title: '邮箱',
|
||||
options: {
|
||||
interface: 'email',
|
||||
type: 'string',
|
||||
filterable: true,
|
||||
format: 'email',
|
||||
component: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 通过 precision 控制精确度
|
||||
*/
|
||||
export const number = {
|
||||
title: '数字',
|
||||
options: {
|
||||
interface: 'number',
|
||||
type: 'integer',
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
precision: 0, // 需要考虑
|
||||
component: {
|
||||
type: 'number',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 通过 precision 控制精确度
|
||||
* 百分比转化是前端处理还是后端处理
|
||||
*/
|
||||
export const percent = {
|
||||
title: '百分比',
|
||||
options: {
|
||||
interface: 'percent',
|
||||
type: 'integer',
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
precision: 0,
|
||||
component: {
|
||||
type: 'number',
|
||||
suffix: '%',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const wysiwyg = {
|
||||
title: '可视化编辑器',
|
||||
options: {
|
||||
interface: 'wysiwyg',
|
||||
type: 'text',
|
||||
component: {
|
||||
type: 'wysiwyg',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 特殊的关系字段
|
||||
*/
|
||||
export const attachment = {
|
||||
title: '附件',
|
||||
options: {
|
||||
interface: 'attachment',
|
||||
type: 'belongsToMany',
|
||||
filterable: true,
|
||||
target: 'attachments',
|
||||
component: {
|
||||
type: 'fileManager',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
export const select = {
|
||||
title: '下拉选择(单选)',
|
||||
options: {
|
||||
interface: 'select',
|
||||
type: 'string',
|
||||
filterable: true,
|
||||
dataSource: [],
|
||||
component: {
|
||||
type: 'select',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* type 怎么处理
|
||||
* 暂时 json 处理
|
||||
* 后续:扩展 type=array 的字段
|
||||
* array 的情况怎么兼容
|
||||
* filter 要处理
|
||||
* 不能处理 json 搜索的数据库可以用 hasMany 转化
|
||||
*
|
||||
* 思考:🤔 如果 select合并成一个 interface,multiple 会影响 type
|
||||
*/
|
||||
export const multipleSelect = {
|
||||
title: '下拉选择(多选)',
|
||||
options: {
|
||||
interface: 'multipleSelect',
|
||||
type: 'json', // json 过滤
|
||||
filterable: true,
|
||||
dataSource: [],
|
||||
multiple: true, // 需要重点考虑
|
||||
component: {
|
||||
type: 'select',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const radio = {
|
||||
title: '单选框',
|
||||
options: {
|
||||
interface: 'radio',
|
||||
type: 'string',
|
||||
filterable: true,
|
||||
dataSource: [],
|
||||
component: {
|
||||
type: 'radio',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const checkboxes = {
|
||||
title: '多选框',
|
||||
options: {
|
||||
interface: 'checkboxes',
|
||||
type: 'json',
|
||||
filterable: true,
|
||||
dataSource: [],
|
||||
component: {
|
||||
type: 'checkboxes',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const boolean = {
|
||||
title: '是/否',
|
||||
options: {
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
filterable: true,
|
||||
component: {
|
||||
type: 'checkbox', // switch
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* dateonly 要不要变 type
|
||||
* 如果是 dateonly 时间怎么办?
|
||||
*/
|
||||
export const datetime = {
|
||||
title: '日期',
|
||||
options: {
|
||||
interface: 'datetime',
|
||||
type: 'date',
|
||||
dateonly: false, // dateonly
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
format: 'YYYY-MM-DD HH:mm:ss',
|
||||
component: {
|
||||
type: 'date',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const time = {
|
||||
title: '时间',
|
||||
options: {
|
||||
interface: 'time',
|
||||
type: 'time',
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
format: 'HH:mm:ss',
|
||||
component: {
|
||||
type: 'time',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 重点:
|
||||
* 初始化子表和子字段
|
||||
* hasMany 相关的设置参数
|
||||
* fields 是子字段
|
||||
*
|
||||
* 分组字段 - virtual:不考虑字段分组
|
||||
* 子表格 - hasMany
|
||||
* - 子字段只属于子表格字段关联的表(target),不属于当前表(source)
|
||||
*/
|
||||
// database.table({
|
||||
// name: 'tablename',
|
||||
// fields: [
|
||||
// {
|
||||
// type: 'hasMany',
|
||||
// name: 'foos',
|
||||
// target: 'foos',
|
||||
// fields: [
|
||||
// {
|
||||
// type: 'string',
|
||||
// name: 'xxx',
|
||||
// }
|
||||
// ],
|
||||
// }
|
||||
// ],
|
||||
// });
|
||||
// database.table({
|
||||
// name: 'foos',
|
||||
// fields: [
|
||||
// {
|
||||
// type: 'string',
|
||||
// name: 'xxx',
|
||||
// }
|
||||
// ],
|
||||
// });
|
||||
export const subTable = {
|
||||
title: '子表格',
|
||||
options: {
|
||||
interface: 'subTable',
|
||||
type: 'hasMany',
|
||||
// fields: [],
|
||||
component: {
|
||||
type: 'subTable',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 尽量减少更新 multiple 造成的影响
|
||||
* 同步生成配对的关系字段
|
||||
*
|
||||
* 只传 name 没有 target,可以通过 addField 处理,找到 target
|
||||
* 没有 name 但是有 target,name 随机生成
|
||||
* 有 name 也有 target
|
||||
*/
|
||||
|
||||
// database.table({
|
||||
// name: 'foos',
|
||||
// fields: [
|
||||
// {
|
||||
// type: 'hasMany',
|
||||
// name: 'bars',
|
||||
// // target: 'bars',
|
||||
// // sourceKey: 'id',
|
||||
// // foreignKey: 'foo_id',
|
||||
// },
|
||||
// {
|
||||
// type: 'hasMany',
|
||||
// name: 'xxxxx', // 如果没有随机生成
|
||||
// target: 'bars',
|
||||
// // sourceKey: 'id',
|
||||
// // foreignKey: 'foo_id',
|
||||
// },
|
||||
// {
|
||||
// type: 'hasMany',
|
||||
// name: 'xxxxx', // 如果没有随机生成
|
||||
// target: 'bars',
|
||||
// sourceKey: 'id',
|
||||
// foreignKey: 'foo_id',
|
||||
// }
|
||||
// ],
|
||||
// });
|
||||
|
||||
// const field = table.addField({
|
||||
// type: 'hasMany',
|
||||
// name: 'xxx', // xxx
|
||||
// });
|
||||
|
||||
export const linkTo = {
|
||||
title: '关联数据',
|
||||
options: {
|
||||
interface: 'linkTo',
|
||||
multiple: true, // 可能影响 type
|
||||
type: 'belongsToMany',
|
||||
// name,
|
||||
// target: '关联表', // 用户会输入
|
||||
filterable: true,
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createdBy = {
|
||||
title: '创建者',
|
||||
options: {
|
||||
interface: 'createdBy',
|
||||
type: 'belongsTo',
|
||||
filterable: true,
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const createdAt = {
|
||||
title: '创建时间',
|
||||
options: {
|
||||
interface: 'createdAt',
|
||||
type: 'date',
|
||||
required: true,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
component: {
|
||||
type: 'date',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updatedBy = {
|
||||
title: '更新人',
|
||||
options: {
|
||||
interface: 'updatedBy',
|
||||
type: 'belongsTo',
|
||||
filterable: true,
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const updatedAt = {
|
||||
title: '更新日期',
|
||||
options: {
|
||||
interface: 'updatedAt',
|
||||
type: 'date',
|
||||
required: true,
|
||||
filterable: true,
|
||||
sortable: true,
|
||||
component: {
|
||||
type: 'date',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 字段分组(暂缓)
|
||||
*
|
||||
* 影响数据输出结构,树形结构输出
|
||||
*/
|
||||
export const group = {
|
||||
title: '字段组',
|
||||
options: {
|
||||
interface: 'group',
|
||||
// name: 'id',
|
||||
type: 'virtual',
|
||||
component: {
|
||||
type: 'hidden',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 主键(暂缓)
|
||||
*/
|
||||
export const primaryKey = {
|
||||
title: '主键',
|
||||
options: {
|
||||
interface: 'primaryKey',
|
||||
name: 'id',
|
||||
type: 'integer',
|
||||
required: true,
|
||||
autoIncrement: true,
|
||||
primaryKey: true,
|
||||
filterable: true,
|
||||
component: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 自增长
|
||||
* scope 的问题
|
||||
*/
|
||||
export const sort = {
|
||||
title: '排序',
|
||||
options: {
|
||||
interface: 'sort',
|
||||
type: 'integer',
|
||||
required: true,
|
||||
component: {
|
||||
type: 'sort',
|
||||
showInTable: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const password = {
|
||||
title: '密码',
|
||||
options: {
|
||||
interface: 'password',
|
||||
type: 'password',
|
||||
hidden: true, // hidden 用来控制 api 不输出这个字段,但是可能这个字段显示在表单里 showInForm
|
||||
component: {
|
||||
type: 'password',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const json = {
|
||||
title: 'JSON',
|
||||
options: {
|
||||
interface: 'json',
|
||||
type: 'json',
|
||||
component: {
|
||||
type: 'hidden',
|
||||
},
|
||||
},
|
||||
};
|
6
packages/plugin-collections/src/models/action.ts
Normal file
6
packages/plugin-collections/src/models/action.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
import BaseModel from './base';
|
||||
|
||||
export class ActionModel extends BaseModel {
|
||||
|
||||
}
|
102
packages/plugin-collections/src/models/base.ts
Normal file
102
packages/plugin-collections/src/models/base.ts
Normal file
@ -0,0 +1,102 @@
|
||||
import _ from 'lodash';
|
||||
import { Model } from '@nocobase/database';
|
||||
|
||||
export class BaseModel extends Model {
|
||||
get additionalAttribute() {
|
||||
const tableOptions = this.database.getTable(this.constructor.name).getOptions();
|
||||
return _.get(tableOptions, 'additionalAttribute') || 'options';
|
||||
}
|
||||
|
||||
get(key?: any, options?: any) {
|
||||
if (typeof key === 'string') {
|
||||
const [column, ...path] = key.split('.');
|
||||
const attribute = this.rawAttributes[column];
|
||||
if (attribute) {
|
||||
const value = super.get(column, options);
|
||||
if (path.length) {
|
||||
return _.get(value, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return _.get(super.get(this.additionalAttribute, options) || {}, key);
|
||||
}
|
||||
const data = super.get();
|
||||
return {
|
||||
...(data[this.additionalAttribute]||{}),
|
||||
..._.omit(data, [this.additionalAttribute]),
|
||||
};
|
||||
}
|
||||
|
||||
getDataValue(key: any) {
|
||||
const [column, ...path] = key.split('.');
|
||||
const attribute = this.rawAttributes[column];
|
||||
if (attribute) {
|
||||
const value = super.getDataValue(column);
|
||||
if (path.length) {
|
||||
return _.get(value, path);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
const options = super.getDataValue(this.additionalAttribute) || {};
|
||||
return _.get(options, key);
|
||||
}
|
||||
|
||||
set(key?: any, value?: any, options?: any) {
|
||||
if (typeof key === 'string') {
|
||||
// 不处理关系数据
|
||||
// @ts-ignore
|
||||
if (_.get(this.constructor.associations, key)) {
|
||||
return this;
|
||||
}
|
||||
// 如果是 object 数据,merge 处理
|
||||
if (_.isPlainObject(value)) {
|
||||
value = _.merge(this.get(key)||{}, value);
|
||||
}
|
||||
const [column, ...path] = key.split('.');
|
||||
this.changed(column, true);
|
||||
const attribute = this.rawAttributes[column];
|
||||
if (attribute) {
|
||||
if (!path.length) {
|
||||
return super.set(key, value, options);
|
||||
}
|
||||
const values = this.get(column, options) || {};
|
||||
_.set(values, path, value);
|
||||
return super.set(column, values, options);
|
||||
}
|
||||
// 如果未设置 attribute,存到 additionalAttribute 里
|
||||
const opts = this.get(this.additionalAttribute, options) || {};
|
||||
_.set(opts, key, value);
|
||||
this.changed(this.additionalAttribute, true);
|
||||
return super.set(this.additionalAttribute, opts, options);
|
||||
}
|
||||
return super.set(key, value, options);
|
||||
}
|
||||
|
||||
setDataValue(key: any, value: any) {
|
||||
// 不处理关系数据
|
||||
// @ts-ignore
|
||||
if (_.get(this.constructor.associations, key)) {
|
||||
return this;
|
||||
}
|
||||
if (_.isPlainObject(value)) {
|
||||
value = _.merge(this.get(key)||{}, value);
|
||||
}
|
||||
const [column, ...path] = key.split('.');
|
||||
this.changed(column, true);
|
||||
const attribute = this.rawAttributes[column];
|
||||
if (attribute) {
|
||||
if (!path.length) {
|
||||
return super.setDataValue(key, value);
|
||||
}
|
||||
const values = this.get(column) || {};
|
||||
_.set(values, path, value);
|
||||
return super.setDataValue(column, values);
|
||||
}
|
||||
const opts = this.get(this.additionalAttribute) || {};
|
||||
_.set(opts, key, value);
|
||||
this.changed(this.additionalAttribute, true);
|
||||
return super.setDataValue(this.additionalAttribute, opts);
|
||||
}
|
||||
}
|
||||
|
||||
export default BaseModel;
|
@ -1,22 +1,92 @@
|
||||
import { Model } from '@nocobase/database';
|
||||
import _ from 'lodash';
|
||||
import BaseModel from './base';
|
||||
import { TableOptions } from '@nocobase/database';
|
||||
import { SaveOptions, Utils } from 'sequelize';
|
||||
|
||||
export class CollectionModel extends Model {
|
||||
async modelInit() {
|
||||
if (['collections', 'fields'].includes(this.get('name'))) {
|
||||
return;
|
||||
}
|
||||
const Field = this.database.getModel('fields');
|
||||
const fields = await Field.findAll();
|
||||
this.database.table({
|
||||
name: this.get('name'),
|
||||
fields: fields.map(field => {
|
||||
return {
|
||||
name: field.get('name'),
|
||||
type: field.get('type'),
|
||||
};
|
||||
}),
|
||||
export class CollectionModel extends BaseModel {
|
||||
|
||||
/**
|
||||
* 通过 name 获取 collection
|
||||
*
|
||||
* @param name
|
||||
*/
|
||||
static async findByName(name: string) {
|
||||
return this.findOne({ where: { name } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成随机数据库表名
|
||||
*
|
||||
* 策略:暂时使用 3+2
|
||||
* 1. 自增 id
|
||||
* 2. 随机字母
|
||||
* 3. 时间戳
|
||||
* 4. 转拼音
|
||||
* 5. 常见词翻译
|
||||
*
|
||||
* @param title 显示的名称
|
||||
*/
|
||||
static generateName(title?: string): string {
|
||||
return `t_${Date.now().toString(36)}_${Math.random().toString(36).replace('0.', '').slice(-4).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 迁移
|
||||
*/
|
||||
async migrate() {
|
||||
const options = await this.getOptions();
|
||||
const prevTable = this.database.getTable(this.get('name'));
|
||||
const prevOptions = prevTable ? prevTable.getOptions() : {};
|
||||
// table 是初始化和重新初始化
|
||||
const table = this.database.table({...prevOptions, ...options});
|
||||
return await table.sync({
|
||||
force: false,
|
||||
alter: {
|
||||
drop: false,
|
||||
}
|
||||
});
|
||||
console.log('modelInit', this.get('name'));
|
||||
}
|
||||
|
||||
async getFieldsOptions() {
|
||||
const fieldsOptions = [];
|
||||
const fields = await this.getFields();
|
||||
for (const field of fields) {
|
||||
fieldsOptions.push(await field.getOptions());
|
||||
}
|
||||
return fieldsOptions;
|
||||
}
|
||||
|
||||
async getOptions(): Promise<TableOptions> {
|
||||
return {
|
||||
...this.get('options'),
|
||||
name: this.get('name'),
|
||||
title: this.get('title'),
|
||||
fields: await this.getFieldsOptions(),
|
||||
};
|
||||
}
|
||||
|
||||
static async import(data: TableOptions, options: SaveOptions = {}): Promise<CollectionModel> {
|
||||
data = _.cloneDeep(data);
|
||||
const collection = await this.create({
|
||||
...data,
|
||||
}, options);
|
||||
const items: any = {};
|
||||
const associations = ['fields', 'tabs', 'actions', 'views'];
|
||||
for (const key of associations) {
|
||||
if (!Array.isArray(data[key])) {
|
||||
continue;
|
||||
}
|
||||
items[key] = data[key].map((item, sort) => ({
|
||||
...item,
|
||||
sort,
|
||||
}));
|
||||
for (const item of items[key]) {
|
||||
await collection[`create${_.upperFirst(Utils.singularize(key))}`](item);
|
||||
}
|
||||
}
|
||||
// updateAssociations 有 BUG
|
||||
// await collection.updateAssociations(items, options);
|
||||
return collection;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,30 @@
|
||||
import { Model } from '@nocobase/database';
|
||||
import _ from 'lodash';
|
||||
import BaseModel from './base';
|
||||
import { FieldOptions } from '@nocobase/database';
|
||||
|
||||
export class FieldModel extends Model {
|
||||
export class FieldModel extends BaseModel {
|
||||
static generateName(title?: string): string {
|
||||
return `f_${Math.random().toString(36).replace('0.', '').slice(-4).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
async getOptions(): Promise<FieldOptions> {
|
||||
return {
|
||||
...this.get('options'),
|
||||
type: this.get('type'),
|
||||
name: this.get('name'),
|
||||
};
|
||||
}
|
||||
|
||||
async migrate() {
|
||||
const table = this.database.getTable(this.get('collection_name'));
|
||||
table.addField(await this.getOptions());
|
||||
await table.sync({
|
||||
force: false,
|
||||
alter: {
|
||||
drop: false,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default FieldModel;
|
||||
|
5
packages/plugin-collections/src/models/index.ts
Normal file
5
packages/plugin-collections/src/models/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export * from './action';
|
||||
export * from './collection';
|
||||
export * from './field';
|
||||
export * from './tab';
|
||||
export * from './view';
|
6
packages/plugin-collections/src/models/tab.ts
Normal file
6
packages/plugin-collections/src/models/tab.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
import BaseModel from './base';
|
||||
|
||||
export class TabModel extends BaseModel {
|
||||
|
||||
}
|
6
packages/plugin-collections/src/models/view.ts
Normal file
6
packages/plugin-collections/src/models/view.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
import BaseModel from './base';
|
||||
|
||||
export class ViewModel extends BaseModel {
|
||||
|
||||
}
|
@ -1,12 +1,28 @@
|
||||
import path from 'path';
|
||||
import Database, { ModelCtor } from '@nocobase/database';
|
||||
import Resourcer from '@nocobase/resourcer';
|
||||
import { Application } from '@nocobase/server';
|
||||
import hooks from './hooks';
|
||||
import { registerModels } from '@nocobase/database';
|
||||
import * as models from './models';
|
||||
|
||||
export default async function (this: any, options = {}) {
|
||||
const database: Database = this.database;
|
||||
const resourcer: Resourcer = this.resourcer;
|
||||
export default async function (this: Application, options = {}) {
|
||||
const database = this.database;
|
||||
const resourcer = this.resourcer;
|
||||
// 提供全局的 models 注册机制
|
||||
registerModels(models);
|
||||
|
||||
database.import({
|
||||
directory: path.resolve(__dirname, 'collections'),
|
||||
});
|
||||
|
||||
Object.keys(hooks).forEach(modelName => {
|
||||
const Model = database.getModel(modelName);
|
||||
Object.keys(hooks[modelName]).forEach(hookKey => {
|
||||
// TODO(types): 多层 map 映射类型定义较为复杂,暂时忽略
|
||||
// @ts-ignore
|
||||
Model.addHook(hookKey, hooks[modelName][hookKey]);
|
||||
});
|
||||
});
|
||||
|
||||
// 加载数据库表 collections 中已经保存的表配置
|
||||
// await Collection.findAll();
|
||||
}
|
||||
|
@ -6,6 +6,9 @@ const transforms = {
|
||||
table: async (fields: Model[]) => {
|
||||
const arr = [];
|
||||
for (const field of fields) {
|
||||
if (!get(field.component, 'showInTable')) {
|
||||
continue;
|
||||
}
|
||||
arr.push({
|
||||
...field.toJSON(),
|
||||
...field.options,
|
||||
@ -17,9 +20,27 @@ const transforms = {
|
||||
form: async (fields: Model[]) => {
|
||||
const schema = {};
|
||||
for (const field of fields) {
|
||||
schema[field.name] = {
|
||||
type: 'string',
|
||||
if (!get(field.component, 'showInForm')) {
|
||||
continue;
|
||||
}
|
||||
const type = get(field.component, 'type', 'string');
|
||||
const prop: any = {
|
||||
type,
|
||||
title: field.title||field.name,
|
||||
...(field.component||{}),
|
||||
}
|
||||
if (type === 'select') {
|
||||
prop.type = 'string'
|
||||
}
|
||||
const defaultValue = get(field.options, 'defaultValue');
|
||||
if (defaultValue) {
|
||||
prop.default = defaultValue;
|
||||
}
|
||||
if (['radio', 'select', 'checkboxes'].includes(type)) {
|
||||
prop.enum = get(field.options, 'dataSource', []);
|
||||
}
|
||||
schema[field.name] = {
|
||||
...prop,
|
||||
};
|
||||
}
|
||||
return schema;
|
||||
@ -27,6 +48,9 @@ const transforms = {
|
||||
details: async (fields: Model[]) => {
|
||||
const arr = [];
|
||||
for (const field of fields) {
|
||||
if (!get(field.component, 'showInDetail')) {
|
||||
continue;
|
||||
}
|
||||
arr.push({
|
||||
...field.toJSON(),
|
||||
...field.options,
|
||||
|
@ -5,6 +5,7 @@ export default {
|
||||
title: '页面配置',
|
||||
fields: [
|
||||
{
|
||||
interface: 'sort',
|
||||
type: 'integer',
|
||||
name: 'sort',
|
||||
title: '排序',
|
||||
@ -12,66 +13,82 @@ export default {
|
||||
type: 'sort',
|
||||
className: 'drag-visible',
|
||||
width: 60,
|
||||
showInTable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'title',
|
||||
title: '名称',
|
||||
showInTable: true,
|
||||
isMainTitle: true,
|
||||
component: {
|
||||
type: 'string',
|
||||
className: 'drag-visible',
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'number',
|
||||
type: 'integer',
|
||||
name: 'parent_id',
|
||||
title: '父级页面',
|
||||
component: {
|
||||
type: 'number',
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'path',
|
||||
title: '路径',
|
||||
unique: true,
|
||||
showInTable: true,
|
||||
component: {
|
||||
type: 'string',
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'string',
|
||||
type: 'string',
|
||||
name: 'icon',
|
||||
title: '图标',
|
||||
component: {
|
||||
type: 'string',
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'select',
|
||||
type: 'string',
|
||||
name: 'type',
|
||||
title: '类型',
|
||||
showInTable: true,
|
||||
options: [
|
||||
{
|
||||
label: '页面',
|
||||
value: 'page',
|
||||
},
|
||||
{
|
||||
label: '布局',
|
||||
value: 'layout',
|
||||
},
|
||||
{
|
||||
label: '数据集',
|
||||
value: 'collection',
|
||||
},
|
||||
],
|
||||
component: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
{
|
||||
label: '页面',
|
||||
value: 'page',
|
||||
},
|
||||
{
|
||||
label: '布局',
|
||||
value: 'layout',
|
||||
},
|
||||
{
|
||||
label: '数据集',
|
||||
value: 'collection',
|
||||
},
|
||||
],
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
'x-linkages': [
|
||||
{
|
||||
"type": "value:visible",
|
||||
@ -82,85 +99,84 @@ export default {
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'select',
|
||||
type: 'string',
|
||||
name: 'collection',
|
||||
title: '属于哪种数据集?',
|
||||
component: {
|
||||
type: 'string',
|
||||
type: 'select',
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'select',
|
||||
type: 'string',
|
||||
name: 'template',
|
||||
title: '模板',
|
||||
showInTable: true,
|
||||
options: [
|
||||
{
|
||||
label: '顶部菜单布局',
|
||||
value: 'TopMenuLayout',
|
||||
},
|
||||
{
|
||||
label: '左侧菜单布局',
|
||||
value: 'SideMenuLayout',
|
||||
},
|
||||
],
|
||||
component: {
|
||||
type: 'string',
|
||||
enum: [
|
||||
{
|
||||
label: '顶部菜单布局',
|
||||
value: 'LayoutWithTopMenu',
|
||||
},
|
||||
{
|
||||
label: '左侧菜单布局',
|
||||
value: 'LayoutWithSideMenu',
|
||||
},
|
||||
{
|
||||
label: '数据集(全部)',
|
||||
value: 'collections',
|
||||
},
|
||||
{
|
||||
label: '数据集(某种)',
|
||||
value: 'collection',
|
||||
},
|
||||
{
|
||||
label: '登录',
|
||||
value: 'login',
|
||||
},
|
||||
{
|
||||
label: '注册',
|
||||
value: 'register',
|
||||
},
|
||||
{
|
||||
label: '分析页',
|
||||
value: 'analysis',
|
||||
},
|
||||
{
|
||||
label: '工作区',
|
||||
value: 'workplace',
|
||||
},
|
||||
],
|
||||
type: 'select',
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
name: 'showInMenu',
|
||||
title: '在菜单里显示',
|
||||
// showInTable: true,
|
||||
defaultValue: false,
|
||||
component: {
|
||||
type: 'boolean',
|
||||
type: 'checkbox',
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'boolean',
|
||||
type: 'boolean',
|
||||
name: 'inherit',
|
||||
title: '继承父级页面内容',
|
||||
defaultValue: true,
|
||||
component: {
|
||||
type: 'boolean',
|
||||
type: 'checkbox',
|
||||
showInTable: true,
|
||||
showInForm: true,
|
||||
showInDetail: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'linkTo',
|
||||
type: 'hasMany',
|
||||
name: 'children',
|
||||
title: '子页面',
|
||||
target: 'pages',
|
||||
foreignKey: 'parent_id',
|
||||
sourceKey: 'id',
|
||||
component: {
|
||||
type: 'drawerSelect',
|
||||
},
|
||||
},
|
||||
{
|
||||
interface: 'json',
|
||||
type: 'json',
|
||||
name: 'options',
|
||||
title: '元数据',
|
||||
component: {
|
||||
type: 'hidden',
|
||||
},
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
|
Loading…
Reference in New Issue
Block a user