mirror of
https://github.com/nocobase/nocobase
synced 2024-11-15 09:47:10 +00:00
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:
parent
4ed21682ac
commit
1ea08e62b6
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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'), {}]);
|
||||
|
42
packages/app/src/api/migrations/create-action-logs-table.ts
Normal file
42
packages/app/src/api/migrations/create-action-logs-table.ts
Normal 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,
|
||||
},
|
||||
]
|
||||
});
|
||||
})();
|
@ -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',
|
||||
|
@ -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 {
|
||||
|
@ -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}/>;
|
||||
}
|
||||
|
10
packages/plugin-action-logs/package.json
Normal file
10
packages/plugin-action-logs/package.json
Normal 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"
|
||||
}
|
||||
}
|
@ -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;
|
108
packages/plugin-action-logs/src/collections/action_logs.ts
Normal file
108
packages/plugin-action-logs/src/collections/action_logs.ts
Normal 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;
|
54
packages/plugin-action-logs/src/hooks/after-create.ts
Normal file
54
packages/plugin-action-logs/src/hooks/after-create.ts
Normal 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();
|
||||
}
|
||||
}
|
53
packages/plugin-action-logs/src/hooks/after-destroy.ts
Normal file
53
packages/plugin-action-logs/src/hooks/after-destroy.ts
Normal 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();
|
||||
}
|
||||
}
|
59
packages/plugin-action-logs/src/hooks/after-update.ts
Normal file
59
packages/plugin-action-logs/src/hooks/after-update.ts
Normal 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();
|
||||
}
|
||||
}
|
10
packages/plugin-action-logs/src/hooks/index.ts
Normal file
10
packages/plugin-action-logs/src/hooks/index.ts
Normal 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);
|
||||
}
|
20
packages/plugin-action-logs/src/server.ts
Normal file
20
packages/plugin-action-logs/src/server.ts
Normal 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);
|
||||
});
|
||||
}
|
@ -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(),
|
||||
|
@ -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: [
|
||||
{
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user