();
+ const compile = useCompile();
+ if (!field.value) {
+ return null;
+ }
+ return {field.value?.uiSchema?.title ? compile(field.value?.uiSchema?.title) : field.value.name}
;
+ },
+ { displayName: 'Field' },
+);
+
+const Value = observer(
+ () => {
+ const field = useField();
+ const record = ArrayTable.useRecord();
+ if (record.field?.uiSchema) {
+ return (
+
+
+
+ );
+ }
+ return {field.value ? JSON.stringify(field.value) : null}
;
+ },
+ { displayName: 'Value' },
+);
+
+const IsAssociationBlock = createContext(null);
+IsAssociationBlock.displayName = 'IsAssociationBlock';
+
+export const AuditLogs: any = () => {
+ const isAssoc = useContext(IsAssociationBlock);
+ const ext = {};
+ if (!isAssoc) {
+ ext['column31'] = {
+ type: 'void',
+ 'x-component': 'TableV2.Column',
+ title: '{{t("Record ID")}}',
+ properties: {
+ recordId: {
+ 'x-component': 'Input',
+ 'x-read-pretty': true,
+ },
+ },
+ };
+ }
+ return (
+
+ );
+};
+
+AuditLogs.Decorator = observer(
+ (props: any) => {
+ const parent = useCollection_deprecated();
+ const record = useRecord();
+ let filter = props?.params?.filter;
+ if (parent.name) {
+ const filterByTk = record?.[parent.filterTargetKey || 'id'];
+ if (filter) {
+ filter = {
+ $and: [
+ filter,
+ {
+ collectionName: parent.name,
+ recordId: `${filterByTk}`,
+ },
+ ],
+ };
+ } else {
+ filter = {
+ collectionName: parent.name,
+ recordId: `${filterByTk}`,
+ };
+ }
+ }
+ const defaults = {
+ collection: 'auditLogs',
+ resource: 'auditLogs',
+ action: 'list',
+ params: {
+ pageSize: 20,
+ appends: ['collection', 'user'],
+ ...props.params,
+ filter,
+ sort: '-createdAt',
+ },
+ rowKey: 'id',
+ showIndex: true,
+ dragSort: false,
+ };
+ return (
+
+
+ {props.children}
+
+
+ );
+ },
+ { displayName: 'AuditLogs.Decorator' },
+);
+
+AuditLogs.Designer = AuditLogsDesigner;
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/client/deplicated/AuditLogsDesigner.tsx b/packages/plugins/@hera/plugin-audit-logs/src/client/deplicated/AuditLogsDesigner.tsx
new file mode 100644
index 000000000..03f57cd8a
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/client/deplicated/AuditLogsDesigner.tsx
@@ -0,0 +1,77 @@
+import { useField, useFieldSchema } from '@formily/react';
+import {
+ GeneralSchemaDesigner,
+ SchemaSettingsDataScope,
+ SchemaSettingsDivider,
+ SchemaSettingsRemove,
+ SchemaSettingsSelectItem,
+ useCollection_deprecated,
+ useDesignable,
+ useFormBlockContext,
+ useTableBlockContext,
+} from '@nocobase/client';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+export const AuditLogsDesigner = () => {
+ const { name, title } = useCollection_deprecated();
+ const field = useField();
+ const fieldSchema = useFieldSchema();
+ const { form } = useFormBlockContext();
+ const { service } = useTableBlockContext();
+ const { t } = useTranslation();
+ const { dn } = useDesignable();
+ return (
+
+ {
+ const params = field.decoratorProps.params || {};
+ params.filter = filter;
+ field.decoratorProps.params = params;
+ fieldSchema['x-decorator-props']['params'] = params;
+ service.run({ ...service.params?.[0], filter, page: 1 });
+ dn.emit('patch', {
+ schema: {
+ ['x-uid']: fieldSchema['x-uid'],
+ 'x-decorator-props': fieldSchema['x-decorator-props'],
+ },
+ });
+ }}
+ />
+ {
+ const params = field.decoratorProps.params || {};
+ params.pageSize = pageSize;
+ field.decoratorProps.params = params;
+ fieldSchema['x-decorator-props']['params'] = params;
+ service.run({ ...service.params?.[0], pageSize, page: 1 });
+ dn.emit('patch', {
+ schema: {
+ ['x-uid']: fieldSchema['x-uid'],
+ 'x-decorator-props': fieldSchema['x-decorator-props'],
+ },
+ });
+ }}
+ />
+
+
+
+ );
+};
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/client/index.ts b/packages/plugins/@hera/plugin-audit-logs/src/client/index.ts
new file mode 100644
index 000000000..3cb402f30
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/client/index.ts
@@ -0,0 +1,39 @@
+import { Plugin, SchemaInitializerItemType } from '@nocobase/client';
+import { AuditLogsProvider } from './AuditLogsProvider';
+import {
+ auditLogsTableActionColumnInitializers,
+ auditLogsTableActionColumnInitializers_deprecated,
+} from './initializers/AuditLogsTableActionColumnInitializers';
+import {
+ auditLogsTableActionInitializers,
+ auditLogsTableActionInitializers_deprecated,
+} from './initializers/AuditLogsTableActionInitializers';
+import {
+ auditLogsTableColumnInitializers,
+ auditLogsTableColumnInitializers_deprecated,
+} from './initializers/AuditLogsTableColumnInitializers';
+export * from './AuditLogsBlockInitializer';
+export * from './AuditLogsProvider';
+
+export class AuditLogsPlugin extends Plugin {
+ async load() {
+ this.app.use(AuditLogsProvider);
+ this.app.schemaInitializerManager.add(auditLogsTableActionInitializers_deprecated);
+ this.app.schemaInitializerManager.add(auditLogsTableActionInitializers);
+ this.app.schemaInitializerManager.add(auditLogsTableActionColumnInitializers_deprecated);
+ this.app.schemaInitializerManager.add(auditLogsTableActionColumnInitializers);
+ this.app.schemaInitializerManager.add(auditLogsTableColumnInitializers_deprecated);
+ this.app.schemaInitializerManager.add(auditLogsTableColumnInitializers);
+
+ const blockInitializers = this.app.schemaInitializerManager.get('page:addBlock');
+ const recordBlockInitializers = this.app.schemaInitializerManager.get('popup:common:addBlock');
+ const auditLogs: Omit = {
+ title: '{{t("Audit logs")}}',
+ Component: 'AuditLogsBlockInitializer',
+ };
+ blockInitializers.add('otherBlocks.auditLogs', auditLogs);
+ recordBlockInitializers.add('otherBlocks.auditLogs', auditLogs);
+ }
+}
+
+export default AuditLogsPlugin;
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableActionColumnInitializer.tsx b/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableActionColumnInitializer.tsx
new file mode 100644
index 000000000..a045445a7
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableActionColumnInitializer.tsx
@@ -0,0 +1,27 @@
+import { InitializerWithSwitch, useSchemaInitializerItem } from '@nocobase/client';
+import React from 'react';
+
+export const AuditLogsTableActionColumnInitializer = () => {
+ const schema = {
+ type: 'void',
+ title: '{{ t("Actions") }}',
+ 'x-decorator': 'TableV2.Column.ActionBar',
+ 'x-component': 'TableV2.Column',
+ 'x-designer': 'TableV2.ActionColumnDesigner',
+ 'x-initializer': 'auditLogsTable:configureItemActions',
+ 'x-action-column': 'actions',
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-decorator': 'DndContext',
+ 'x-component': 'Space',
+ 'x-component-props': {
+ split: '|',
+ },
+ properties: {},
+ },
+ },
+ };
+ const itemConfig = useSchemaInitializerItem();
+ return ;
+};
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableActionColumnInitializers.tsx b/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableActionColumnInitializers.tsx
new file mode 100644
index 000000000..437f47899
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableActionColumnInitializers.tsx
@@ -0,0 +1,141 @@
+import { MenuOutlined } from '@ant-design/icons';
+import { useFieldSchema } from '@formily/react';
+import {
+ CompatibleSchemaInitializer,
+ createDesignable,
+ Resizable,
+ useAPIClient,
+ useDesignable,
+} from '@nocobase/client';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+/**
+ * @deprecated
+ */
+export const auditLogsTableActionColumnInitializers_deprecated = new CompatibleSchemaInitializer({
+ name: 'AuditLogsTableActionColumnInitializers',
+ insertPosition: 'beforeEnd',
+ Component: (props: any) => ,
+ useInsert() {
+ const fieldSchema = useFieldSchema();
+ const api = useAPIClient();
+ const { refresh } = useDesignable();
+ const { t } = useTranslation();
+
+ return (schema) => {
+ const spaceSchema = fieldSchema.reduceProperties((buf, schema) => {
+ if (schema['x-component'] === 'Space') {
+ return schema;
+ }
+ return buf;
+ }, null);
+ if (!spaceSchema) {
+ return;
+ }
+ const dn = createDesignable({
+ t,
+ api,
+ refresh,
+ current: spaceSchema,
+ });
+ dn.loadAPIClientEvents();
+ dn.insertBeforeEnd(schema);
+ };
+ },
+ items: [
+ {
+ name: 'enableActions',
+ type: 'itemGroup',
+ title: '{{t("Enable actions")}}',
+ children: [
+ {
+ name: 'view',
+ type: 'item',
+ title: '{{t("View")}}',
+ Component: 'AuditLogsViewActionInitializer',
+ schema: {
+ 'x-component': 'Action.Link',
+ 'x-action': 'view',
+ 'x-decorator': 'ACLActionProvider',
+ },
+ },
+ ],
+ },
+ {
+ name: 'divider',
+ type: 'divider',
+ },
+ {
+ name: 'columnWidth',
+ type: 'item',
+ title: '{{t("Column width")}}',
+ Component: Resizable,
+ },
+ ],
+});
+
+export const auditLogsTableActionColumnInitializers = new CompatibleSchemaInitializer(
+ {
+ name: 'auditLogsTable:configureItemActions',
+ insertPosition: 'beforeEnd',
+ Component: (props: any) => ,
+ useInsert() {
+ const fieldSchema = useFieldSchema();
+ const api = useAPIClient();
+ const { refresh } = useDesignable();
+ const { t } = useTranslation();
+
+ return (schema) => {
+ const spaceSchema = fieldSchema.reduceProperties((buf, schema) => {
+ if (schema['x-component'] === 'Space') {
+ return schema;
+ }
+ return buf;
+ }, null);
+ if (!spaceSchema) {
+ return;
+ }
+ const dn = createDesignable({
+ t,
+ api,
+ refresh,
+ current: spaceSchema,
+ });
+ dn.loadAPIClientEvents();
+ dn.insertBeforeEnd(schema);
+ };
+ },
+ items: [
+ {
+ name: 'enableActions',
+ type: 'itemGroup',
+ title: '{{t("Enable actions")}}',
+ children: [
+ {
+ name: 'view',
+ type: 'item',
+ title: '{{t("View")}}',
+ Component: 'AuditLogsViewActionInitializer',
+ schema: {
+ 'x-component': 'Action.Link',
+ 'x-action': 'view',
+ 'x-decorator': 'ACLActionProvider',
+ },
+ },
+ ],
+ },
+ {
+ name: 'divider',
+ type: 'divider',
+ },
+ {
+ name: 'columnWidth',
+ type: 'item',
+ title: '{{t("Column width")}}',
+ Component: Resizable,
+ },
+ ],
+ },
+ auditLogsTableActionColumnInitializers_deprecated,
+);
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableActionInitializers.tsx b/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableActionInitializers.tsx
new file mode 100644
index 000000000..41b74bec4
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableActionInitializers.tsx
@@ -0,0 +1,76 @@
+import { CompatibleSchemaInitializer } from '@nocobase/client';
+
+/**
+ * @deprecated
+ * 操作记录表格操作配置
+ */
+export const auditLogsTableActionInitializers_deprecated = new CompatibleSchemaInitializer({
+ name: 'AuditLogsTableActionInitializers',
+ title: "{{t('Configure actions')}}",
+ icon: 'SettingOutlined',
+ style: {
+ marginLeft: 8,
+ },
+ items: [
+ {
+ type: 'itemGroup',
+ title: "{{t('Enable actions')}}",
+ name: 'enableActions',
+ children: [
+ {
+ name: 'filter',
+ title: "{{t('Filter')}}",
+ Component: 'FilterActionInitializer',
+ schema: {
+ 'x-align': 'left',
+ },
+ },
+ {
+ name: 'refresh',
+ title: "{{t('Refresh')}}",
+ Component: 'RefreshActionInitializer',
+ schema: {
+ 'x-align': 'right',
+ },
+ },
+ ],
+ },
+ ],
+});
+
+export const auditLogsTableActionInitializers = new CompatibleSchemaInitializer(
+ {
+ name: 'auditLogsTable:configureActions',
+ title: "{{t('Configure actions')}}",
+ icon: 'SettingOutlined',
+ style: {
+ marginLeft: 8,
+ },
+ items: [
+ {
+ type: 'itemGroup',
+ title: "{{t('Enable actions')}}",
+ name: 'enableActions',
+ children: [
+ {
+ name: 'filter',
+ title: "{{t('Filter')}}",
+ Component: 'FilterActionInitializer',
+ schema: {
+ 'x-align': 'left',
+ },
+ },
+ {
+ name: 'refresh',
+ title: "{{t('Refresh')}}",
+ Component: 'RefreshActionInitializer',
+ schema: {
+ 'x-align': 'right',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ auditLogsTableActionInitializers_deprecated,
+);
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableColumnInitializers.tsx b/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableColumnInitializers.tsx
new file mode 100644
index 000000000..81ae12a81
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/client/initializers/AuditLogsTableColumnInitializers.tsx
@@ -0,0 +1,139 @@
+import {
+ CompatibleSchemaInitializer,
+ SchemaInitializerChildren,
+ useAssociatedTableColumnInitializerFields,
+ useCompile,
+ useInheritsTableColumnInitializerFields,
+ useTableColumnInitializerFields,
+} from '@nocobase/client';
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+
+// 表格列配置
+const ParentCollectionFields = () => {
+ const inheritFields = useInheritsTableColumnInitializerFields();
+ const { t } = useTranslation();
+ const compile = useCompile();
+ if (!inheritFields?.length) return null;
+ const res = [];
+ inheritFields.forEach((inherit) => {
+ Object.values(inherit)[0].length &&
+ res.push({
+ type: 'itemGroup',
+ divider: true,
+ title: t(`Parent collection fields`) + '(' + compile(`${Object.keys(inherit)[0]}`) + ')',
+ children: Object.values(inherit)[0].filter((v: any) => !v?.field?.isForeignKey),
+ });
+ });
+ return {res};
+};
+
+const AssociatedFields = () => {
+ const associatedFields = useAssociatedTableColumnInitializerFields();
+ const { t } = useTranslation();
+
+ if (!associatedFields?.length) return null;
+ const schema: any = [
+ {
+ type: 'itemGroup',
+ divider: true,
+ title: t('Display association fields'),
+ children: associatedFields,
+ },
+ ];
+ return {schema};
+};
+
+/**
+ * @deprecated
+ */
+export const auditLogsTableColumnInitializers_deprecated = new CompatibleSchemaInitializer({
+ name: 'AuditLogsTableColumnInitializers',
+ insertPosition: 'beforeEnd',
+ icon: 'SettingOutlined',
+ title: '{{t("Configure columns")}}',
+ wrap(s) {
+ if (s['x-action-column']) {
+ return s;
+ }
+ return {
+ type: 'void',
+ 'x-decorator': 'TableV2.Column.Decorator',
+ 'x-designer': 'TableV2.Column.Designer',
+ 'x-component': 'TableV2.Column',
+ properties: {
+ [s.name]: {
+ ...s,
+ },
+ },
+ };
+ },
+ items: [
+ {
+ name: 'displayFields',
+ type: 'itemGroup',
+ title: '{{t("Display fields")}}',
+ useChildren: useTableColumnInitializerFields,
+ },
+ {
+ name: 'parentCollectionFields',
+ Component: ParentCollectionFields,
+ },
+ {
+ name: 'associationFields',
+ Component: AssociatedFields,
+ },
+ {
+ name: 'actionColumn',
+ title: '{{t("Action column")}}',
+ Component: 'AuditLogsTableActionColumnInitializer',
+ },
+ ],
+});
+
+export const auditLogsTableColumnInitializers = new CompatibleSchemaInitializer(
+ {
+ name: 'auditLogsTable:configureColumns',
+ insertPosition: 'beforeEnd',
+ icon: 'SettingOutlined',
+ title: '{{t("Configure columns")}}',
+ wrap(s) {
+ if (s['x-action-column']) {
+ return s;
+ }
+ return {
+ type: 'void',
+ 'x-decorator': 'TableV2.Column.Decorator',
+ 'x-designer': 'TableV2.Column.Designer',
+ 'x-component': 'TableV2.Column',
+ properties: {
+ [s.name]: {
+ ...s,
+ },
+ },
+ };
+ },
+ items: [
+ {
+ name: 'displayFields',
+ type: 'itemGroup',
+ title: '{{t("Display fields")}}',
+ useChildren: useTableColumnInitializerFields,
+ },
+ {
+ name: 'parentCollectionFields',
+ Component: ParentCollectionFields,
+ },
+ {
+ name: 'associationFields',
+ Component: AssociatedFields,
+ },
+ {
+ name: 'actionColumn',
+ title: '{{t("Action column")}}',
+ Component: 'AuditLogsTableActionColumnInitializer',
+ },
+ ],
+ },
+ auditLogsTableColumnInitializers_deprecated,
+);
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/client/locale/index.ts b/packages/plugins/@hera/plugin-audit-logs/src/client/locale/index.ts
new file mode 100644
index 000000000..799a97370
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/client/locale/index.ts
@@ -0,0 +1,14 @@
+import { i18n, tval as nTval } from '@nocobase/client';
+import { useTranslation } from 'react-i18next';
+
+export const NAMESPACE = 'audit-logs';
+
+export function lang(key: string) {
+ return i18n.t(key, { ns: NAMESPACE });
+}
+
+export function useAuditLogsTranslation() {
+ return useTranslation(NAMESPACE);
+}
+
+export const tval = (key: string) => nTval(key, { ns: NAMESPACE });
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/client/utils.ts b/packages/plugins/@hera/plugin-audit-logs/src/client/utils.ts
new file mode 100644
index 000000000..1ecc63443
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/client/utils.ts
@@ -0,0 +1,303 @@
+import { uid } from '@formily/shared';
+
+export const createSchema = () => {
+ const filterSchema = {
+ 'x-component': 'Action',
+ 'x-component-props': {
+ popover: true,
+ },
+ type: 'void',
+ title: '{{t("Filter")}}',
+ properties: {
+ popover: {
+ type: 'void',
+ 'x-decorator': 'Form',
+ 'x-decorator-props': {},
+ 'x-component': 'Action.Popover',
+ 'x-component-props': {
+ trigger: 'click',
+ placement: 'bottomLeft',
+ },
+ properties: {
+ filter: {
+ type: 'object',
+ default: {
+ $and: [{}],
+ },
+ 'x-component': 'Filter',
+ 'x-component-props': {
+ useDataSource: '{{cm.useFilterDataSource}}',
+ },
+ },
+ footer: {
+ type: 'void',
+ 'x-component': 'Action.Popover.Footer',
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'ActionBar',
+ properties: {
+ saveDefault: {
+ type: 'void',
+ 'x-component': 'Filter.SaveDefaultValue',
+ 'x-component-props': {},
+ },
+ cancel: {
+ type: 'void',
+ title: '{{t("Cancel")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ useAction: '{{cm.useCancelFilterAction}}',
+ },
+ },
+ submit: {
+ type: 'void',
+ title: '{{t("Submit")}}',
+ 'x-component': 'Action',
+ 'x-component-props': {
+ type: 'primary',
+ useAction: '{{cm.useFilterAction}}',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ const schema = {
+ type: 'void',
+ name: 'actionLog',
+ 'x-decorator': 'ResourceActionProvider',
+ 'x-decorator-props': {
+ collection: 'action_logs',
+ dragSort: false,
+ request: {
+ resource: 'action_logs',
+ action: 'list',
+ params: {
+ pageSize: 20,
+ filter: {},
+ appends: [],
+ },
+ },
+ },
+ 'x-designer': 'ActionLog.Designer',
+ 'x-component': 'CardItem',
+ properties: {
+ actions: {
+ type: 'void',
+ 'x-component': 'ActionBar',
+ 'x-component-props': {
+ layout: 'one-column',
+ style: {
+ marginBottom: 16,
+ },
+ },
+ properties: {
+ [uid()]: { ...filterSchema },
+ },
+ },
+ table: {
+ type: 'void',
+ 'x-component': 'Table.Void',
+ 'x-component-props': {
+ rowKey: 'id',
+ useDataSource: '{{cm.useDataSourceFromRAC}}',
+ },
+ properties: {
+ column1: {
+ type: 'void',
+ title: "{{t('Created at')}}",
+ 'x-component': 'Table.Column',
+ properties: {
+ created_at: {
+ type: 'string',
+ 'x-component': 'DatePicker',
+ 'x-read-pretty': true,
+ 'x-component-props': {
+ format: 'YYYY-MM-DD HH:mm:ss',
+ },
+ },
+ },
+ },
+ column2: {
+ type: 'void',
+ title: "{{t('Created by')}}",
+ 'x-component': 'Table.Column',
+ properties: {
+ 'user.nickname': {
+ type: 'string',
+ 'x-component': 'Input',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ column3: {
+ type: 'void',
+ title: "{{t('Collection display name')}}",
+ 'x-component': 'Table.Column',
+ properties: {
+ 'collection.title': {
+ type: 'string',
+ 'x-component': 'Input',
+ 'x-read-pretty': true,
+ },
+ },
+ },
+ column4: {
+ type: 'void',
+ title: "{{t('Action type')}}",
+ 'x-component': 'Table.Column',
+ properties: {
+ type: {
+ type: 'string',
+ 'x-component': 'Select',
+ 'x-read-pretty': true,
+ enum: [
+ { label: "{{ t('Add new') }}", value: 'create', color: 'green' },
+ { label: "{{ t('Update') }}", value: 'update', color: 'blue' },
+ { label: "{{ t('Delete') }}", value: 'destroy', color: 'red' },
+ ],
+ },
+ },
+ },
+ [uid()]: {
+ type: 'void',
+ title: "{{t('Actions')}}",
+ 'x-component': 'Table.Column',
+ 'x-component-props': {
+ width: 60,
+ align: 'center',
+ },
+ properties: {
+ [uid()]: {
+ title: '{{ t("View") }}',
+ type: 'void',
+ 'x-action': 'view',
+ 'x-component': 'Action.Link',
+ 'x-component-props': {
+ openMode: 'drawer',
+ },
+ properties: {
+ drawer: {
+ type: 'void',
+ 'x-component': 'Action.Container',
+ 'x-component-props': {
+ className: 'nb-action-popup',
+ },
+ title: '{{ t("View record") }}',
+ properties: {
+ created_at: {
+ type: 'string',
+ title: "{{t('Created at')}}",
+ 'x-decorator': 'FormItem',
+ 'x-component': 'DatePicker',
+ 'x-read-pretty': true,
+ 'x-component-props': {
+ format: 'YYYY-MM-DD HH:mm:ss',
+ },
+ },
+ 'user.nickname': {
+ type: 'string',
+ title: "{{t('Created by')}}",
+ 'x-decorator': 'FormItem',
+ 'x-component': 'Input',
+ 'x-read-pretty': true,
+ },
+ 'collection.title': {
+ type: 'string',
+ title: "{{t('Collection display name')}}",
+ 'x-decorator': 'FormItem',
+ 'x-component': 'Input',
+ 'x-read-pretty': true,
+ },
+ type: {
+ type: 'string',
+ title: "{{t('Action type')}}",
+ 'x-decorator': 'FormItem',
+ 'x-component': 'Select',
+ 'x-read-pretty': true,
+ enum: [
+ {
+ label: "{{t('Insert')}}",
+ value: 'create',
+ color: 'green',
+ },
+ {
+ label: "{{t('Update')}}",
+ value: 'update',
+ color: 'blue',
+ },
+ {
+ label: "{{t('Delete')}}",
+ value: 'destroy',
+ color: 'red',
+ },
+ ],
+ },
+ changes: {
+ type: 'array',
+ title: "{{t('Data changes')}}",
+ 'x-decorator': 'FormItem',
+ 'x-component': 'Table.Array',
+ 'x-component-props': {
+ pagination: false,
+ showIndex: true,
+ },
+ items: {
+ type: 'object',
+ properties: {
+ column1: {
+ type: 'void',
+ 'x-component': 'Table.Column',
+ 'x-component-props': { title: "{{t('Field display name')}}" },
+ properties: {
+ field: {
+ type: 'string',
+ 'x-decorator': 'FormilyFormItem',
+ 'x-component': 'ActionLog.Field',
+ },
+ },
+ },
+ column2: {
+ type: 'void',
+ 'x-component': 'Table.Column',
+ 'x-component-props': { title: "{{ t('Before change') }}" },
+ properties: {
+ before: {
+ type: 'string',
+ 'x-decorator': 'FormilyFormItem',
+ 'x-component': 'ActionLog.FieldValue',
+ },
+ },
+ },
+ column3: {
+ type: 'void',
+ 'x-component': 'Table.Column',
+ 'x-component-props': { title: "{{ t('After change') }}" },
+ properties: {
+ after: {
+ type: 'string',
+ 'x-decorator': 'FormilyFormItem',
+ 'x-component': 'ActionLog.FieldValue',
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ };
+ return schema;
+};
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/index.ts b/packages/plugins/@hera/plugin-audit-logs/src/index.ts
new file mode 100644
index 000000000..7e74612df
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/index.ts
@@ -0,0 +1,2 @@
+export * from './server';
+export { default } from './server';
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/locale/en-US.json b/packages/plugins/@hera/plugin-audit-logs/src/locale/en-US.json
new file mode 100644
index 000000000..3dc09f65a
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/locale/en-US.json
@@ -0,0 +1,4 @@
+{
+ "Details of changes": "Details of changes",
+ "Create record": "Create record"
+}
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/locale/zh-CN.json b/packages/plugins/@hera/plugin-audit-logs/src/locale/zh-CN.json
new file mode 100644
index 000000000..783f355b5
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/locale/zh-CN.json
@@ -0,0 +1,4 @@
+{
+ "Details of changes": "变更详情",
+ "Create record": "创建数据"
+}
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/server/__tests__/hook.test.ts b/packages/plugins/@hera/plugin-audit-logs/src/server/__tests__/hook.test.ts
new file mode 100644
index 000000000..4f8920a04
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/server/__tests__/hook.test.ts
@@ -0,0 +1,117 @@
+import Database from '@nocobase/database';
+import { createMockServer, MockServer } from '@nocobase/test';
+
+describe('hook', () => {
+ let api: MockServer;
+ let db: Database;
+
+ beforeEach(async () => {
+ api = await createMockServer({
+ plugins: ['audit-logs'],
+ });
+ db = api.db;
+ db.collection({
+ name: 'posts',
+ logging: true,
+ fields: [
+ {
+ type: 'string',
+ name: 'title',
+ },
+ {
+ type: 'string',
+ name: 'status',
+ defaultValue: 'draft',
+ },
+ ],
+ });
+ db.collection({
+ name: 'users',
+ logging: false,
+ fields: [
+ { type: 'string', name: 'nickname' },
+ { type: 'string', name: 'token' },
+ ],
+ });
+ await db.sync();
+ });
+
+ afterEach(async () => {
+ await api.destroy();
+ });
+
+ it('model', async () => {
+ const Post = db.getCollection('posts').model;
+ const post = await Post.create({ title: 't1' });
+ await post.update({ title: 't2' });
+ await post.destroy();
+ const auditLogs = await db.getCollection('auditLogs').repository.find({
+ appends: ['changes'],
+ });
+ expect(auditLogs.length).toBe(3);
+
+ const titleChange = (changes) => {
+ return changes.find((item) => item.field.name === 'title');
+ };
+
+ expect(titleChange(auditLogs[0].changes).before).toBeNull();
+ expect(titleChange(auditLogs[0].changes).after).toBe('t1');
+
+ expect(titleChange(auditLogs[1].changes).before).toBe('t1');
+ expect(titleChange(auditLogs[1].changes).after).toBe('t2');
+
+ expect(titleChange(auditLogs[2].changes).before).toBe('t2');
+ });
+
+ it('repository', async () => {
+ const Post = db.getCollection('posts');
+ const User = db.getCollection('users').model;
+ const user = await User.create({ nickname: 'a', token: 'token1' });
+ const post = await Post.repository.create({
+ values: { title: 't1' },
+ context: {
+ state: {
+ currentUser: user,
+ },
+ },
+ });
+ const AuditLog = db.getCollection('auditLogs');
+ const log = await AuditLog.repository.findOne({
+ appends: ['changes'],
+ });
+ expect(log.toJSON()).toMatchObject({
+ collectionName: 'posts',
+ type: 'create',
+ userId: 1,
+ recordId: `${post.get('id')}`,
+ changes: [
+ {
+ field: {
+ name: 'title',
+ type: 'string',
+ },
+ before: null,
+ after: 't1',
+ },
+ ],
+ });
+ });
+
+ // it.skip('resource', async () => {
+ // const agent = api.agent();
+ // agent.set('Authorization', `Bearer token1`);
+ // const response = await agent.resource('posts').create({
+ // values: { title: 't1' },
+ // });
+ // await agent.resource('posts').update({
+ // resourceIndex: response.body.data.id,
+ // values: { title: 't2' },
+ // });
+ // await agent.resource('posts').destroy({
+ // resourceIndex: response.body.data.id,
+ // });
+ // const ActionLog = db.getCollection('action_logs').model;
+ // const count = await ActionLog.count();
+ // expect(count).toBe(3);
+ // });
+});
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/server/collections/auditChanges.ts b/packages/plugins/@hera/plugin-audit-logs/src/server/collections/auditChanges.ts
new file mode 100644
index 000000000..c0713a451
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/server/collections/auditChanges.ts
@@ -0,0 +1,34 @@
+import { defineCollection } from '@nocobase/database';
+
+export default defineCollection({
+ dumpRules: {
+ group: 'log',
+ },
+ name: 'auditChanges',
+ title: '变动值',
+ createdBy: false,
+ updatedBy: false,
+ createdAt: false,
+ updatedAt: false,
+ shared: true,
+ fields: [
+ {
+ type: 'json',
+ name: 'field',
+ },
+ {
+ type: 'json',
+ name: 'before',
+ },
+ {
+ type: 'json',
+ name: 'after',
+ },
+ {
+ type: 'belongsTo',
+ name: 'log',
+ target: 'auditLogs',
+ foreignKey: 'auditLogId',
+ },
+ ],
+});
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/server/collections/auditLogs.ts b/packages/plugins/@hera/plugin-audit-logs/src/server/collections/auditLogs.ts
new file mode 100644
index 000000000..76609b423
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/server/collections/auditLogs.ts
@@ -0,0 +1,51 @@
+import { defineCollection } from '@nocobase/database';
+
+export default defineCollection({
+ dumpRules: {
+ group: 'log',
+ },
+ name: 'auditLogs',
+ createdBy: false,
+ updatedBy: false,
+ updatedAt: false,
+ shared: true,
+ fields: [
+ {
+ type: 'date',
+ name: 'createdAt',
+ },
+ {
+ type: 'string',
+ name: 'type',
+ },
+ {
+ type: 'string',
+ name: 'recordId',
+ index: true,
+ },
+ {
+ type: 'string',
+ name: 'collectionName',
+ },
+ {
+ type: 'belongsTo',
+ name: 'collection',
+ target: 'collections',
+ targetKey: 'name',
+ sourceKey: 'id',
+ foreignKey: 'collectionName',
+ constraints: false,
+ },
+ {
+ type: 'hasMany',
+ name: 'changes',
+ target: 'auditChanges',
+ foreignKey: 'auditLogId',
+ },
+ {
+ type: 'belongsTo',
+ name: 'user',
+ target: 'users',
+ },
+ ],
+});
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/server/constants.ts b/packages/plugins/@hera/plugin-audit-logs/src/server/constants.ts
new file mode 100644
index 000000000..0cf75c724
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/server/constants.ts
@@ -0,0 +1,3 @@
+export const LOG_TYPE_CREATE = 'create';
+export const LOG_TYPE_UPDATE = 'update';
+export const LOG_TYPE_DESTROY = 'destroy';
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/after-create.ts b/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/after-create.ts
new file mode 100644
index 000000000..4f6f480c2
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/after-create.ts
@@ -0,0 +1,45 @@
+import { LOG_TYPE_CREATE } from '../constants';
+
+export async function afterCreate(model, options) {
+ if (options.logging === false) {
+ return;
+ }
+ const { collection } = model.constructor;
+ if (!collection || !collection.options.logging) {
+ return;
+ }
+ const transaction = options.transaction;
+ const AuditLog = model.constructor.database.getCollection('auditLogs');
+ const currentUserId = options?.context?.state?.currentUser?.id;
+ try {
+ const changes = [];
+ const changed = model.changed();
+ if (changed) {
+ changed.forEach((key: string) => {
+ const field = collection.findField((field) => {
+ return field.name === key || field.options.field === key;
+ });
+ if (field && !field.options.hidden) {
+ changes.push({
+ field: field.options,
+ after: model.get(key),
+ });
+ }
+ });
+ }
+ await AuditLog.repository.create({
+ values: {
+ type: LOG_TYPE_CREATE,
+ collectionName: model.constructor.name,
+ recordId: model.get(model.constructor.primaryKeyAttribute),
+ createdAt: model.get('createdAt'),
+ userId: currentUserId,
+ changes,
+ },
+ transaction,
+ hooks: false,
+ });
+ } catch (error) {
+ // console.error(error);
+ }
+}
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/after-destroy.ts b/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/after-destroy.ts
new file mode 100644
index 000000000..4a7b8fa59
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/after-destroy.ts
@@ -0,0 +1,43 @@
+import { LOG_TYPE_DESTROY } from '../constants';
+
+export async function afterDestroy(model, options) {
+ const { collection } = model.constructor;
+ if (!collection || !collection.options.logging) {
+ return;
+ }
+ const transaction = options.transaction;
+ const AuditLog = model.constructor.database.getCollection('auditLogs');
+ const currentUserId = options?.context?.state?.currentUser?.id;
+ try {
+ const changes = [];
+ Object.keys(model.get()).forEach((key: string) => {
+ const field = collection.findField((field) => {
+ return field.name === key || field.options.field === key;
+ });
+ if (field) {
+ changes.push({
+ field: field.options,
+ before: model.get(key),
+ });
+ }
+ });
+ await AuditLog.repository.create({
+ values: {
+ type: LOG_TYPE_DESTROY,
+ collectionName: model.constructor.name,
+ recordId: model.get(model.constructor.primaryKeyAttribute),
+ userId: currentUserId,
+ changes,
+ },
+ transaction,
+ hooks: false,
+ });
+ // if (!options.transaction) {
+ // await transaction.commit();
+ // }
+ } catch (error) {
+ // if (!options.transaction) {
+ // await transaction.rollback();
+ // }
+ }
+}
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/after-update.ts b/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/after-update.ts
new file mode 100644
index 000000000..72407e63d
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/after-update.ts
@@ -0,0 +1,52 @@
+import { LOG_TYPE_UPDATE } from '../constants';
+
+export async function afterUpdate(model, options) {
+ const { collection } = model.constructor;
+ if (!collection || !collection.options.logging) {
+ return;
+ }
+ const changed = model.changed();
+ if (!changed) {
+ return;
+ }
+ const transaction = options.transaction;
+ const AuditLog = model.constructor.database.getCollection('auditLogs');
+ const currentUserId = options?.context?.state?.currentUser?.id;
+ const changes = [];
+ changed.forEach((key: string) => {
+ const field = collection.findField((field) => {
+ return field.name === key || field.options.field === key;
+ });
+ if (field && !field.options.hidden) {
+ changes.push({
+ field: field.options,
+ after: model.get(key),
+ before: model.previous(key),
+ });
+ }
+ });
+ if (!changes.length) {
+ return;
+ }
+ try {
+ await AuditLog.repository.create({
+ values: {
+ type: LOG_TYPE_UPDATE,
+ collectionName: model.constructor.name,
+ recordId: model.get(model.constructor.primaryKeyAttribute),
+ createdAt: model.get('updatedAt'),
+ userId: currentUserId,
+ changes,
+ },
+ transaction,
+ hooks: false,
+ });
+ // if (!options.transaction) {
+ // await transaction.commit();
+ // }
+ } catch (error) {
+ // if (!options.transaction) {
+ // await transaction.rollback();
+ // }
+ }
+}
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/index.ts b/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/index.ts
new file mode 100644
index 000000000..3c84cf55e
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/server/hooks/index.ts
@@ -0,0 +1,3 @@
+export * from './after-create';
+export * from './after-update';
+export * from './after-destroy';
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/server/index.ts b/packages/plugins/@hera/plugin-audit-logs/src/server/index.ts
new file mode 100644
index 000000000..7093d6227
--- /dev/null
+++ b/packages/plugins/@hera/plugin-audit-logs/src/server/index.ts
@@ -0,0 +1,23 @@
+import { Plugin } from '@nocobase/server';
+import path from 'path';
+import { afterCreate, afterDestroy, afterUpdate } from './hooks';
+
+export default class PluginActionLogs extends Plugin {
+ async beforeLoad() {
+ this.db.on('afterCreate', afterCreate);
+ this.db.on('afterUpdate', afterUpdate);
+ this.db.on('afterDestroy', afterDestroy);
+ }
+
+ async load() {
+ await this.importCollections(path.resolve(__dirname, 'collections'));
+
+ this.db.addMigrations({
+ namespace: 'audit-logs',
+ directory: path.resolve(__dirname, './migrations'),
+ context: {
+ plugin: this,
+ },
+ });
+ }
+}
diff --git a/packages/plugins/@hera/plugin-audit-logs/src/server/migrations/.gitkeep b/packages/plugins/@hera/plugin-audit-logs/src/server/migrations/.gitkeep
new file mode 100644
index 000000000..e69de29bb