Feature/action logs (#61)

* feat: add action logs plugin

* feat: add afterUpdate/afterDestroy hooks for logs

* 子表格细节改进

* fix: subtable

* activity

* bugfix

Co-authored-by: mytharcher <mytharcher@gmail.com>
This commit is contained in:
chenos 2021-01-29 23:53:50 +08:00 committed by GitHub
parent 4ed21682ac
commit 1ea08e62b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 474 additions and 10 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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'), {}]);

View File

@ -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,
},
]
});
})();

View File

@ -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',

View File

@ -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 {

View File

@ -361,6 +361,20 @@ export function AttachmentFieldItem(props: any) {
);
}
function LogField(props) {
const { value = {} } = props;
return (
<div>{value.title||value.name}</div>
)
}
function LogFieldValue(props) {
const { value, schema, data } = props;
return (
<div>{value}</div>
)
}
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 <Component {...props}/>;
}

View File

@ -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"
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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);
});
}

View File

@ -157,6 +157,7 @@ const transforms = {
return schema;
},
details: async (fields: Model[], context?: any) => {
const [Field] = context.db.getModels(['fields']) as ModelCtor<Model>[];
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(),

View File

@ -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: [
{

View File

@ -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,