diff --git a/package.json b/package.json index 6a6031f19..ae4ac7777 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "scripts": { "start": "cd packages/app && npm start", "db-migrate": "cd packages/app && npm run db-migrate", + "start:app:server": "cd packages/app && nodemon", "start-server": "yarn nodemon", "bootstrap": "lerna bootstrap --no-ci", "build": "npm run build-father-build && node packages/father-build/bin/father-build.js", diff --git a/packages/app/package.json b/packages/app/package.json index 67adc6448..3c5f31d70 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -29,6 +29,7 @@ "@nocobase/client": "^0.3.0-alpha.0", "@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-collections": "^0.3.0-alpha.0", "@nocobase/plugin-pages": "^0.3.0-alpha.0", "@nocobase/server": "^0.3.0-alpha.0", diff --git a/packages/app/src/api/app.ts b/packages/app/src/api/app.ts index de3d08065..43d281ad6 100644 --- a/packages/app/src/api/app.ts +++ b/packages/app/src/api/app.ts @@ -62,6 +62,7 @@ api.resourcer.registerActionHandlers({...actions.common, ...actions.associate}); // }); api.registerPlugin('plugin-collections', [path.resolve(__dirname, '../../../plugin-collections'), {}]); +api.registerPlugin('plugin-action-logs', [path.resolve(__dirname, '../../../plugin-action-logs'), {}]); api.registerPlugin('plugin-pages', [path.resolve(__dirname, '../../../plugin-pages'), {}]); api.registerPlugin('plugin-users', [path.resolve(__dirname, '../../../plugin-users'), {}]); api.registerPlugin('plugin-file-manager', [path.resolve(__dirname, '../../../plugin-file-manager'), {}]); diff --git a/packages/app/src/api/migrations/create-action-logs-table.ts b/packages/app/src/api/migrations/create-action-logs-table.ts new file mode 100644 index 000000000..bf10af056 --- /dev/null +++ b/packages/app/src/api/migrations/create-action-logs-table.ts @@ -0,0 +1,42 @@ +import api from '../app'; +import Database from '@nocobase/database'; + +(async () => { + await api.loadPlugins(); + const database: Database = api.database; + await api.database.sync({ + tables: ['actions_scopes', 'action_logs', 'action_changes'], + }); + + const [Collection, Page] = database.getModels(['collections', 'pages']); + + const tables = database.getTables(['actions_scopes', 'action_logs', 'action_changes']); + + for (let table of tables) { + console.log(table.getName()); + await Collection.import(table.getOptions(), { update: true, migrate: false }); + } + + await Page.import({ + title: '动态', + type: 'layout', + path: '/activity', + icon: 'NotificationOutlined', + template: 'SideMenuLayout', + sort: 85, + showInMenu: true, + parent_id: 1, + children: [ + { + title: '操作记录', + type: 'collection', + path: '/activity/logs', + icon: 'HistoryOutlined', + template: 'collection', + collection: 'action_logs', + sort: 80, + showInMenu: true, + }, + ] + }); +})(); diff --git a/packages/app/src/api/migrations/init.ts b/packages/app/src/api/migrations/init.ts index 9e9d4738f..5ae5848b0 100644 --- a/packages/app/src/api/migrations/init.ts +++ b/packages/app/src/api/migrations/init.ts @@ -74,6 +74,27 @@ const data = [ }, ] }, + { + title: '动态', + type: 'layout', + path: '/activity', + icon: 'NotificationOutlined', + template: 'SideMenuLayout', + sort: 85, + showInMenu: true, + children: [ + { + title: '操作记录', + type: 'collection', + path: '/activity/logs', + icon: 'HistoryOutlined', + template: 'collection', + collection: 'action_logs', + sort: 80, + showInMenu: true, + }, + ] + }, { title: '配置', type: 'layout', diff --git a/packages/app/src/components/icons/index.tsx b/packages/app/src/components/icons/index.tsx index a0a819374..8975b514a 100644 --- a/packages/app/src/components/icons/index.tsx +++ b/packages/app/src/components/icons/index.tsx @@ -8,6 +8,8 @@ import { SettingOutlined, TableOutlined, MenuOutlined, + HistoryOutlined, + NotificationOutlined, } from '@ant-design/icons'; export const IconFont = createFromIconfontCN({ @@ -36,6 +38,7 @@ export function registerIcons(components) { } registerIcons({ + HistoryOutlined, MenuOutlined, TableOutlined, SettingOutlined, @@ -43,6 +46,7 @@ registerIcons({ UserOutlined, DatabaseOutlined, DashboardOutlined, + NotificationOutlined, }); interface IconProps { diff --git a/packages/app/src/components/views/Field/index.tsx b/packages/app/src/components/views/Field/index.tsx index a55856dd8..7068960d0 100644 --- a/packages/app/src/components/views/Field/index.tsx +++ b/packages/app/src/components/views/Field/index.tsx @@ -361,6 +361,20 @@ export function AttachmentFieldItem(props: any) { ); } +function LogField(props) { + const { value = {} } = props; + return ( +
{value.title||value.name}
+ ) +} + +function LogFieldValue(props) { + const { value, schema, data } = props; + return ( +
{value}
+ ) +} + registerFieldComponents({ string: StringField, textarea: TextareaField, @@ -380,10 +394,12 @@ registerFieldComponents({ subTable: SubTableField, linkTo: LinkToField, attachment: AttachmentField, + 'logs.field': LogField, + 'logs.fieldValue': LogFieldValue, }); export default function Field(props: any) { const { schema = {} } = props; - const Component = getFieldComponent(schema.interface); + const Component = getFieldComponent(schema.interface||get(schema, 'component.type')); return ; } diff --git a/packages/plugin-action-logs/package.json b/packages/plugin-action-logs/package.json new file mode 100644 index 000000000..babc448a2 --- /dev/null +++ b/packages/plugin-action-logs/package.json @@ -0,0 +1,10 @@ +{ + "name": "@nocobase/plugin-action-logs", + "version": "0.3.0-alpha.0", + "main": "lib/index.js", + "license": "MIT", + "dependencies": { + "@nocobase/database": "^0.3.0-alpha.0", + "@nocobase/resourcer": "^0.3.0-alpha.0" + } +} diff --git a/packages/plugin-action-logs/src/collections/action_changes.ts b/packages/plugin-action-logs/src/collections/action_changes.ts new file mode 100644 index 000000000..a30a33cf7 --- /dev/null +++ b/packages/plugin-action-logs/src/collections/action_changes.ts @@ -0,0 +1,57 @@ +import { TableOptions } from '@nocobase/database'; + +export default { + name: 'action_changes', + title: '变动值', + developerMode: true, + internal: true, + createdBy: false, + updatedBy: false, + createdAt: false, + updatedAt: false, + fields: [ + { + interface: 'linkTo', + type: 'belongsTo', + name: 'log', + target: 'action_logs', + title: '所属操作' + }, + { + type: 'jsonb', + name: 'field', + title: '字段信息', + component: { + type: 'logs.field', + showInTable: true, + }, + }, + { + type: 'jsonb', + name: 'before', + title: '操作前', + component: { + type: 'logs.fieldValue', + showInTable: true, + }, + }, + { + type: 'jsonb', + name: 'after', + title: '操作后', + component: { + type: 'logs.fieldValue', + showInTable: true, + }, + } + ], + views: [ + { + type: 'table', + name: 'table', + title: '列表', + template: 'Table', + default: true, + }, + ], +} as TableOptions; diff --git a/packages/plugin-action-logs/src/collections/action_logs.ts b/packages/plugin-action-logs/src/collections/action_logs.ts new file mode 100644 index 000000000..ad6e4cb64 --- /dev/null +++ b/packages/plugin-action-logs/src/collections/action_logs.ts @@ -0,0 +1,108 @@ +import { TableOptions } from '@nocobase/database'; + +export default { + name: 'action_logs', + title: '操作记录', + developerMode: true, + internal: true, + createdBy: false, + updatedBy: false, + updatedAt: false, + fields: [ + { + interface: 'createdAt', + name: 'created_at', + type: 'date', + title: '操作时间', + showTime: true, + component: { + showInTable: true, + showInDetail: true, + }, + }, + { + interface: 'linkTo', + type: 'belongsTo', + name: 'user', + target: 'users', + title: '操作用户', + appends: true, + labelField: 'nickname', + component: { + showInTable: true, + showInDetail: true, + }, + }, + { + interface: 'linkTo', + type: 'belongsTo', + name: 'collection', + target: 'collections', + title: '数据表', + targetKey: 'name', + appends: true, + labelField: 'title', + component: { + showInTable: true, + showInDetail: true, + }, + }, + { + interface: 'select', + type: 'string', + name: 'type', + title: '操作类型', + filterable: true, + dataSource: [ + { value: 'create', label: '新增' }, + { value: 'update', label: '更新' }, + { value: 'destroy', label: '删除' }, + ], + component: { + showInTable: true, + showInDetail: true, + }, + }, + { + type: 'integer', + name: 'index', + title: '对象索引', + component: { + showInTable: true, + }, + }, + { + interface: 'subTable', + type: 'hasMany', + name: 'changes', + target: 'action_changes', + title: '数据变动', + component: { + showInDetail: true, + }, + } + ], + actions: [ + { + type: 'list', + name: 'list', + title: '查看', + sort: '-created_at' + }, + ], + views: [ + { + type: 'table', + name: 'table', + title: '列表', + template: 'Table', + default: true + }, + { + type: 'details', + name: 'details', + title: '详情', + template: 'Details', + }, + ], +} as TableOptions; diff --git a/packages/plugin-action-logs/src/hooks/after-create.ts b/packages/plugin-action-logs/src/hooks/after-create.ts new file mode 100644 index 000000000..6e6b0cb2a --- /dev/null +++ b/packages/plugin-action-logs/src/hooks/after-create.ts @@ -0,0 +1,54 @@ +import { Field } from '@nocobase/database'; + +export default async function(model, options) { + if (!options.context) { + return; + } + const { database: db } = model; + const { context, transaction = await db.sequelize.transaction() } = options; + const { + state, + action: { + params: { + actionName, + resourceName, + } + } + } = context; + const ActionLog = db.getModel('action_logs'); + // 创建操作记录 + const log = await ActionLog.create({ + type: actionName, + collection_name: model.constructor.name, + index: model.get(model.constructor.primaryKeyAttribute), + created_at: model.get('created_at') + }, { + transaction + }); + + const fields = db.getTable(model.constructor.name).getFields(); + const fieldsList = Array.from(fields.values()); + const changes = []; + model.changed().forEach((key: string) => { + const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key); + if (field) { + changes.push({ + field: field.options, + after: model.get(key) + }); + } + }); + // TODO(bug): state.currentUser 不是 belongsTo field 的 target 实例 + // Sequelize 会另外创建一个 Model 的继承类,无法直传 instance + // await log.setUser(state.currentUser, { transaction }); + await log.updateAssociations({ + ...(state.currentUser ? { user: state.currentUser.id } : {}), + changes + }, { + transaction + }); + + if (!options.transaction) { + await transaction.commit(); + } +} diff --git a/packages/plugin-action-logs/src/hooks/after-destroy.ts b/packages/plugin-action-logs/src/hooks/after-destroy.ts new file mode 100644 index 000000000..938984ac7 --- /dev/null +++ b/packages/plugin-action-logs/src/hooks/after-destroy.ts @@ -0,0 +1,53 @@ +import { Field } from '@nocobase/database'; + +export default async function(model, options) { + if (!options.context) { + return; + } + const { database: db } = model; + const { context, transaction = await db.sequelize.transaction() } = options; + const { + state, + action: { + params: { + actionName, + resourceName, + } + } + } = context; + const ActionLog = db.getModel('action_logs'); + // 创建操作记录 + const log = await ActionLog.create({ + // user_id: state.currentUser ? state.currentUser.id : null, + type: actionName, + collection_name: model.constructor.name, + index: model.get(model.constructor.primaryKeyAttribute), + // created_at: model.get('created_at') + }, { + transaction + }); + + const fields = db.getTable(model.constructor.name).getFields(); + const fieldsList = Array.from(fields.values()); + const changes = []; + Object.keys(model.get()).forEach((key: string) => { + const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key); + if (field) { + changes.push({ + field: field.options, + before: model.get(key) + }); + } + }); + + await log.updateAssociations({ + ...(state.currentUser ? { user: state.currentUser.id } : {}), + changes + }, { + transaction + }); + + if (!options.transaction) { + await transaction.commit(); + } +} diff --git a/packages/plugin-action-logs/src/hooks/after-update.ts b/packages/plugin-action-logs/src/hooks/after-update.ts new file mode 100644 index 000000000..b02ad7231 --- /dev/null +++ b/packages/plugin-action-logs/src/hooks/after-update.ts @@ -0,0 +1,59 @@ +import { Field } from '@nocobase/database'; + +export default async function(model, options) { + if (!options.context) { + return; + } + const { database: db } = model; + const { context, transaction = await db.sequelize.transaction() } = options; + const { + state, + action: { + params: { + actionName, + resourceName, + } + } + } = context; + const ActionLog = db.getModel('action_logs'); + + const fields = db.getTable(model.constructor.name).getFields(); + const fieldsList = Array.from(fields.values()); + const changes = []; + + model.changed().forEach((key: string) => { + const field = fields.get(key) || fieldsList.find((item: Field) => item.options.field === key); + if (field && field.options.type !== 'formula') { + changes.push({ + field: field.options, + after: model.get(key), + before: model.previous(key) + }); + } + }); + + if (changes.length === 0) { + return; + } + + // 创建操作记录 + const log = await ActionLog.create({ + type: actionName, + collection_name: model.constructor.name, + index: model.get(model.constructor.primaryKeyAttribute), + created_at: model.get('updated_at') + }, { + transaction + }); + + await log.updateAssociations({ + ...(state.currentUser ? { user: state.currentUser.id } : {}), + changes + }, { + transaction + }); + + if (!options.transaction) { + await transaction.commit(); + } +} diff --git a/packages/plugin-action-logs/src/hooks/index.ts b/packages/plugin-action-logs/src/hooks/index.ts new file mode 100644 index 000000000..799f79683 --- /dev/null +++ b/packages/plugin-action-logs/src/hooks/index.ts @@ -0,0 +1,10 @@ +import afterCreate from './after-create'; +import afterUpdate from './after-update'; +import afterDestroy from './after-destroy'; + +export function addAll(Model) { + Model.addHook('afterCreate', afterCreate); + // Model.addHook('afterBulkCreate', hooks.afterBulkCreate); + Model.addHook('afterUpdate', afterUpdate); + Model.addHook('afterDestroy', afterDestroy); +} diff --git a/packages/plugin-action-logs/src/server.ts b/packages/plugin-action-logs/src/server.ts new file mode 100644 index 000000000..470ec6350 --- /dev/null +++ b/packages/plugin-action-logs/src/server.ts @@ -0,0 +1,20 @@ +import path from 'path'; + +import { addAll } from './hooks'; + +export default async function() { + const { database } = this; + + database.import({ + directory: path.resolve(__dirname, 'collections'), + }); + + // 为所有的表都加上日志的 hooks + database.addHook('afterTableInit', function (table) { + if (['action_logs', 'action_changes'].includes(table.options.name)) { + return; + } + const Model = database.getModel(table.options.name); + addAll(Model); + }); +} diff --git a/packages/plugin-pages/src/actions/getView.ts b/packages/plugin-pages/src/actions/getView.ts index 4f218a0ec..2193e63a4 100644 --- a/packages/plugin-pages/src/actions/getView.ts +++ b/packages/plugin-pages/src/actions/getView.ts @@ -157,6 +157,7 @@ const transforms = { return schema; }, details: async (fields: Model[], context?: any) => { + const [Field] = context.db.getModels(['fields']) as ModelCtor[]; const arr = []; for (const field of fields) { if (!get(field.component, 'showInDetail')) { @@ -167,10 +168,17 @@ const transforms = { } const props = {}; if (field.get('interface') === 'subTable') { - const children = await field.getChildren({ - order: [['sort', 'asc']], - }); - props['children'] = children.map(child => ({...child.toJSON(), dataIndex: child.name.split('.')})) + const children = await Field.findAll(Field.parseApiJson({ + filter: { + collection_name: field.get('target'), + }, + perPage: -1, + sort: ['sort'], + })); + // const children = await field.getChildren({ + // order: [['sort', 'asc']], + // }); + props['children'] = children.filter(item => item.get('component.showInTable')).map(child => ({...child.toJSON(), dataIndex: child.name.split('.')})) } arr.push({ ...field.toJSON(), diff --git a/packages/plugin-permissions/src/server.ts b/packages/plugin-permissions/src/server.ts index 87af2fa63..48b74af09 100644 --- a/packages/plugin-permissions/src/server.ts +++ b/packages/plugin-permissions/src/server.ts @@ -35,7 +35,6 @@ export class Permissions { }); database.getModel('collections').addHook('afterCreate', async (model: any, options) => { - // console.log('plugin-permissions hook'); await model.updateAssociations({ scopes: [ { diff --git a/packages/plugin-users/src/server.ts b/packages/plugin-users/src/server.ts index a19e539c6..de753d01f 100644 --- a/packages/plugin-users/src/server.ts +++ b/packages/plugin-users/src/server.ts @@ -18,10 +18,6 @@ export default async function (options = {}) { registerFields(fields); - database.import({ - directory: path.resolve(__dirname, 'collections'), - }); - database.addHook('afterTableInit', (table) => { let { createdBy, updatedBy, internal } = table.getOptions(); // 非内置表,默认创建 createdBy 和 updatedBy @@ -39,6 +35,10 @@ export default async function (options = {}) { .map(type => table.addField(makeOptions(type, fieldsToMake[type]))); }); + database.import({ + directory: path.resolve(__dirname, 'collections'), + }); + resourcer.registerActionHandlers({ 'users:login': login, 'users:register': register,